feat(core): add sleep diary domain

This commit is contained in:
fy59 2026-09-21 06:23:58 +02:00
parent 6948e0bbd5
commit 2565d0eafa
38 changed files with 1099 additions and 280 deletions

View file

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

View file

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

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v28 requis")
raise ValueError("schema desktop v15-v29 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +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
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v28 requis")
raise ValueError("schema desktop v18-v29 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
supported_versions = tuple(range(8, 29))
supported_versions = tuple(range(8, 30))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v28 requis")
raise ValueError("schema desktop v8 à v29 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 29))
supported_versions = tuple(range(8, 30))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v28 requis")
raise ValueError("schema desktop v8 à v29 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")
equipment = [dict(row) for row in connection.execute(

View file

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

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 29))
supported_versions = tuple(range(11, 30))
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():

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 29):
if version not in range(8, 30):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,9 +83,9 @@ 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, 25, 26, 27, 28):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 29)
supported_versions = range(11, 30)
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)")

View file

@ -16,8 +16,8 @@ def nullable(row, index):
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28):
raise ValueError("desktop schema v26, v27 or v28 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29):
raise ValueError("desktop schema v26, v27, v28 or v29 required")
programs = []
for row in db.execute(
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"

View file

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

View file

@ -10,9 +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:
supported_versions = range(15, 29)
supported_versions = range(15, 30)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v28 requis")
raise ValueError("schema desktop v15-v29 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -193,9 +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.
"""
supported_versions = range(18, 29)
supported_versions = range(18, 30)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v28 requis")
raise ImportFailure("schema desktop v18-v29 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,7 +206,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 29)
supported_versions = range(8, 30)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 29)
supported_versions = range(8, 30)
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():

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 29)
supported_versions = range(12, 30)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:

View file

@ -151,9 +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."""
supported_versions = range(11, 29)
supported_versions = range(11, 30)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v28 requis")
raise ImportFailure("schema desktop v11-v29 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ 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, 25, 26, 27, 28):
fail("schema desktop v17-v28 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
fail("schema desktop v17-v29 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,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 range(5, 29):
if version not in range(5, 30):
raise ImportFailure(
f"base desktop schema v5 à v28 attendue, version trouvée: {version}"
f"base desktop schema v5 à v29 attendue, version trouvée: {version}"
)
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
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, 25, 26, 27, 28):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +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")
supported_versions = range(15, 29)
supported_versions = range(15, 30)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v28 requis")
fail("schema desktop v15-v29 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -87,8 +87,8 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28):
fail("desktop schema v27 or v28 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29):
fail("desktop schema v27, v28 or v29 required")
added = 0
advanced = 0
skipped = 0

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 28
#define TRAINLOG_DATABASE_SCHEMA_VERSION 29
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -44,6 +44,7 @@
* occurrence, delivery, and withdrawal identities;
* - `pgr` for desktop Program V1 revision identities;
* - `del` for immutable causal deletion operations.
* - `sl`, `slr`, and `sle` for sleep diary entries, revisions, and events.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.

View file

@ -0,0 +1,100 @@
/* Trainlog Sleep Diary V1 domain API. */
#ifndef TRAINLOG_SLEEP_DIARY_H
#define TRAINLOG_SLEEP_DIARY_H
#include <stdbool.h>
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
#define TRAINLOG_SLEEP_EVENTS_MAX 64U
#define TRAINLOG_SLEEP_NOTES_MAX 16384U
#define TRAINLOG_SLEEP_ENTRY_ID_CAPACITY 40U
#define TRAINLOG_SLEEP_REVISION_ID_CAPACITY 41U
#define TRAINLOG_SLEEP_EVENT_ID_CAPACITY 41U
#define TRAINLOG_SLEEP_TIMESTAMP_CAPACITY 64U
typedef enum TrainlogSleepQuality {
TRAINLOG_SLEEP_QUALITY_UNSET = 0,
TRAINLOG_SLEEP_QUALITY_TB,
TRAINLOG_SLEEP_QUALITY_B,
TRAINLOG_SLEEP_QUALITY_MOY,
TRAINLOG_SLEEP_QUALITY_M,
TRAINLOG_SLEEP_QUALITY_TM
} TrainlogSleepQuality;
typedef enum TrainlogSleepEventType {
TRAINLOG_SLEEP_EVENT_BED_TIME = 0,
TRAINLOG_SLEEP_EVENT_FINAL_GET_UP,
TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP,
TRAINLOG_SLEEP_EVENT_SLEEP,
TRAINLOG_SLEEP_EVENT_NAP,
TRAINLOG_SLEEP_EVENT_LONG_AWAKE,
TRAINLOG_SLEEP_EVENT_HALF_SLEEP,
TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
} TrainlogSleepEventType;
typedef struct TrainlogSleepEvent {
char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY];
TrainlogSleepEventType type;
char start_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char end_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
} TrainlogSleepEvent;
typedef struct TrainlogSleepDiaryEntry {
char entry_id[TRAINLOG_SLEEP_ENTRY_ID_CAPACITY];
char night_start_date[11];
char night_end_date[11];
char created_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char updated_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
char parent_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
TrainlogSleepQuality sleep_quality;
TrainlogSleepQuality wake_quality;
TrainlogSleepQuality day_form;
char treatment_and_notes[TRAINLOG_SLEEP_NOTES_MAX + 1U];
TrainlogSleepEvent events[TRAINLOG_SLEEP_EVENTS_MAX];
size_t event_count;
bool deleted;
} TrainlogSleepDiaryEntry;
typedef TrainlogStatus (*TrainlogSleepDiaryVisitor)(void *context,
const TrainlogSleepDiaryEntry *entry);
/* CONTRACT: timestamps are ISO-8601 instants carrying Z or a numeric offset.
* Intervals are ordered by absolute time, never lexically or by HH:MM. */
bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry);
/* Creates one stable identity and initial immutable revision transactionally. */
TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database,
TrainlogSleepDiaryEntry *entry);
/* Replaces the complete current snapshot iff expected_revision is current.
* Exact replay of an already-current resulting revision is idempotent. */
TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database,
const char *expected_revision,
TrainlogSleepDiaryEntry *entry);
TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database,
const char *entry_id,
bool include_deleted,
TrainlogSleepDiaryEntry *output);
/* Dates are inclusive YYYY-MM-DD bounds; NULL means unbounded. */
TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database,
const char *start_date,
const char *end_date,
size_t limit,
TrainlogSleepDiaryVisitor visitor,
void *context);
/* Logical deletion is revision guarded and retained for sync replay safety. */
TrainlogStatus
trainlog_sleep_diary_delete(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *deleted_at,
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]);
#endif

