refactor(programs): clarify persistence and transaction flow
This commit is contained in:
parent
679ed6184a
commit
ed9395366e
32 changed files with 3732 additions and 4111 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
6465
tui/src/database.c
6465
tui/src/database.c
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue