refactor(programs): clarify persistence and transaction flow

This commit is contained in:
fy59 2026-09-18 16:18:34 +02:00
parent 679ed6184a
commit ed9395366e
32 changed files with 3732 additions and 4111 deletions

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24):
raise CausalError(f"desktop schema v20-v24 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25):
raise CausalError(f"desktop schema v20-v25 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24):
raise LifecycleError(f"desktop schema v19-v24 required, found v{version}")
if version not in (19, 20, 21, 22, 23, 24, 25):
raise LifecycleError(f"desktop schema v19-v25 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",

View file

@ -20,8 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v18-v24 requis")
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v25 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,7 +28,8 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = tuple(range(8, 26))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
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(

View file

@ -24,7 +24,8 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = tuple(range(8, 26))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v12-v24 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25):
raise ValueError("schema desktop v12-v25 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,7 +41,8 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = tuple(range(11, 26))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25):
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,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
if version not in range(8, 26):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,9 +83,11 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25):
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, 21, 22, 23, 24) and not (args.version == 2 and schema_version == 10):
supported_versions = range(11, 26)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():

View file

@ -17,8 +17,9 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
if connection.execute("PRAGMA user_version").fetchone()[0] != 24:
raise ValueError("desktop schema v24 required")
supported_versions = (24, 25)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24 or v25 required")
deliveries = []
rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"

View file

@ -10,7 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23, 24): raise ValueError("schema desktop v15-v24 requis")
supported_versions = range(15, 26)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v25 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -193,8 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
raise ImportFailure("schema desktop v18-v24 requis")
supported_versions = range(18, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v25 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")

View file

@ -187,7 +187,8 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = range(8, 26)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export
if proof_path is None:

View file

@ -53,7 +53,8 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = range(8, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")

View file

@ -56,7 +56,8 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = range(12, 26)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()

View file

@ -151,7 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23,24): raise ImportFailure("schema desktop v11-v24 requis")
supported_versions = range(11, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v25 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -49,7 +49,9 @@ def validate(item):
def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23,24): fail("schema desktop v17-v24 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25):
fail("schema desktop v17-v25 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -846,9 +846,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
if version not in range(5, 26):
raise ImportFailure(
f"base desktop schema v5 à v24 attendue, version trouvée: {version}"
f"base desktop schema v5 à v25 attendue, version trouvée: {version}"
)
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v24")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25")
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")}

View file

@ -52,7 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23,24):fail("schema desktop v15-v24 requis")
supported_versions = range(15, 26)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v25 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -201,8 +201,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 24:
raise GenerationError("desktop schema v24 required")
supported_versions = (24, 25)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 or v25 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:

View file

@ -1,7 +1,8 @@
/*
* Trainlog database interface.
*
* Declares the module boundary and ownership contract; implementation and persistence remain in their owning modules.
* Declares the module boundary and ownership contract; implementation and persistence remain in
* their owning modules.
*/
#ifndef TRAINLOG_DATABASE_H
#define TRAINLOG_DATABASE_H
@ -16,7 +17,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 24
#define TRAINLOG_DATABASE_SCHEMA_VERSION 25
typedef struct TrainlogDatabase TrainlogDatabase;
@ -31,16 +32,20 @@ typedef struct TrainlogFeedbackView {
/* CONTRACT: read-only chronological consultation; Android remains the sole
* V1 creator. Ties use the stable ID byte order. No SQLite resources escape. */
TrainlogStatus trainlog_database_list_training_feedback(
TrainlogDatabase *database, const char *session_id,
TrainlogFeedbackView *exercise_feedback, size_t exercise_capacity,
size_t *exercise_count, TrainlogFeedbackView *followups,
size_t followup_capacity, size_t *followup_count);
TrainlogStatus trainlog_database_list_training_feedback(TrainlogDatabase *database,
const char *session_id,
TrainlogFeedbackView *exercise_feedback,
size_t exercise_capacity,
size_t *exercise_count,
TrainlogFeedbackView *followups,
size_t followup_capacity,
size_t *followup_count);
/* Nestable read savepoints let composition services observe one database state
* without exposing SQLite or issuing writes to application data. */
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database);
TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database, bool commit_snapshot);
TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database,
bool commit_snapshot);
/* WHY: session occurrences retain an equipment ID, while custom definitions
* need durable presentation metadata. A reference alone is never a definition. */
@ -69,16 +74,12 @@ typedef struct TrainlogResolvedEquipment {
TrainlogEquipmentOrigin origin;
} TrainlogResolvedEquipment;
TrainlogStatus trainlog_database_create_custom_equipment(
TrainlogDatabase *database,
const TrainlogCustomEquipment *equipment
);
TrainlogStatus trainlog_database_list_custom_equipment(
TrainlogDatabase *database,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_create_custom_equipment(TrainlogDatabase *database,
const TrainlogCustomEquipment *equipment);
TrainlogStatus trainlog_database_list_custom_equipment(TrainlogDatabase *database,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count);
/* CONTRACT: reads at most capacity + 1 ordered definitions, so callers can
* filter pages before imposing a presentation cap. database and output are
* borrowed for the call; the caller owns copied strings. This read-only API
@ -90,33 +91,24 @@ TrainlogStatus trainlog_database_list_custom_equipment(
* including any storage copied before the failure. offset is valid only while
* data is unchanged; refresh after writes. It is neither a durable cursor nor
* an idempotency mechanism. */
TrainlogStatus trainlog_database_list_custom_equipment_page(
TrainlogDatabase *database,
size_t offset,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count,
bool *output_more
);
TrainlogStatus trainlog_database_list_custom_equipment_page(TrainlogDatabase *database,
size_t offset,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count,
bool *output_more);
/* CONTRACT: an occurrence ID always resolves to a visible value. Unknown IDs
* are returned verbatim with TRAINLOG_EQUIPMENT_UNKNOWN, never hidden. */
TrainlogStatus trainlog_database_resolve_equipment(
TrainlogDatabase *database,
const char *equipment_id,
TrainlogResolvedEquipment *output
);
TrainlogStatus trainlog_database_list_exercise_equipment(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogResolvedEquipment *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_resolve_equipment(TrainlogDatabase *database,
const char *equipment_id,
TrainlogResolvedEquipment *output);
TrainlogStatus trainlog_database_list_exercise_equipment(TrainlogDatabase *database,
const char *exercise_id,
TrainlogResolvedEquipment *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_open(
const char *path,
TrainlogDatabase **output_database
);
TrainlogStatus trainlog_database_open(const char *path, TrainlogDatabase **output_database);
/**
* @brief Open a database and report the backend failure that prevented it.
@ -127,24 +119,17 @@ TrainlogStatus trainlog_database_open(
* This preserves the stable application status surface without hiding the
* path-specific reason needed to repair a user's durable database safely.
*/
TrainlogStatus trainlog_database_open_with_diagnostic(
const char *path,
TrainlogDatabase **output_database,
char *output_diagnostic,
size_t output_diagnostic_capacity
);
TrainlogStatus trainlog_database_open_with_diagnostic(const char *path,
TrainlogDatabase **output_database,
char *output_diagnostic,
size_t output_diagnostic_capacity);
void trainlog_database_close(TrainlogDatabase *database);
TrainlogStatus trainlog_database_schema_version(
TrainlogDatabase *database,
int *output_version
);
TrainlogStatus trainlog_database_schema_version(TrainlogDatabase *database, int *output_version);
TrainlogStatus trainlog_database_foreign_keys_enabled(
TrainlogDatabase *database,
int *output_enabled
);
TrainlogStatus trainlog_database_foreign_keys_enabled(TrainlogDatabase *database,
int *output_enabled);
TrainlogStatus trainlog_database_begin(TrainlogDatabase *database);
TrainlogStatus trainlog_database_commit(TrainlogDatabase *database);
@ -156,35 +141,26 @@ TrainlogStatus trainlog_database_rollback(TrainlogDatabase *database);
* Unicode normalization belongs to catalog.c. This lower-level API owns the
* final SQLite uniqueness barrier.
*/
TrainlogStatus trainlog_database_insert_exercise(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode
);
TrainlogStatus trainlog_database_insert_exercise(TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode);
TrainlogStatus trainlog_database_insert_exercise_profiled(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields
);
TrainlogStatus trainlog_database_insert_exercise_profiled(TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields);
TrainlogStatus trainlog_database_exercise_count(
TrainlogDatabase *database,
size_t *output_count
);
TrainlogStatus trainlog_database_exercise_count(TrainlogDatabase *database, size_t *output_count);
TrainlogStatus trainlog_database_list_exercises(
TrainlogDatabase *database,
TrainlogExercise *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercises(TrainlogDatabase *database,
TrainlogExercise *output,
size_t capacity,
size_t *output_count);
/**
* @brief Resolve a current or merged exercise identity to its canonical ID.
@ -193,12 +169,10 @@ TrainlogStatus trainlog_database_list_exercises(
* resolve through exercise_aliases. Unknown IDs return NOT_FOUND. The output
* buffer is caller-owned and must hold TRAINLOG_ID_MAX + 1 bytes.
*/
TrainlogStatus trainlog_database_resolve_exercise_id(
TrainlogDatabase *database,
const char *exercise_id,
char *output_canonical_id,
size_t output_capacity
);
TrainlogStatus trainlog_database_resolve_exercise_id(TrainlogDatabase *database,
const char *exercise_id,
char *output_canonical_id,
size_t output_capacity);
/**
* @brief Atomically merge one current catalogue exercise into another.
@ -210,11 +184,9 @@ TrainlogStatus trainlog_database_resolve_exercise_id(
* its stable entry ID or any owned actual/planning data. Existing aliases to
* source collapse directly to canonical and source becomes a durable alias.
*/
TrainlogStatus trainlog_database_merge_exercises(
TrainlogDatabase *database,
const char *source_exercise_id,
const char *canonical_exercise_id
);
TrainlogStatus trainlog_database_merge_exercises(TrainlogDatabase *database,
const char *source_exercise_id,
const char *canonical_exercise_id);
typedef struct TrainlogExerciseMergePreview {
size_t occurrences;
@ -227,11 +199,9 @@ typedef struct TrainlogExerciseMergePreview {
/* CONTRACT: counts describe source-owned records which the atomic merge will
* repoint or union. Unknown IDs return NOT_FOUND and output is never partial. */
TrainlogStatus trainlog_database_preview_exercise_merge(
TrainlogDatabase *database,
const char *source_exercise_id,
TrainlogExerciseMergePreview *output
);
TrainlogStatus trainlog_database_preview_exercise_merge(TrainlogDatabase *database,
const char *source_exercise_id,
TrainlogExerciseMergePreview *output);
/**
* @brief Atomically replace one exercise's direct body-zone relations.
@ -245,24 +215,20 @@ TrainlogStatus trainlog_database_preview_exercise_merge(
* @return INVALID_ARGUMENT for unknown/group/duplicate zones, NOT_FOUND for
* an unknown exercise, or the explicit persistence status.
*/
TrainlogStatus trainlog_database_replace_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
);
TrainlogStatus trainlog_database_replace_exercise_body_zones(TrainlogDatabase *database,
const char *exercise_id,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count);
/* NOT_FOUND distinguishes an unknown exercise identity from the valid empty
* relation set. Output is caller-owned; output_count reports required capacity
* and no relation is truncated as a successful result. */
TrainlogStatus trainlog_database_list_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExerciseBodyZone *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercise_body_zones(TrainlogDatabase *database,
const char *exercise_id,
TrainlogExerciseBodyZone *output,
size_t capacity,
size_t *output_count);
/* CONTRACT: the caller owns output storage; output_count reports the required
* count and INVALID_ARGUMENT is returned when capacity is insufficient.
@ -273,17 +239,15 @@ TrainlogStatus trainlog_database_list_exercise_body_zones(
* The resulting exercise IDs compose with list_exercise_body_zones() and
* list_exercise_performance(); the latter exposes newest history and explicit
* MAX without duplicating either datum for a future session generator. */
TrainlogStatus trainlog_database_list_exercises_filtered(
TrainlogDatabase *database,
const char *normalized_prefix,
const char *zone_id,
bool include_descendants,
bool primary_only,
bool unclassified_only,
TrainlogExercise *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercises_filtered(TrainlogDatabase *database,
const char *normalized_prefix,
const char *zone_id,
bool include_descendants,
bool primary_only,
bool unclassified_only,
TrainlogExercise *output,
size_t capacity,
size_t *output_count);
/* CONTRACT: name/profile/direct zones are one transaction and all string/list
* inputs are borrowed only for the duration of the call. A profile change is
@ -291,51 +255,38 @@ TrainlogStatus trainlog_database_list_exercises_filtered(
* rename or zone replacement preserves exercise_id and history. Zone rules
* match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND;
* invalid metadata and name collisions remain explicit errors. */
TrainlogStatus trainlog_database_update_exercise_profiled(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
);
TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count);
TrainlogStatus trainlog_database_insert_session(
TrainlogDatabase *database,
const TrainlogSessionInput *session
);
TrainlogStatus trainlog_database_insert_session(TrainlogDatabase *database,
const TrainlogSessionInput *session);
TrainlogStatus trainlog_database_session_count(
TrainlogDatabase *database,
size_t *output_count
);
TrainlogStatus trainlog_database_session_count(TrainlogDatabase *database, size_t *output_count);
/* Observable history only: a returned session owns at least one persisted
* performed set, continuous activity, or explicit MAX result. ended_at is
* presentation metadata and is not an actual-work gate. */
TrainlogStatus trainlog_database_list_sessions(
TrainlogDatabase *database,
TrainlogSessionSummary *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_sessions(TrainlogDatabase *database,
TrainlogSessionSummary *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_insert_body_observation(
TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation
);
TrainlogStatus trainlog_database_list_weight_points(
TrainlogDatabase *database,
TrainlogWeightPoint *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus
trainlog_database_insert_body_observation(TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation);
TrainlogStatus trainlog_database_list_weight_points(TrainlogDatabase *database,
TrainlogWeightPoint *output,
size_t capacity,
size_t *output_count);
/* TRAINLOG_SESSION_DETAILS_API */
@ -382,21 +333,17 @@ typedef struct TrainlogPersistedExerciseDetail {
* every partial allocation and, when output_exercise_count is non-NULL, sets
* it to zero. A nonzero capacity requires a non-NULL output_exercises array.
*/
TrainlogStatus trainlog_database_get_session_details(
TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogPersistedExerciseDetail *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count
);
TrainlogStatus
trainlog_database_get_session_details(TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogPersistedExerciseDetail *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count);
/* Safe for zero-initialized details and details returned by the loader. */
void trainlog_database_free_session_details(
TrainlogPersistedExerciseDetail *exercises,
size_t exercise_count
);
void trainlog_database_free_session_details(TrainlogPersistedExerciseDetail *exercises,
size_t exercise_count);
/* TRAINLOG_BODY_METRIC_HISTORY_API */
@ -430,21 +377,16 @@ typedef struct TrainlogBodyPairPoint {
double right_value;
} TrainlogBodyPairPoint;
TrainlogStatus trainlog_database_list_body_metric_points(
TrainlogDatabase *database,
TrainlogBodyMetric metric,
TrainlogBodyMetricPoint *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_latest_body_pair(
TrainlogDatabase *database,
TrainlogBodyMetric left_metric,
TrainlogBodyMetric right_metric,
TrainlogBodyPairPoint *output
);
TrainlogStatus trainlog_database_list_body_metric_points(TrainlogDatabase *database,
TrainlogBodyMetric metric,
TrainlogBodyMetricPoint *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_latest_body_pair(TrainlogDatabase *database,
TrainlogBodyMetric left_metric,
TrainlogBodyMetric right_metric,
TrainlogBodyPairPoint *output);
/* TRAINLOG_EXERCISE_PERFORMANCE_API */
@ -476,13 +418,11 @@ typedef struct TrainlogExercisePerformancePoint {
* A zero-repetition failed attempt is never promoted to representative
* performance. This API does not create or infer a measured maximum.
*/
TrainlogStatus trainlog_database_list_exercise_performance(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercisePerformancePoint *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercise_performance(TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercisePerformancePoint *output,
size_t capacity,
size_t *output_count);
/* TRAINING_KNOWLEDGE_RUNTIME_READ_V1 */
#define TRAINLOG_OCCURRENCE_PAGE_MAX 32U
@ -534,11 +474,9 @@ typedef struct TrainlogLatestExplicitMax {
} TrainlogLatestExplicitMax;
/* Exact-ID profile read; unknown IDs return NOT_FOUND. */
TrainlogStatus trainlog_database_get_exercise_profile(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercise *output
);
TrainlogStatus trainlog_database_get_exercise_profile(TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercise *output);
/**
* Current-data keyset page ordered by the exact started_at instant, then
@ -555,53 +493,46 @@ TrainlogStatus trainlog_database_get_exercise_profile(
* are DATABASE_ERROR rather than omitted or truncated. limit must be
* 1..TRAINLOG_OCCURRENCE_PAGE_MAX.
*/
TrainlogStatus trainlog_database_list_exercise_occurrences_page(
TrainlogDatabase *database,
const char *exercise_id,
const TrainlogExerciseOccurrenceCursor *after,
size_t limit,
TrainlogExerciseOccurrence *output,
size_t *output_count,
bool *output_has_more,
TrainlogExerciseOccurrenceCursor *output_next
);
TrainlogStatus
trainlog_database_list_exercise_occurrences_page(TrainlogDatabase *database,
const char *exercise_id,
const TrainlogExerciseOccurrenceCursor *after,
size_t limit,
TrainlogExerciseOccurrence *output,
size_t *output_count,
bool *output_has_more,
TrainlogExerciseOccurrenceCursor *output_next);
/* Original set positions are preserved. after_position is exclusive; use -1
* for the first page. Reps are non-negative, so zero preserves a failed
* attempt. Nullable values remain distinct from zero. Corrupt storage types or
* values outside the public int/size_t ranges return DATABASE_ERROR. No rows
* exist for continuous occurrences. Output remains caller-owned. */
TrainlogStatus trainlog_database_list_occurrence_sets_page(
TrainlogDatabase *database,
const char *entry_id,
int after_position,
size_t limit,
TrainlogOccurrenceSet *output,
size_t *output_count,
bool *output_has_more,
int *output_next_position
);
TrainlogStatus trainlog_database_list_occurrence_sets_page(TrainlogDatabase *database,
const char *entry_id,
int after_position,
size_t limit,
TrainlogOccurrenceSet *output,
size_t *output_count,
bool *output_has_more,
int *output_next_position);
/* Reads max_results joined only through max_test sessions; ordinary large sets
* never qualify. It uses the same exact instant and bytewise-ID ordering as
* occurrence pagination. Output keeps the exact source timestamp and
* occurrence load/equipment context; corrupt or over-capacity selected storage
* returns DATABASE_ERROR. */
TrainlogStatus trainlog_database_latest_explicit_max_context(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogLatestExplicitMax *output
);
TrainlogStatus trainlog_database_latest_explicit_max_context(TrainlogDatabase *database,
const char *exercise_id,
TrainlogLatestExplicitMax *output);
/* Same chronological contract, additionally restricted to one exact nonempty
* equipment ID. This is the compatibility reader for user-directed %MAX. */
TrainlogStatus trainlog_database_latest_explicit_max_equipment_context(
TrainlogDatabase *database,
const char *exercise_id,
const char *equipment_id,
TrainlogLatestExplicitMax *output
);
TrainlogStatus
trainlog_database_latest_explicit_max_equipment_context(TrainlogDatabase *database,
const char *exercise_id,
const char *equipment_id,
TrainlogLatestExplicitMax *output);
/* TRAINLOG_SESSION_EDIT_API */
@ -639,17 +570,16 @@ typedef struct TrainlogEditableExerciseRecord {
* too small it returns TRAINLOG_STATUS_INVALID_ARGUMENT and the outputs must not
* be used.
*/
TrainlogStatus trainlog_database_load_session_editable(
TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogEditableExerciseRecord *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count,
TrainlogSetInput *output_sets,
size_t set_capacity,
size_t *output_set_count
);
TrainlogStatus
trainlog_database_load_session_editable(TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogEditableExerciseRecord *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count,
TrainlogSetInput *output_sets,
size_t set_capacity,
size_t *output_set_count);
/**
* @brief Replace only the exercise/set contents of an existing session.
@ -661,13 +591,11 @@ TrainlogStatus trainlog_database_load_session_editable(
* inherit feedback. The replacement is atomic: any error rolls the whole
* operation back.
*/
TrainlogStatus trainlog_database_replace_session_exercises(
TrainlogDatabase *database,
const char *session_id,
const TrainlogSessionExerciseInput *exercises,
size_t exercise_count
);
TrainlogStatus
trainlog_database_replace_session_exercises(TrainlogDatabase *database,
const char *session_id,
const TrainlogSessionExerciseInput *exercises,
size_t exercise_count);
/* TRAINLOG_BODY_OBSERVATION_RECORD_API */
@ -708,18 +636,14 @@ typedef struct TrainlogBodyObservationRecord {
char notes[TRAINLOG_NOTE_MAX + 1U];
} TrainlogBodyObservationRecord;
TrainlogStatus trainlog_database_list_body_observations(
TrainlogDatabase *database,
TrainlogBodyObservationRecord *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_body_observations(TrainlogDatabase *database,
TrainlogBodyObservationRecord *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_get_body_observation(
TrainlogDatabase *database,
const char *observation_id,
TrainlogBodyObservationRecord *output
);
TrainlogStatus trainlog_database_get_body_observation(TrainlogDatabase *database,
const char *observation_id,
TrainlogBodyObservationRecord *output);
/**
* @brief Correct metric values of one existing observation.
@ -727,9 +651,8 @@ TrainlogStatus trainlog_database_get_body_observation(
* observation_id identifies the stable row. observed_at and session linkage
* remain unchanged. At least one body metric must remain present.
*/
TrainlogStatus trainlog_database_update_body_observation(
TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation
);
TrainlogStatus
trainlog_database_update_body_observation(TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation);
#endif

View file

@ -41,7 +41,8 @@
* - `sy` for synchronization runs;
* - `sxe` for persisted session-exercise occurrences;
* - `sp`, `spr`, `spe`, `spd`, and `spw` for preparation, revision,
* occurrence, delivery, and withdrawal identities.
* occurrence, delivery, and withdrawal identities;
* - `pgr` for desktop Program V1 revision identities.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.

View file

@ -9,6 +9,7 @@ mtp_dep = dependency('libmtp', required: true)
notcurses_dep = dependency('notcurses-core', required: true)
microhttpd_dep = dependency('libmicrohttpd', required: true)
yyjson_dep = dependency('yyjson', required: true)
crypto_dep = dependency('libcrypto', required: true)
utf8proc_dep = dependency('libutf8proc', required: false)
if not utf8proc_dep.found()
@ -74,6 +75,8 @@ if web_assets_enabled
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
'../web/src/api/sessions.ts',
'../web/src/api/programs.ts',
'../web/src/routes/ProgramsTab.tsx',
'../web/src/theme/app.css',
'../web/src/theme/tokens.css',
)
@ -202,6 +205,7 @@ trainlog_core_sources = files(
'src/web_prepared_items.c',
'src/web_prepared_items_json.c',
'src/web_sessions.c',
'src/web_programs.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -235,6 +239,7 @@ trainlog_core = static_library(
udev_dep,
mtp_dep,
yyjson_dep,
crypto_dep,
],
c_args: strict_c_args,
)
@ -250,6 +255,7 @@ trainlog_core_dep = declare_dependency(
udev_dep,
mtp_dep,
yyjson_dep,
crypto_dep,
],
)
@ -364,6 +370,15 @@ test_web_sessions = executable(
)
test('web_sessions', test_web_sessions)
test_web_programs = executable(
'test_web_programs',
'tests/test_web_programs.c',
dependencies: trainlog_core_dep,
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_programs', test_web_programs)
test_dashboard_header = executable(
'test_dashboard_header',
'tests/test_dashboard_header.c',

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@ static bool prefix_is_supported(const char *prefix) {
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
strcmp(prefix, "spw") == 0;
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0;
}
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {

View file

@ -22,6 +22,7 @@
#include "trainlog/web_dashboard.h"
#include "trainlog/web_prepared_items.h"
#include "trainlog/web_programs.h"
#include "trainlog/web_sessions.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_preferences.h"
@ -31,7 +32,7 @@
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT (128U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT TRAINLOG_WEB_PROGRAM_BYTES_MAX
#define TRAINLOG_HTTP_LEGACY_BODY_LIMIT (4U * 1024U)
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
#define TRAINLOG_HTTP_BACKLOG 32U
@ -564,6 +565,234 @@ static enum MHD_Result queue_owned_sessions_json(struct MHD_Connection *connecti
return result;
}
static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connection,
TrainlogStatus status,
char *json,
size_t json_size) {
if (status == TRAINLOG_STATUS_NOT_FOUND) {
free(json);
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
}
return queue_owned_sessions_json(connection, status, json, json_size);
}
static bool program_mutation_allowed(TrainlogWebContext *context,
struct MHD_Connection *connection) {
const char *origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
const char *csrf =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
const char *content_type =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
return valid_origin(context, origin) && valid_csrf(context, csrf) && content_type != NULL &&
strcmp(content_type, "application/json") == 0;
}
static enum MHD_Result
handle_program_list(TrainlogWebContext *context, struct MHD_Connection *connection, bool is_get) {
const char *search;
const char *state;
char *json = NULL;
size_t json_size = 0U;
size_t offset;
size_t limit;
TrainlogStatus status;
if (!is_get) {
return queue_json(
connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET");
}
search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
state = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "state");
if (!parse_page_number(MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset"),
0U,
1000000U,
&offset) ||
!parse_page_number(MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"),
24U,
TRAINLOG_WEB_SESSIONS_PAGE_MAX,
&limit) ||
limit == 0U) {
return queue_json(connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
}
status = trainlog_web_programs_list_json(
context->database, search, state, offset, limit, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_import(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *url,
const char *method,
TrainlogHttpRequestState *request) {
char *json = NULL;
size_t json_size = 0U;
bool commit = strcmp(url, "/api/v1/sessions/programs/import") == 0;
TrainlogStatus status;
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_programs_import_json(
context->database, request->body, request->body_size, commit, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static bool
copy_path_component(char *output, size_t output_capacity, const char *input, size_t input_length) {
if (input_length == 0U || input_length >= output_capacity) {
return false;
}
(void)memcpy(output, input, input_length);
output[input_length] = '\0';
return true;
}
static bool parse_quoted_revision(const char *header, char *output, size_t output_capacity) {
size_t length = header == NULL ? 0U : strlen(header);
if (length < 3U || header[0] != '"' || header[length - 1U] != '"') {
return false;
}
return copy_path_component(output, output_capacity, header + 1, length - 2U);
}
static enum MHD_Result handle_program_archive(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *identity,
size_t identity_length,
const char *method) {
static const char SUFFIX[] = "/archive";
const char *request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *if_match =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
char program_id[TRAINLOG_ID_MAX + 1U];
char revision[TRAINLOG_ID_MAX + 1U];
char *json = NULL;
size_t json_size = 0U;
size_t program_length = identity_length - strlen(SUFFIX);
TrainlogStatus status;
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection) || request_id == NULL ||
request_id[0] == '\0' ||
!copy_path_component(program_id, sizeof(program_id), identity, program_length) ||
!parse_quoted_revision(if_match, revision, sizeof(revision))) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
status = trainlog_web_programs_archive_json(
context->database, program_id, revision, request_id, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_prepare(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *identity,
const char *sessions_marker,
const char *method) {
static const char SUFFIX[] = "/prepare";
const char *request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *session_id = sessions_marker + strlen("/sessions/");
size_t session_length = strlen(session_id);
char program_id[TRAINLOG_ID_MAX + 1U];
char program_session_id[TRAINLOG_ID_MAX + 1U];
char *json = NULL;
size_t json_size = 0U;
TrainlogStatus status;
if (session_length <= strlen(SUFFIX) ||
strcmp(session_id + session_length - strlen(SUFFIX), SUFFIX) != 0 ||
strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
session_length -= strlen(SUFFIX);
if (!program_mutation_allowed(context, connection) || request_id == NULL ||
request_id[0] == '\0' ||
!copy_path_component(
program_id, sizeof(program_id), identity, (size_t)(sessions_marker - identity)) ||
!copy_path_component(
program_session_id, sizeof(program_session_id), session_id, session_length)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_programs_prepare_json(
context->database, program_id, program_session_id, request_id, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_request(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *url,
const char *method,
bool is_get,
TrainlogHttpRequestState *request) {
static const char PREFIX[] = "/api/v1/sessions/program/";
static const char ARCHIVE_SUFFIX[] = "/archive";
const char *identity;
const char *sessions_marker;
char *json = NULL;
size_t json_size = 0U;
size_t identity_length;
TrainlogStatus status;
if (strcmp(url, "/api/v1/sessions/programs") == 0) {
return handle_program_list(context, connection, is_get);
}
if (strcmp(url, "/api/v1/sessions/programs/validate") == 0 ||
strcmp(url, "/api/v1/sessions/programs/import") == 0) {
return handle_program_import(context, connection, url, method, request);
}
if (strncmp(url, PREFIX, strlen(PREFIX)) != 0) {
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
identity = url + strlen(PREFIX);
identity_length = strlen(identity);
sessions_marker = strstr(identity, "/sessions/");
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
return handle_program_archive(context, connection, identity, identity_length, method);
}
if (sessions_marker != NULL) {
return handle_program_prepare(context, connection, identity, sessions_marker, method);
}
if (!is_get || identity_length == 0U || identity_length > TRAINLOG_ID_MAX) {
return queue_json(
connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET");
}
status = trainlog_web_programs_detail_json(context->database, identity, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_request(void *closure,
struct MHD_Connection *connection,
const char *url,
@ -782,6 +1011,9 @@ static enum MHD_Result handle_request(void *closure,
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strncmp(url, "/api/v1/sessions/program", strlen("/api/v1/sessions/program")) == 0) {
return handle_program_request(context, connection, url, method, is_get, state);
}
if (strncmp(url, "/api/v1/sessions", strlen("/api/v1/sessions")) == 0) {
static const struct {
const char *path;
@ -985,12 +1217,8 @@ static enum MHD_Result handle_request(void *closure,
if (is_get) {
preferences_result = trainlog_web_preferences_load(&preferences, &source);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
!trainlog_web_preferences_serialize(&preferences,
source,
true,
json,
sizeof(json),
&json_size)) {
!trainlog_web_preferences_serialize(
&preferences, source, true, json, sizeof(json), &json_size)) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"preferences_unavailable\"}\n",
@ -1002,11 +1230,8 @@ static enum MHD_Result handle_request(void *closure,
"Trainlog Web : préférences persistées invalides, défaut utilisé\n");
context->invalid_preferences_reported = true;
}
return queue_layout_json(connection,
MHD_HTTP_OK,
json,
preferences.revision,
context->csrf_token);
return queue_layout_json(
connection, MHD_HTTP_OK, json, preferences.revision, context->csrf_token);
}
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0) {
return queue_json(connection,
@ -1040,15 +1265,11 @@ static enum MHD_Result handle_request(void *closure,
}
preferences_result =
trainlog_web_preferences_parse(state->body, state->body_size, &preferences);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
preferences.revision != expected) {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_preferences\"}\n",
NULL);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK || preferences.revision != expected) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_preferences\"}\n", NULL);
}
preferences_result =
trainlog_web_preferences_save(&preferences, expected, &saved);
preferences_result = trainlog_web_preferences_save(&preferences, expected, &saved);
if (preferences_result == TRAINLOG_WEB_PREFERENCES_CONFLICT) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_FAILED,
@ -1056,12 +1277,8 @@ static enum MHD_Result handle_request(void *closure,
NULL);
}
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
!trainlog_web_preferences_serialize(&saved,
TRAINLOG_WEB_PREFERENCES_PERSISTED,
true,
json,
sizeof(json),
&json_size)) {
!trainlog_web_preferences_serialize(
&saved, TRAINLOG_WEB_PREFERENCES_PERSISTED, true, json, sizeof(json), &json_size)) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"preferences_save_failed\"}\n",

View file

@ -23,7 +23,8 @@ static const ListDefinition LISTS[] = {
"FROM session_preparations p JOIN session_preparation_revisions r ON "
"r.revision_id=p.current_revision_id "
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
"WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
"EXISTS(SELECT 1 FROM "
"session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE "
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"GROUP BY p.preparation_id ORDER BY "
@ -194,22 +195,22 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
bool proposal,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
const char *sql = proposal
? "SELECT "
"d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,"
"i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN "
"'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN "
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
: "SELECT "
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
"p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
"w.acknowledged_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
"LEFT JOIN ai_session_drafts source ON source.draft_id=p.source_proposal_id "
"LEFT JOIN session_preparation_withdrawals w ON "
"w.preparation_id=p.preparation_id WHERE p.preparation_id=?1";
const char *sql =
proposal ? "SELECT "
"d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,"
"i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN "
"'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN "
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
: "SELECT "
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
"p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
"w.acknowledged_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
"LEFT JOIN ai_session_drafts source ON source.draft_id=p.source_proposal_id "
"LEFT JOIN session_preparation_withdrawals w ON "
"w.preparation_id=p.preparation_id WHERE p.preparation_id=?1";
const char *entry_sql =
proposal
? "SELECT "
@ -254,16 +255,13 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
}
if (!proposal) {
static const char *const provenance_keys[] = {"source_proposal_id",
"source_proposal_title",
"withdrawn_at",
"withdrawal_id",
"withdrawal_acknowledged_at"};
"source_proposal_title",
"withdrawn_at",
"withdrawal_id",
"withdrawal_acknowledged_at"};
for (column = 0; column < 5; ++column) {
if (!add_nullable_text(document,
root,
provenance_keys[column],
statement,
column + 8)) {
if (!add_nullable_text(
document, root, provenance_keys[column], statement, column + 8)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
@ -717,6 +715,96 @@ static bool bind_nullable_text(sqlite3_stmt *statement, int index, yyjson_val *v
SQLITE_TRANSIENT) == SQLITE_OK;
}
static bool program_session_exists(TrainlogDatabase *database,
const char *program_id,
const char *program_session_id) {
sqlite3_stmt *statement = NULL;
int result;
bool found = false;
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_sessions s JOIN programs p ON "
"p.program_id=s.program_id WHERE p.program_id=?1 AND "
"s.program_session_id=?2",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, program_session_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
found = sqlite3_step(statement) == SQLITE_ROW;
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return found;
}
static TrainlogStatus insert_preparation_root(TrainlogDatabase *database,
const char *preparation_id,
const char *revision_id,
const char *created_at,
const char *editing_state,
const char *source_proposal_id,
const char *source_payload_sha256,
const char *source_program_id,
const char *source_program_session_id) {
static const char INSERT_SQL[] =
"INSERT INTO session_preparations("
"preparation_id,current_revision_id,created_at,updated_at,editing_state,"
"delivery_state,source_proposal_id,source_payload_sha256,withdrawn_at,"
"source_program_id,source_program_session_id) "
"VALUES(?1,?2,?3,?3,?4,'local',?5,?6,NULL,?7,?8)";
sqlite3_stmt *statement = NULL;
int result;
result = sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, created_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = source_proposal_id == NULL
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(statement, 5, source_proposal_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = source_payload_sha256 == NULL
? sqlite3_bind_null(statement, 6)
: sqlite3_bind_text(statement, 6, source_payload_sha256, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = source_program_id == NULL
? sqlite3_bind_null(statement, 7)
: sqlite3_bind_text(statement, 7, source_program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result =
source_program_session_id == NULL
? sqlite3_bind_null(statement, 8)
: sqlite3_bind_text(statement, 8, source_program_session_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
const char *revision_id,
yyjson_val *entry,
@ -854,7 +942,9 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
"editing_state",
"occurrences",
"source_proposal_id",
"source_payload_sha256"};
"source_payload_sha256",
"source_program_id",
"source_program_session_id"};
yyjson_doc *input = NULL;
yyjson_val *root;
yyjson_val *occurrences;
@ -868,6 +958,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
const char *editing_state;
const char *source_proposal = NULL;
const char *source_fingerprint = NULL;
const char *source_program = NULL;
const char *source_program_session = NULL;
size_t index;
TrainlogStatus status = TRAINLOG_STATUS_OK;
yyjson_mut_doc *response;
@ -912,6 +1004,21 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (yyjson_obj_get(root, "source_program_id") != NULL &&
(!bounded_json_string(
yyjson_obj_get(root, "source_program_id"), TRAINLOG_ID_MAX + 1U, &source_program) ||
!bounded_json_string(yyjson_obj_get(root, "source_program_session_id"),
TRAINLOG_ID_MAX + 1U,
&source_program_session))) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if ((source_program == NULL) != (source_program_session == NULL) ||
(preparation_id != NULL && preparation_id[0] != '\0' && source_program != NULL) ||
(source_proposal != NULL && source_program != NULL)) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (actual_preparation == NULL || actual_preparation[0] == '\0') {
if (expected_revision[0] != '\0' ||
trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) !=
@ -948,6 +1055,12 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
(void)sqlite3_finalize(statement);
statement = NULL;
}
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_program != NULL) {
if (!program_session_exists(database, source_program, source_program_session)) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
}
}
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) {
if (sqlite3_prepare_v2(
database->connection,
@ -987,31 +1100,19 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
}
(void)sqlite3_finalize(statement);
statement = NULL;
} else if (sqlite3_prepare_v2(
database->connection,
"INSERT INTO session_preparations("
"preparation_id,current_revision_id,created_at,updated_at,editing_state,"
"delivery_state,source_proposal_id,source_payload_sha256,withdrawn_at) "
"VALUES(?1,?2,?3,?3,?4,'local',?5,?6,NULL)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
(source_proposal == NULL
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(statement, 5, source_proposal, -1, SQLITE_TRANSIENT)) !=
SQLITE_OK ||
(source_fingerprint == NULL
? sqlite3_bind_null(statement, 6)
: sqlite3_bind_text(statement, 6, source_fingerprint, -1, SQLITE_TRANSIENT)) !=
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
} else {
status = insert_preparation_root(database,
actual_preparation,
revision,
now,
editing_state,
source_proposal,
source_fingerprint,
source_program,
source_program_session);
if (status != TRAINLOG_STATUS_OK) {
goto rollback;
}
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
@ -1372,10 +1473,8 @@ TrainlogStatus trainlog_web_sessions_withdraw_json(TrainlogDatabase *database,
(void)yyjson_mut_obj_add_strcpy(response, root, "preparation_id", preparation_id);
(void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", expected_revision);
(void)yyjson_mut_obj_add_strcpy(response, root, "withdrawal_id", withdrawal_id);
(void)yyjson_mut_obj_add_strcpy(response,
root,
"android_cancellation",
has_deliveries ? "pending" : "not_required");
(void)yyjson_mut_obj_add_strcpy(
response, root, "android_cancellation", has_deliveries ? "pending" : "not_required");
status = write_document(response, output_json, output_size);
response = NULL;
if (status != TRAINLOG_STATUS_OK ||

View file

@ -7,38 +7,82 @@
#include "trainlog/database.h"
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"CHECK failed at %s:%d: %s\n",__FILE__,__LINE__,#x); return 1; } } while (0)
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return 1; \
} \
} while (0)
static int scalar(sqlite3 *database, const char *sql)
{
sqlite3_stmt *statement=NULL;int value=-1;
if(sqlite3_prepare_v2(database,sql,-1,&statement,NULL)==SQLITE_OK && sqlite3_step(statement)==SQLITE_ROW)value=sqlite3_column_int(statement,0);
(void)sqlite3_finalize(statement);return value;
static int scalar(sqlite3 *database, const char *sql) {
sqlite3_stmt *statement = NULL;
int value = -1;
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
value = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return value;
}
int main(void)
{
char path[]="/tmp/trainlog-schema-v21-XXXXXX";
int fd=mkstemp(path);TrainlogDatabase *database=NULL;sqlite3 *raw=NULL;size_t exercises=0U;
CHECK(fd>=0);CHECK(close(fd)==0);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
int main(void) {
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
"DROP TABLE program_requests;"
"DROP TABLE program_session_entries;"
"DROP TABLE program_sessions;"
"DROP TABLE programs;"
"DROP TABLE session_preparation_withdrawals;"
"DROP TABLE session_preparation_deliveries;"
"DROP TABLE session_preparation_requests;"
"DROP TABLE session_preparation_entries;"
"DROP TABLE session_preparation_revisions;"
"DROP TABLE session_preparations;"
"DROP TABLE sync_generation_archives;"
"DROP TABLE sync_causal_publications;"
"DROP TABLE sync_acknowledgements;"
"DROP TABLE sync_consumed_generations;"
"DROP TABLE sync_generation_artifacts;"
"DROP TABLE sync_generations;"
"DROP TABLE sync_peer_identity;"
"PRAGMA user_version=20;";
char path[] = "/tmp/trainlog-schema-v21-XXXXXX";
int file_descriptor = mkstemp(path);
TrainlogDatabase *database = NULL;
sqlite3 *raw = NULL;
size_t exercises = 0U;
CHECK(file_descriptor >= 0);
CHECK(close(file_descriptor) == 0);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","Migration sentinel","migration sentinel",
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_withdrawals;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_close(raw)==SQLITE_OK);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(scalar(raw,"PRAGMA user_version")==TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw,"SELECT COUNT(*) FROM session_preparation_withdrawals")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generation_archives")==0);
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"Migration sentinel",
"migration sentinel",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, DOWNGRADE_TO_V20, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database, &exercises) == TRAINLOG_STATUS_OK);
CHECK(exercises > 0U);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_generations") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_consumed_generations") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_acknowledgements") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_generation_archives") == 0);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return 0;
}

View file

@ -13,45 +13,72 @@
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); \
return false; \
} \
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static const char *const V7_FIXTURE_SQL =
"PRAGMA foreign_keys=ON;"
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL,notes TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);"
"CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER UNIQUE REFERENCES sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);"
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT "
"NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT "
"NULL,data_fields INTEGER NOT NULL);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT "
"NOT NULL,ended_at TEXT,session_type TEXT NOT NULL,notes TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL "
"UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT "
"NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL,data_fields INTEGER NOT "
"NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT "
"NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg "
"REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL "
"REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds "
"INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT "
"NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh "
"REAL,distance_km REAL);"
"CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL "
"UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER UNIQUE REFERENCES "
"sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm "
"REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm "
"REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);"
"INSERT INTO exercises VALUES(11,'ex_fixture_sets','Squat','squat','reps','sets',0);"
"INSERT INTO exercises VALUES(12,'ex_fixture_run','Run','run','duration','continuous',3);"
"INSERT INTO sessions VALUES(21,'se_fixture','2026-09-08T06:00:00Z','2026-09-08T07:00:00Z','training','fixture session');"
"INSERT INTO session_exercises VALUES(31,'sxe_fixture_sets',21,11,'sets',0,0,'external',90,2,5,NULL,82.5,'eq_fixture_rack','sets note');"
"INSERT INTO session_exercises VALUES(32,'sxe_fixture_run',21,12,'continuous',3,1,'none',0,NULL,NULL,NULL,NULL,NULL,'run note');"
"INSERT INTO sessions "
"VALUES(21,'se_fixture','2026-09-08T06:00:00Z','2026-09-08T07:00:00Z','training','fixture "
"session');"
"INSERT INTO session_exercises "
"VALUES(31,'sxe_fixture_sets',21,11,'sets',0,0,'external',90,2,5,NULL,82.5,'eq_fixture_rack','"
"sets note');"
"INSERT INTO session_exercises "
"VALUES(32,'sxe_fixture_run',21,12,'continuous',3,1,'none',0,NULL,NULL,NULL,NULL,NULL,'run "
"note');"
"INSERT INTO performed_sets VALUES(41,31,0,5,NULL,80.25);"
"INSERT INTO performed_sets VALUES(42,31,1,4,NULL,82.5);"
"INSERT INTO continuous_activity VALUES(51,32,1234,10.75,3.67);"
"INSERT INTO body_observations(id,observation_id,observed_at,session_row_id,body_weight_kg,waist_cm,notes) VALUES(61,'bo_fixture','2026-09-08T07:05:00Z',21,74.25,81.5,'body note');"
"INSERT INTO "
"body_observations(id,observation_id,observed_at,session_row_id,body_weight_kg,waist_cm,notes) "
"VALUES(61,'bo_fixture','2026-09-08T07:05:00Z',21,74.25,81.5,'body note');"
"PRAGMA user_version=7;";
static bool verify_preserved_values(const char *path)
{
static bool verify_preserved_values(const char *path) {
sqlite3 *connection = NULL;
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(connection,
"SELECT s.session_id,se.entry_id,se.position,se.equipment_id,ps.position,ps.reps,ps.weight_kg "
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id ORDER BY ps.position;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(
sqlite3_prepare_v2(
connection,
"SELECT "
"s.session_id,se.entry_id,se.position,se.equipment_id,ps.position,ps.reps,ps.weight_kg "
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id ORDER BY ps.position;",
-1,
&statement,
NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "se_fixture") == 0);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 1), "sxe_fixture_sets") == 0);
@ -68,10 +95,16 @@ static bool verify_preserved_values(const char *path)
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(connection,
"SELECT se.entry_id,se.position,ca.duration_seconds,ca.speed_kmh,ca.distance_km,bo.observation_id,bo.body_weight_kg,bo.waist_cm,bo.notes "
"FROM session_exercises se JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id "
"JOIN sessions s ON s.id=se.session_row_id JOIN body_observations bo ON bo.session_row_id=s.id;",
-1, &statement, NULL) == SQLITE_OK);
"SELECT "
"se.entry_id,se.position,ca.duration_seconds,ca.speed_kmh,ca.distance_"
"km,bo.observation_id,bo.body_weight_kg,bo.waist_cm,bo.notes "
"FROM session_exercises se JOIN continuous_activity ca ON "
"ca.session_exercise_row_id=se.id "
"JOIN sessions s ON s.id=se.session_row_id JOIN body_observations bo "
"ON bo.session_row_id=s.id;",
-1,
&statement,
NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "sxe_fixture_run") == 0);
CHECK(sqlite3_column_int(statement, 1) == 1);
@ -88,12 +121,12 @@ static bool verify_preserved_values(const char *path)
return true;
}
static bool test_v7_migrates_and_current_reopens(void)
{
static bool test_v7_migrates_and_current_reopens(void) {
char path[] = "/tmp/trainlog-schema-v7-XXXXXX";
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
int version = 0;
char diagnostic[256];
int fd = mkstemp(path);
CHECK(fd >= 0);
@ -101,22 +134,29 @@ static bool test_v7_migrates_and_current_reopens(void)
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
fprintf(stderr, "migration diagnostic: %s\n", diagnostic);
return false;
}
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(database);
CHECK(verify_preserved_values(path));
database = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
fprintf(stderr, "reopen diagnostic: %s\n", diagnostic);
return false;
}
trainlog_database_close(database);
CHECK(verify_preserved_values(path));
CHECK(unlink(path) == 0);
return true;
}
static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
{
static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
char path[] = "/tmp/trainlog-schema-v7-failure-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -128,14 +168,18 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
/* Synthetic collision: only the v8 migration target is pre-created. */
CHECK(sqlite3_exec(raw, "CREATE TABLE custom_equipment (equipment_id TEXT PRIMARY KEY);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"CREATE TABLE custom_equipment (equipment_id TEXT PRIMARY KEY);",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
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 v24: SQLite rc=",
strlen("migrate database to schema v24: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic,
"migrate database to schema v25: SQLite rc=",
strlen("migrate database to schema v25: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -143,8 +187,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
return true;
}
static bool test_newer_schema_has_application_diagnostic(void)
{
static bool test_newer_schema_has_application_diagnostic(void) {
char path[] = "/tmp/trainlog-schema-v17-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -154,19 +197,18 @@ 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=25;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=26;", 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(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 25 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 26 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;
}
static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
{
static bool test_unrecognized_historic_schema_has_application_diagnostic(void) {
char path[] = "/tmp/trainlog-schema-invalid-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -178,8 +220,8 @@ static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=-1;", 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(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version -1 is unsupported") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
@ -187,12 +229,13 @@ static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
return true;
}
int main(void)
{
int main(void) {
if (!test_v7_migrates_and_current_reopens() ||
!test_v7_migration_sqlite_failure_has_diagnostic() ||
!test_newer_schema_has_application_diagnostic() ||
!test_unrecognized_historic_schema_has_application_diagnostic()) return 1;
!test_unrecognized_historic_schema_has_application_diagnostic()) {
return 1;
}
(void)printf("PASS schema_v7_migration\n");
return 0;
}

View file

@ -13,13 +13,13 @@
#include "trainlog/database.h"
#define CHECK(condition) do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static const char *const V9_FIXTURE_SQL =
"PRAGMA foreign_keys=ON;"
@ -36,7 +36,8 @@ static const char *const V9_FIXTURE_SQL =
"target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
"equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL "
"REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL CHECK(position>=0),"
"REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL "
"CHECK(position>=0),"
"reps INTEGER CHECK(reps>=0),duration_seconds INTEGER CHECK(duration_seconds>0),"
"weight_kg REAL CHECK(weight_kg>0.0),UNIQUE(session_exercise_row_id,position),"
"CHECK((reps IS NOT NULL AND duration_seconds IS NULL) OR "
@ -46,13 +47,13 @@ static const char *const V9_FIXTURE_SQL =
"CHECK(max_weight_kg>0.0));"
"INSERT INTO exercises VALUES(7,'ex_fixture','Fixture','fixture','reps','sets',0);"
"INSERT INTO sessions VALUES(11,'se_fixture','2026-09-09T10:00:00+02:00',NULL,'training',NULL);"
"INSERT INTO session_exercises VALUES(13,'sxe_fixture',11,7,'sets',0,4,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO session_exercises "
"VALUES(13,'sxe_fixture',11,7,'sets',0,4,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO performed_sets VALUES(17,13,2,8,NULL,32.5);"
"INSERT INTO performed_sets VALUES(19,13,5,7,NULL,NULL);"
"PRAGMA user_version=9;";
static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected)
{
static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected) {
sqlite3_stmt *statement = NULL;
bool matches = false;
if (sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK &&
@ -64,8 +65,7 @@ static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected)
return matches;
}
static bool test_v9_to_current_is_lossless(void)
{
static bool test_v9_to_current_is_lossless(void) {
char path[] = "/tmp/trainlog-schema-v9-current-XXXXXX";
sqlite3 *raw = NULL;
sqlite3_stmt *rows = NULL;
@ -96,9 +96,13 @@ static bool test_v9_to_current_is_lossless(void)
CHECK(sqlite3_step(rows) == SQLITE_DONE);
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
rows = NULL;
CHECK(sqlite3_prepare_v2(raw,
"SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
"FROM performed_sets ORDER BY position;", -1, &rows, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(
raw,
"SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
"FROM performed_sets ORDER BY position;",
-1,
&rows,
NULL) == SQLITE_OK);
CHECK(sqlite3_step(rows) == SQLITE_ROW);
CHECK(sqlite3_column_int64(rows, 0) == 17);
CHECK(sqlite3_column_int64(rows, 1) == 13);
@ -113,25 +117,24 @@ static bool test_v9_to_current_is_lossless(void)
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
rows = NULL;
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(23,13,6,6,NULL,0.0);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(29,13,7,5,NULL,-0.5);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(31,13,8,4,3,NULL);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(37,999,9,4,NULL,NULL);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(23,13,6,6,NULL,0.0);", NULL, NULL, NULL) ==
SQLITE_OK);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(29,13,7,5,NULL,-0.5);", NULL, NULL, NULL) ==
SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(31,13,8,4,3,NULL);", NULL, NULL, NULL) ==
SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(37,999,9,4,NULL,NULL);", NULL, NULL, NULL) ==
SQLITE_CONSTRAINT);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return true;
}
static bool test_v9_to_v10_failure_rolls_back(void)
{
static bool test_v9_to_v10_failure_rolls_back(void) {
char path[] = "/tmp/trainlog-schema-v9-v10-failure-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -142,29 +145,28 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V9_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"CREATE TABLE performed_sets_v9(collision INTEGER);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(
sqlite3_exec(raw, "CREATE TABLE performed_sets_v9(collision INTEGER);", NULL, NULL, NULL) ==
SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
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 v24") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v25") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar_text_is(raw, "PRAGMA integrity_check;", "ok"));
{
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_prepare_v2(raw, "PRAGMA user_version;", -1,
&statement, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "PRAGMA user_version;", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 9);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM performed_sets;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(
raw, "SELECT COUNT(*) FROM performed_sets;", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 2);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
@ -174,10 +176,10 @@ static bool test_v9_to_v10_failure_rolls_back(void)
return true;
}
int main(void)
{
if (!test_v9_to_current_is_lossless() ||
!test_v9_to_v10_failure_rolls_back()) return 1;
int main(void) {
if (!test_v9_to_current_is_lossless() || !test_v9_to_v10_failure_rolls_back()) {
return 1;
}
(void)printf("PASS schema_v9_migration\n");
return 0;
}