View file

@ -48,6 +48,7 @@ if web_assets_enabled
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/analysis.ts',
'../web/src/api/sleepDiary.ts',
'../web/src/api/analysis.test.ts',
'../web/src/analysis/messages.ts',
'../web/src/api/dashboardLayout.ts',
@ -77,6 +78,9 @@ if web_assets_enabled
'../web/src/presentation/dateFormat.ts',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/AnalysisPage.tsx',
'../web/src/routes/SleepDiaryWorkspace.tsx',
'../web/src/report/sleepDiaryPdf.ts',
'../web/src/report/sleepDiaryPdf.test.ts',
'../web/src/routes/AnalysisPage.test.tsx',
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
@ -219,6 +223,7 @@ trainlog_core_sources = files(
'src/web_sessions.c',
'src/web_session_deletions.c',
'src/web_programs.c',
'src/web_sleep.c',
'src/web_exercises.c',
'src/duration.c',
'src/id.c',
@ -229,6 +234,7 @@ trainlog_core_sources = files(
'src/generation_mtp.c',
'src/reps.c',
'src/session_generation.c',
'src/sleep_diary.c',
'src/statistics.c',
'src/measured_max.c',
'src/sync.c',
@ -801,6 +807,22 @@ test_schema_v28_migration = executable(
test('schema_v28_migration', test_schema_v28_migration)
test_sleep_diary = executable(
'test_sleep_diary',
'tests/test_sleep_diary.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('sleep_diary', test_sleep_diary)
test_schema_v29_migration = executable(
'test_schema_v29_migration',
'tests/test_schema_v29_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v29_migration', test_schema_v29_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -875,6 +897,11 @@ test(
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120,
)
test(
'sleep_diary_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
)
test(
'session_preparation_export',
find_program('python3'),

View file

@ -1274,6 +1274,41 @@ static const char *const MIGRATE_V27_TO_V28_SQL =
"ON program_session_executions(program_id,state);"
"PRAGMA user_version=28;COMMIT;PRAGMA foreign_keys=ON;";
/* WHY: sleep observations need minute-precise, timezone-aware facts that can
* later be correlated with physiological samples; a rendered 18:00 timeline
* is not a persistence model. CONTRACT: one stable diary identity owns a
* bounded causal revision chain and zero or more typed points/intervals.
* INVARIANT: wall-clock timestamps never order concurrent peer revisions and
* logical deletion retains the last revision for causal replay protection. */
static const char *const MIGRATE_V28_TO_V29_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS sleep_diary_entries("
"entry_id TEXT PRIMARY KEY,night_start_date TEXT NOT NULL,night_end_date TEXT NOT NULL,"
"created_at TEXT NOT NULL,updated_at TEXT NOT NULL,current_revision_id TEXT NOT NULL,"
"deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
"CHECK(entry_id GLOB 'sl_*'),CHECK(night_start_date<night_end_date));"
"CREATE TABLE IF NOT EXISTS sleep_diary_revisions("
"revision_id TEXT PRIMARY KEY,entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) "
"ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
"sleep_quality TEXT CHECK(sleep_quality IN('TB','B','Moy','M','TM')),"
"wake_quality TEXT CHECK(wake_quality IN('TB','B','Moy','M','TM')),"
"day_form TEXT CHECK(day_form IN('TB','B','Moy','M','TM')),"
"treatment_and_notes TEXT,"
"CHECK(treatment_and_notes IS NULL OR length(CAST(treatment_and_notes AS BLOB))<=16384),"
"UNIQUE(entry_id,revision_id));"
"CREATE TABLE IF NOT EXISTS sleep_diary_events("
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
"event_id TEXT NOT NULL,event_type TEXT NOT NULL CHECK(event_type IN("
"'bed_time','final_get_up','night_get_up','sleep','nap','long_awake','half_sleep',"
"'daytime_sleepiness')),start_at TEXT NOT NULL,end_at TEXT,"
"CHECK((event_type IN('bed_time','final_get_up','night_get_up','daytime_sleepiness') AND "
"end_at IS NULL) OR (event_type IN('sleep','nap','long_awake','half_sleep') AND "
"end_at IS NOT NULL)),PRIMARY KEY(revision_id,event_id));"
"CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON "
"sleep_diary_entries(night_start_date,deleted);"
"CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at);"
"PRAGMA user_version=29;COMMIT;";
/* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when
* absent; a genuine v24 database still receives both before opening returns. */
@ -2063,7 +2098,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28) {
version == 26 || version == 27 || version == 28 || version == 29) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2205,6 +2240,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 28) {
status = execute_sql(database, MIGRATE_V27_TO_V28_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 29) {
status = execute_sql(database, MIGRATE_V28_TO_V29_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2224,7 +2262,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v28" : "migrate database to schema v28",
version == 0 ? "create schema v29" : "migrate database to schema v29",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -20,7 +20,8 @@ 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, "pgr") == 0 || strcmp(prefix, "del") == 0;
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0 ||
strcmp(prefix, "sl") == 0 || strcmp(prefix, "slr") == 0 || strcmp(prefix, "sle") == 0;
}
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {

586
tui/src/sleep_diary.c Normal file
View file

@ -0,0 +1,586 @@
#include "trainlog/sleep_diary.h"
#include <stdio.h>
#include <string.h>
#include <sqlite3.h>
#include "database_internal.h"
#include "timestamp.h"
#include "trainlog/id.h"
static bool date_valid(const char *value) {
TrainlogTimestampKey key;
char timestamp[32];
int written;
if (value == NULL || strlen(value) != 10U) {
return false;
}
written = snprintf(timestamp, sizeof(timestamp), "%sT00:00:00Z", value);
return written == 20 && trainlog_timestamp_parse(timestamp, 20U, &key);
}
static bool id_valid(const char *value, const char *prefix, size_t capacity) {
size_t prefix_size = strlen(prefix);
return value != NULL && strlen(value) < capacity && strncmp(value, prefix, prefix_size) == 0 &&
value[prefix_size] == '_' &&
strlen(value + prefix_size + 1U) == TRAINLOG_UUID_TEXT_LENGTH;
}
static const char *quality_text(TrainlogSleepQuality value) {
static const char *const values[] = {NULL, "TB", "B", "Moy", "M", "TM"};
return value >= TRAINLOG_SLEEP_QUALITY_UNSET && value <= TRAINLOG_SLEEP_QUALITY_TM
? values[value]
: NULL;
}
static bool quality_parse(const unsigned char *value, TrainlogSleepQuality *output) {
size_t index;
if (value == NULL) {
*output = TRAINLOG_SLEEP_QUALITY_UNSET;
return true;
}
for (index = 1U; index <= (size_t)TRAINLOG_SLEEP_QUALITY_TM; ++index) {
if (strcmp((const char *)value, quality_text((TrainlogSleepQuality)index)) == 0) {
*output = (TrainlogSleepQuality)index;
return true;
}
}
return false;
}
static const char *event_text(TrainlogSleepEventType type) {
static const char *const values[] = {"bed_time",
"final_get_up",
"night_get_up",
"sleep",
"nap",
"long_awake",
"half_sleep",
"daytime_sleepiness"};
return type >= TRAINLOG_SLEEP_EVENT_BED_TIME && type <= TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
? values[type]
: NULL;
}
static bool event_parse(const unsigned char *value, TrainlogSleepEventType *output) {
int index;
if (value == NULL) {
return false;
}
for (index = 0; index <= (int)TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS; ++index) {
if (strcmp((const char *)value, event_text((TrainlogSleepEventType)index)) == 0) {
*output = (TrainlogSleepEventType)index;
return true;
}
}
return false;
}
static bool point_type(TrainlogSleepEventType type) {
return type == TRAINLOG_SLEEP_EVENT_BED_TIME || type == TRAINLOG_SLEEP_EVENT_FINAL_GET_UP ||
type == TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP ||
type == TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS;
}
static bool timestamp_valid(const char *value, TrainlogTimestampKey *key) {
size_t length = value == NULL ? 0U : strlen(value);
return length > 0U && length < TRAINLOG_SLEEP_TIMESTAMP_CAPACITY &&
trainlog_timestamp_parse(value, length, key);
}
bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry) {
size_t index, other;
TrainlogTimestampKey created, updated;
if (entry == NULL || !date_valid(entry->night_start_date) ||
!date_valid(entry->night_end_date) ||
strcmp(entry->night_start_date, entry->night_end_date) >= 0 ||
!timestamp_valid(entry->created_at, &created) ||
!timestamp_valid(entry->updated_at, &updated) ||
trainlog_timestamp_compare(&updated, &created) < 0 ||
entry->sleep_quality > TRAINLOG_SLEEP_QUALITY_TM ||
entry->wake_quality > TRAINLOG_SLEEP_QUALITY_TM ||
entry->day_form > TRAINLOG_SLEEP_QUALITY_TM ||
strlen(entry->treatment_and_notes) > TRAINLOG_SLEEP_NOTES_MAX ||
entry->event_count > TRAINLOG_SLEEP_EVENTS_MAX) {
return false;
}
for (index = 0U; index < entry->event_count; ++index) {
const TrainlogSleepEvent *event = &entry->events[index];
TrainlogTimestampKey start, end;
bool point = point_type(event->type);
if (!id_valid(event->event_id, "sle", sizeof(event->event_id)) ||
event_text(event->type) == NULL || !timestamp_valid(event->start_at, &start) ||
(point ? event->end_at[0] != '\0'
: !timestamp_valid(event->end_at, &end) ||
trainlog_timestamp_compare(&end, &start) <= 0)) {
return false;
}
for (other = index + 1U; other < entry->event_count; ++other) {
if (strcmp(event->event_id, entry->events[other].event_id) == 0) {
return false;
}
}
}
return true;
}
static void rollback(sqlite3 *database) {
(void)sqlite3_exec(database, "ROLLBACK", NULL, NULL, NULL);
}
static TrainlogStatus insert_revision(TrainlogDatabase *database,
const TrainlogSleepDiaryEntry *entry) {
static const char revision_sql[] =
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,created_at,"
"sleep_quality,wake_quality,day_form,treatment_and_notes) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)";
static const char event_sql[] =
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
"VALUES(?1,?2,?3,?4,?5)";
sqlite3_stmt *statement = NULL;
size_t index;
int result = sqlite3_prepare_v2(database->connection, revision_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry->entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result =
entry->parent_revision_id[0] == '\0'
? sqlite3_bind_null(statement, 3)
: sqlite3_bind_text(statement, 3, entry->parent_revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, entry->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = entry->sleep_quality == TRAINLOG_SLEEP_QUALITY_UNSET
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(
statement, 5, quality_text(entry->sleep_quality), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result = entry->wake_quality == TRAINLOG_SLEEP_QUALITY_UNSET
? sqlite3_bind_null(statement, 6)
: sqlite3_bind_text(
statement, 6, quality_text(entry->wake_quality), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result =
entry->day_form == TRAINLOG_SLEEP_QUALITY_UNSET
? sqlite3_bind_null(statement, 7)
: sqlite3_bind_text(statement, 7, quality_text(entry->day_form), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 8, entry->treatment_and_notes, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
if (result != SQLITE_OK) {
return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR;
}
for (index = 0U; index < entry->event_count; ++index) {
const TrainlogSleepEvent *event = &entry->events[index];
statement = NULL;
result = sqlite3_prepare_v2(database->connection, event_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, event->event_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, event_text(event->type), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, event->start_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = point_type(event->type)
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(statement, 5, event->end_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
if (result != SQLITE_OK) {
return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR;
}
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database,
TrainlogSleepDiaryEntry *entry) {
static const char sql[] = "INSERT INTO sleep_diary_entries VALUES(?1,?2,?3,?4,?5,?6,0)";
sqlite3_stmt *statement = NULL;
TrainlogStatus status;
int result;
if (database == NULL || entry == NULL || entry->deleted || entry->entry_id[0] != '\0' ||
entry->revision_id[0] != '\0') {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (trainlog_id_generate("sl", entry->entry_id, sizeof(entry->entry_id)) !=
TRAINLOG_STATUS_OK ||
trainlog_id_generate("slr", entry->revision_id, sizeof(entry->revision_id)) !=
TRAINLOG_STATUS_OK ||
!trainlog_sleep_diary_validate(entry)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry->night_start_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, entry->night_end_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, entry->created_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 5, entry->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 6, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
status = result == SQLITE_OK ? insert_revision(database, entry)
: (result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR);
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database,
const char *expected_revision,
TrainlogSleepDiaryEntry *entry) {
static const char sql[] =
"UPDATE sleep_diary_entries SET "
"night_start_date=?1,night_end_date=?2,updated_at=?3,current_revision_id=?4 WHERE "
"entry_id=?5 AND current_revision_id=?6 AND deleted=0";
sqlite3_stmt *statement = NULL;
TrainlogStatus status;
char next[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
int result;
if (database == NULL || entry == NULL ||
!id_valid(entry->entry_id, "sl", sizeof(entry->entry_id)) ||
!id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY) ||
entry->deleted) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (strcmp(entry->revision_id, expected_revision) != 0 ||
trainlog_id_generate("slr", next, sizeof(next)) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(
entry->parent_revision_id, sizeof(entry->parent_revision_id), "%s", expected_revision);
(void)snprintf(entry->revision_id, sizeof(entry->revision_id), "%s", next);
if (!trainlog_sleep_diary_validate(entry)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = insert_revision(database, entry);
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->night_start_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry->night_end_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, entry->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 5, entry->entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 6, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT;
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
static TrainlogStatus load_entry(TrainlogDatabase *database,
const char *entry_id,
bool include_deleted,
TrainlogSleepDiaryEntry *output) {
static const char root_sql[] =
"SELECT "
"e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.current_"
"revision_id,e.deleted,r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r."
"treatment_and_notes FROM sleep_diary_entries e JOIN sleep_diary_revisions r ON "
"r.revision_id=e.current_revision_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)";
static const char events_sql[] =
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?1 "
"ORDER BY start_at COLLATE BINARY,event_id COLLATE BINARY";
sqlite3_stmt *statement = NULL;
int result;
memset(output, 0, sizeof(*output));
result = sqlite3_prepare_v2(database->connection, root_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int(statement, 2, include_deleted ? 1 : 0);
}
if (result != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return result == SQLITE_OK ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
}
#define COPY_COLUMN(field, column) \
(void)snprintf(output->field, \
sizeof(output->field), \
"%s", \
sqlite3_column_text(statement, column) == NULL \
? "" \
: (const char *)sqlite3_column_text(statement, column))
COPY_COLUMN(entry_id, 0);
COPY_COLUMN(night_start_date, 1);
COPY_COLUMN(night_end_date, 2);
COPY_COLUMN(created_at, 3);
COPY_COLUMN(updated_at, 4);
COPY_COLUMN(revision_id, 5);
output->deleted = sqlite3_column_int(statement, 6) != 0;
COPY_COLUMN(parent_revision_id, 7);
#undef COPY_COLUMN
if (!quality_parse(sqlite3_column_text(statement, 8), &output->sleep_quality) ||
!quality_parse(sqlite3_column_text(statement, 9), &output->wake_quality) ||
!quality_parse(sqlite3_column_text(statement, 10), &output->day_form)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)snprintf(output->treatment_and_notes,
sizeof(output->treatment_and_notes),
"%s",
sqlite3_column_text(statement, 11) == NULL
? ""
: (const char *)sqlite3_column_text(statement, 11));
if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
result = sqlite3_prepare_v2(database->connection, events_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, output->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
while (result == SQLITE_ROW) {
TrainlogSleepEvent *event;
if (output->event_count == TRAINLOG_SLEEP_EVENTS_MAX) {
result = SQLITE_TOOBIG;
break;
}
event = &output->events[output->event_count++];
(void)snprintf(
event->event_id, sizeof(event->event_id), "%s", sqlite3_column_text(statement, 0));
if (!event_parse(sqlite3_column_text(statement, 1), &event->type)) {
result = SQLITE_MISMATCH;
break;
}
(void)snprintf(
event->start_at, sizeof(event->start_at), "%s", sqlite3_column_text(statement, 2));
(void)snprintf(event->end_at,
sizeof(event->end_at),
"%s",
sqlite3_column_text(statement, 3) == NULL
? ""
: (const char *)sqlite3_column_text(statement, 3));
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
result = SQLITE_ERROR;
}
return result == SQLITE_DONE && trainlog_sleep_diary_validate(output)
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database,
const char *entry_id,
bool include_deleted,
TrainlogSleepDiaryEntry *output) {
if (database == NULL || output == NULL ||
!id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
return load_entry(database, entry_id, include_deleted, output);
}
TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database,
const char *start_date,
const char *end_date,
size_t limit,
TrainlogSleepDiaryVisitor visitor,
void *context) {
static const char sql[] =
"SELECT entry_id FROM sleep_diary_entries WHERE deleted=0 AND (?1 IS NULL OR "
"night_start_date>=?1) AND (?2 IS NULL OR night_start_date<=?2) ORDER BY night_start_date "
"DESC,entry_id COLLATE BINARY LIMIT ?3";
sqlite3_stmt *statement = NULL;
int result;
if (database == NULL || visitor == NULL || limit == 0U || limit > 3660U ||
(start_date != NULL && !date_valid(start_date)) ||
(end_date != NULL && !date_valid(end_date)) ||
(start_date != NULL && end_date != NULL && strcmp(start_date, end_date) > 0)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = start_date == NULL
? sqlite3_bind_null(statement, 1)
: sqlite3_bind_text(statement, 1, start_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = end_date == NULL ? sqlite3_bind_null(statement, 2)
: sqlite3_bind_text(statement, 2, end_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int64(statement, 3, (sqlite3_int64)limit);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
while (result == SQLITE_ROW) {
TrainlogSleepDiaryEntry entry;
TrainlogStatus status =
load_entry(database, (const char *)sqlite3_column_text(statement, 0), false, &entry);
if (status == TRAINLOG_STATUS_OK) {
status = visitor(context, &entry);
}
if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement);
return status;
}
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
result = SQLITE_ERROR;
}
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus
trainlog_sleep_diary_delete(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *deleted_at,
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]) {
static const char sql[] =
"UPDATE sleep_diary_entries SET deleted=1,updated_at=?1,current_revision_id=?2 WHERE "
"entry_id=?3 AND current_revision_id=?4 AND deleted=0";
sqlite3_stmt *statement = NULL;
TrainlogSleepDiaryEntry entry;
TrainlogStatus status;
int result;
if (database == NULL || output_revision == NULL ||
!id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY) ||
!id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = load_entry(database, entry_id, false, &entry);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (strcmp(entry.revision_id, expected_revision) != 0) {
return TRAINLOG_STATUS_CONFLICT;
}
(void)snprintf(
entry.parent_revision_id, sizeof(entry.parent_revision_id), "%s", expected_revision);
if (trainlog_id_generate("slr", entry.revision_id, sizeof(entry.revision_id)) !=
TRAINLOG_STATUS_OK ||
!timestamp_valid(deleted_at, &(TrainlogTimestampKey){0})) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", deleted_at);
entry.deleted = true;
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = insert_revision(database, &entry);
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, deleted_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry.revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT;
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
(void)snprintf(
output_revision, TRAINLOG_SLEEP_REVISION_ID_CAPACITY, "%s", entry.revision_id);
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}

View file

@ -11,125 +11,69 @@
#include "trainlog/database.h"
#include "trainlog/id.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 bool test_database_open_and_schema(void)
{
static bool test_database_open_and_schema(void) {
TrainlogDatabase *database = NULL;
int schema_version = 0;
int foreign_keys = 0;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_schema_version(database, &schema_version) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &schema_version) == TRAINLOG_STATUS_OK);
CHECK(schema_version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(
trainlog_database_foreign_keys_enabled(database, &foreign_keys) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK);
CHECK(foreign_keys == 1);
trainlog_database_close(database);
return true;
}
static bool test_database_open_diagnostic(void)
{
static bool test_database_open_diagnostic(void) {
TrainlogDatabase *database = NULL;
char diagnostic[256];
/* CONTRACT: a launcher must retain the stable status code while showing
* the SQLite operation that blocked access to the user's real database. */
CHECK(
trainlog_database_open_with_diagnostic(
"/",
&database,
diagnostic,
sizeof(diagnostic)
) == TRAINLOG_STATUS_DATABASE_ERROR
);
CHECK(trainlog_database_open_with_diagnostic("/", &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "open database:") != NULL);
return true;
}
static bool test_generated_ids(void)
{
static bool test_generated_ids(void) {
char first[TRAINLOG_GENERATED_ID_CAPACITY];
char second[TRAINLOG_GENERATED_ID_CAPACITY];
char sync_id[TRAINLOG_GENERATED_ID_CAPACITY];
char entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U];
CHECK(
trainlog_id_generate("ex", first, sizeof(first)) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_id_generate("ex", second, sizeof(second)) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_id_generate("ex", first, sizeof(first)) == TRAINLOG_STATUS_OK);
CHECK(trainlog_id_generate("ex", second, sizeof(second)) == TRAINLOG_STATUS_OK);
CHECK(strncmp(first, "ex_", 3U) == 0);
CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH);
CHECK(strcmp(first, second) != 0);
CHECK(first[3U + 14U] == '4');
/* ABI behavior: the new longer occurrence prefix must not invalidate the
* exact buffer size historically sufficient for two-character prefixes. */
CHECK(
trainlog_id_generate(
"se",
v1_exact_capacity,
sizeof(v1_exact_capacity)
) == TRAINLOG_STATUS_OK
);
CHECK(trainlog_id_generate("se", v1_exact_capacity, sizeof(v1_exact_capacity)) ==
TRAINLOG_STATUS_OK);
/* TRAINLOG_SYNC_ID_PREFIX_TEST */
CHECK(
trainlog_id_generate(
"sy",
sync_id,
sizeof(sync_id)
) == TRAINLOG_STATUS_OK
);
CHECK(trainlog_id_generate("sy", sync_id, sizeof(sync_id)) == TRAINLOG_STATUS_OK);
CHECK(
strncmp(
sync_id,
"sy_",
3U
) == 0
);
CHECK(strncmp(sync_id, "sy_", 3U) == 0);
CHECK(
sync_id[3U + 14U] ==
'4'
);
CHECK(sync_id[3U + 14U] == '4');
CHECK(
trainlog_id_generate(
"sxe",
entry_id,
sizeof(entry_id)
) == TRAINLOG_STATUS_OK
);
CHECK(trainlog_id_generate("sxe", entry_id, sizeof(entry_id)) == TRAINLOG_STATUS_OK);
CHECK(strncmp(entry_id, "sxe_", 4U) == 0);
CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U);
CHECK(entry_id[4U + 14U] == '4');
@ -137,19 +81,15 @@ static bool test_generated_ids(void)
return true;
}
static bool seed_exercise(TrainlogDatabase *database)
{
return trainlog_database_insert_exercise(
database,
"ex_test",
"Presse à cuisses",
"presse à cuisses",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK;
static bool seed_exercise(TrainlogDatabase *database) {
return trainlog_database_insert_exercise(database,
"ex_test",
"Presse à cuisses",
"presse à cuisses",
TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK;
}
static bool test_session_insert(void)
{
static bool test_session_insert(void) {
TrainlogDatabase *database = NULL;
TrainlogSetInput sets[3];
TrainlogSessionExerciseInput exercise;
@ -158,10 +98,7 @@ static bool test_session_insert(void)
TrainlogPersistedExerciseDetail persisted_exercises[1];
size_t count = 0U;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(seed_exercise(database));
(void)memset(sets, 0, sizeof(sets));
@ -175,12 +112,7 @@ static bool test_session_insert(void)
sets[2].weight_kg = 0.0;
(void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(
exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_test"
);
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_test");
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
exercise.rest_seconds = 60;
exercise.target_sets = 3;
@ -191,50 +123,20 @@ static bool test_session_insert(void)
exercise.set_count = 3U;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_test");
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_test"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-05T18:00:00+02:00"
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
"2026-09-05T19:00:00+02:00"
);
session.started_at, sizeof(session.started_at), "%s", "2026-09-05T18:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-05T19:00:00+02:00");
session.exercises = &exercise;
session.exercise_count = 1U;
CHECK(
trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_CONFLICT
);
CHECK(
trainlog_database_session_count(database, &count) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U);
CHECK(
trainlog_database_get_session_details(
database,
"se_test",
&persisted_session,
persisted_exercises,
1U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_get_session_details(
database, "se_test", &persisted_session, persisted_exercises, 1U, &count) ==
TRAINLOG_STATUS_OK);
CHECK(count == 1U);
/* Regression: local occurrences are `sxe`, never synchronization runs. */
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
@ -247,67 +149,39 @@ static bool test_session_insert(void)
trainlog_database_free_session_details(persisted_exercises, count);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_negative_weight");
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_negative_weight");
sets[0].weight_kg = -0.5;
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_nonfinite_weight");
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_nonfinite_weight");
sets[0].weight_kg = INFINITY;
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_session_count(database, &count) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U);
trainlog_database_close(database);
return true;
}
static bool test_body_weight_history(void)
{
static bool test_body_weight_history(void) {
TrainlogDatabase *database = NULL;
TrainlogBodyObservationInput observation;
TrainlogWeightPoint points[4];
size_t count = 0U;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
(void)memset(&observation, 0, sizeof(observation));
(void)snprintf(
observation.observation_id,
sizeof(observation.observation_id),
"%s",
"bo_test"
);
(void)snprintf(
observation.observed_at,
sizeof(observation.observed_at),
"%s",
"2026-09-05T07:00:00+02:00"
);
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "%s", "bo_test");
(void)snprintf(observation.observed_at,
sizeof(observation.observed_at),
"%s",
"2026-09-05T07:00:00+02:00");
observation.has_body_weight = true;
observation.body_weight_kg = 82.4;
CHECK(
trainlog_database_insert_body_observation(
database,
&observation
) == TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_list_weight_points(
database,
points,
4U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_list_weight_points(database, points, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U);
CHECK(points[0].body_weight_kg > 82.39);
CHECK(points[0].body_weight_kg < 82.41);
@ -316,22 +190,15 @@ static bool test_body_weight_history(void)
return true;
}
static bool test_transaction_rollback(void)
{
static bool test_transaction_rollback(void) {
TrainlogDatabase *database = NULL;
size_t count = 0U;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_begin(database) == TRAINLOG_STATUS_OK);
CHECK(seed_exercise(database));
CHECK(trainlog_database_rollback(database) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_exercise_count(database, &count) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
/* Schema v13 seeds the five frozen supplied machine identities. The
* rolled-back custom row must not change that baseline. */
CHECK(count == 5U);
@ -340,8 +207,7 @@ static bool test_transaction_rollback(void)
return true;
}
static bool test_exercise_merge_aliases_and_conflicts(void)
{
static bool test_exercise_merge_aliases_and_conflicts(void) {
TrainlogDatabase *database = NULL;
TrainlogExerciseBodyZone zones[4];
const char *source_secondary[] = {"shoulders"};
@ -350,50 +216,53 @@ static bool test_exercise_merge_aliases_and_conflicts(void)
TrainlogExerciseMergePreview preview;
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_source", "Curl A",
"curl a", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_target", "Curl B",
"curl b", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_final", "Curl C",
"curl c", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_duration", "Hold",
"hold", TRAINLOG_TRACKING_DURATION) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_source",
"arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_target",
"arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source",
&preview) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_source", "Curl A", "curl a", TRAINLOG_TRACKING_REPS) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_target", "Curl B", "curl b", TRAINLOG_TRACKING_REPS) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_final", "Curl C", "curl c", TRAINLOG_TRACKING_REPS) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_duration", "Hold", "hold", TRAINLOG_TRACKING_DURATION) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(
database, "ex_source", "arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(
database, "ex_target", "arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source", &preview) ==
TRAINLOG_STATUS_OK);
CHECK(preview.occurrences == 0U && preview.performed_sets == 0U &&
preview.continuous_activities == 0U && preview.max_results == 0U &&
preview.associated_equipment == 0U && preview.body_zones == 2U);
preview.continuous_activities == 0U && preview.max_results == 0U &&
preview.associated_equipment == 0U && preview.body_zones == 2U);
CHECK(trainlog_database_merge_exercises(database, "ex_source", "ex_target") ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_target") == 0);
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones,
4U, &count) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones, 4U, &count) ==
TRAINLOG_STATUS_OK);
CHECK(count == 3U);
CHECK(strcmp(zones[0].zone_id, "arms") == 0 &&
zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
CHECK(strcmp(zones[0].zone_id, "arms") == 0 && zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
/* A second merge must collapse every old source directly to the newest
* canonical ID; resolution never depends on an unbounded alias walk. */
CHECK(trainlog_database_merge_exercises(database, "ex_target", "ex_final") ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_target", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_target", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_merge_exercises(database, "ex_final", "ex_duration") ==
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_final", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_final", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 7U);
@ -406,8 +275,7 @@ struct TestCase {
bool (*function)(void);
};
int main(void)
{
int main(void) {
static const struct TestCase tests[] = {
{"database_open_and_schema", test_database_open_and_schema},
{"database_open_diagnostic", test_database_open_diagnostic},

View file

@ -432,12 +432,12 @@ static int run_test(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 28);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 28);
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(capture_projection(raw,
BUSINESS_PROJECTIONS,
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),

View file

@ -119,7 +119,7 @@ static int run_test(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 28);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(production);
production = NULL;

View file

@ -0,0 +1,73 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "trainlog/database.h"
#define CHECK(value) \
do { \
if (!(value)) { \
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
goto cleanup; \
} \
} 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;
}
int main(void) {
char path[] = "/tmp/trainlog-schema-v29-XXXXXX";
TrainlogDatabase *production = NULL;
sqlite3 *raw = NULL;
int descriptor = mkstemp(path);
int result = EXIT_FAILURE;
CHECK(descriptor >= 0 && close(descriptor) == 0);
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"DROP TABLE sleep_diary_events;DROP TABLE sleep_diary_revisions;"
"DROP TABLE sleep_diary_entries;PRAGMA user_version=28;",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'sleep_diary_%'") == 5);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
1);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
result = EXIT_SUCCESS;
cleanup:
if (production != NULL) {
trainlog_database_close(production);
}
if (raw != NULL) {
(void)sqlite3_close(raw);
}
(void)unlink(path);
if (result == EXIT_SUCCESS) {
puts("PASS schema v28 to v29 migration and reopen");
}
return result;
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic,
"migrate database to schema v28: SQLite rc=",
strlen("migrate database to schema v28: SQLite rc=")) == 0);
"migrate database to schema v29: SQLite rc=",
strlen("migrate database to schema v29: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=29;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=30;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 29 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v28") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -0,0 +1,125 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "trainlog/database.h"
#include "trainlog/id.h"
#include "trainlog/sleep_diary.h"
#include "trainlog/web_sleep.h"
#define CHECK(value) \
do { \
if (!(value)) { \
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
return 1; \
} \
} while (0)
static TrainlogSleepDiaryEntry sample(void) {
TrainlogSleepDiaryEntry entry = {0};
(void)snprintf(entry.night_start_date, sizeof(entry.night_start_date), "2026-10-24");
(void)snprintf(entry.night_end_date, sizeof(entry.night_end_date), "2026-10-25");
(void)snprintf(entry.created_at, sizeof(entry.created_at), "2026-10-25T08:10:00+01:00");
(void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", entry.created_at);
entry.sleep_quality = TRAINLOG_SLEEP_QUALITY_B;
entry.wake_quality = TRAINLOG_SLEEP_QUALITY_MOY;
(void)snprintf(
entry.treatment_and_notes, sizeof(entry.treatment_and_notes), "Synthetic fixture");
entry.event_count = 4U;
(void)trainlog_id_generate("sle", entry.events[0].event_id, sizeof(entry.events[0].event_id));
entry.events[0].type = TRAINLOG_SLEEP_EVENT_BED_TIME;
(void)snprintf(
entry.events[0].start_at, sizeof(entry.events[0].start_at), "2026-10-24T23:30:00+02:00");
(void)trainlog_id_generate("sle", entry.events[1].event_id, sizeof(entry.events[1].event_id));
entry.events[1].type = TRAINLOG_SLEEP_EVENT_SLEEP;
(void)snprintf(
entry.events[1].start_at, sizeof(entry.events[1].start_at), "2026-10-25T00:30:00+02:00");
(void)snprintf(
entry.events[1].end_at, sizeof(entry.events[1].end_at), "2026-10-25T02:30:00+02:00");
(void)trainlog_id_generate("sle", entry.events[2].event_id, sizeof(entry.events[2].event_id));
entry.events[2].type = TRAINLOG_SLEEP_EVENT_LONG_AWAKE;
(void)snprintf(
entry.events[2].start_at, sizeof(entry.events[2].start_at), "2026-10-25T02:30:00+02:00");
(void)snprintf(
entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:30:00+01:00");
(void)trainlog_id_generate("sle", entry.events[3].event_id, sizeof(entry.events[3].event_id));
entry.events[3].type = TRAINLOG_SLEEP_EVENT_FINAL_GET_UP;
(void)snprintf(
entry.events[3].start_at, sizeof(entry.events[3].start_at), "2026-10-25T08:00:00+01:00");
return entry;
}
static TrainlogStatus count_visitor(void *context, const TrainlogSleepDiaryEntry *entry) {
size_t *count = context;
if (entry == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
++*count;
return TRAINLOG_STATUS_OK;
}
int main(void) {
TrainlogDatabase *database = NULL;
TrainlogSleepDiaryEntry entry = sample();
TrainlogSleepDiaryEntry loaded;
TrainlogSleepDiaryEntry stale;
TrainlogSleepDiaryEntry second;
char *json = NULL;
size_t json_size = 0U;
char first_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
char deleted_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_sleep_diary_validate(&entry));
CHECK(trainlog_sleep_diary_create(database, &entry) == TRAINLOG_STATUS_OK);
second = sample();
(void)snprintf(second.night_start_date, sizeof(second.night_start_date), "2026-10-25");
(void)snprintf(second.night_end_date, sizeof(second.night_end_date), "2026-10-26");
(void)snprintf(
second.events[0].start_at, sizeof(second.events[0].start_at), "2026-10-26T00:30:00+01:00");
CHECK(trainlog_sleep_diary_create(database, &second) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_sleep_list_json(database, NULL, NULL, 7U, &json, &json_size) ==
TRAINLOG_STATUS_OK);
CHECK(json_size > 0U && strstr(json, "\"average_bed_minute\":360") != NULL);
free(json);
json = NULL;
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK);
CHECK(loaded.event_count == 4U && loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B);
(void)snprintf(first_revision, sizeof(first_revision), "%s", loaded.revision_id);
stale = loaded;
loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB;
(void)snprintf(loaded.updated_at, sizeof(loaded.updated_at), "2026-10-25T19:00:00+01:00");
CHECK(trainlog_sleep_diary_update(database, first_revision, &loaded) == TRAINLOG_STATUS_OK);
stale.day_form = TRAINLOG_SLEEP_QUALITY_TM;
(void)snprintf(stale.updated_at, sizeof(stale.updated_at), "2026-10-25T20:00:00+01:00");
CHECK(trainlog_sleep_diary_update(database, first_revision, &stale) ==
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_sleep_diary_list(
database, "2026-10-24", "2026-10-24", 7U, count_visitor, &count) ==
TRAINLOG_STATUS_OK &&
count == 1U);
CHECK(trainlog_sleep_diary_delete(database,
entry.entry_id,
loaded.revision_id,
"2026-10-26T09:00:00+01:00",
deleted_revision) == TRAINLOG_STATUS_OK);
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) ==
TRAINLOG_STATUS_NOT_FOUND);
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, true, &loaded) == TRAINLOG_STATUS_OK &&
loaded.deleted);
CHECK(trainlog_sleep_diary_delete(database,
entry.entry_id,
first_revision,
"2026-10-26T10:00:00+01:00",
deleted_revision) != TRAINLOG_STATUS_OK);
/* Same local clock across the DST fallback is a positive absolute interval. */
entry = sample();
(void)snprintf(
entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:15:00+02:00");
CHECK(!trainlog_sleep_diary_validate(&entry));
trainlog_database_close(database);
puts("PASS sleep diary domain");
return 0;
}

View file

@ -219,7 +219,7 @@ static bool import_archive_and_prepare(void) {
char *second_entry;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(scalar(database, "PRAGMA user_version") == 28);
CHECK(scalar(database, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(database,
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
"name IN('generation_id','acknowledged_at')") == 2);