test(programs): close migration and sync gates

This commit is contained in:
fy59 2026-09-18 20:01:42 +02:00
parent 20fc427c2f
commit f431b6ec9c
7 changed files with 961 additions and 35 deletions

View file

@ -170,7 +170,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name
heuristics.
Android local SQLite schema v23 retains the exactly-one durable active-session
Android local SQLite schema v24 retains the exactly-one durable active-session
draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit
@ -184,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v24 and Android migrations reach v23 without changing
desktop migrations reach v26 and Android migrations reach v24 without changing
that identity contract. The active
completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -241,13 +241,14 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version;
```
The current desktop schema is v24. Schema v9 added an occurrence-owned explicit
The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
and durable preparation withdrawal. None changes `TRAINLOG_FORMAT_V1`.
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and
regression coverage.

View file

@ -26,6 +26,224 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
val root = Files.createTempDirectory("trainlog-program-deletion-generation-").toFile()
val desktopDb = java.io.File(root, "desktop.sqlite")
val desktopOwned = java.io.File(root, "desktop-owned")
val desktopObjects = java.io.File(root, "desktop-objects")
val androidName = "program-deletion-generation-${UUID.randomUUID()}.db"
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val deletionId = "program-delete-generation-regression"
var android = TrainlogRepository(context, androidName)
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
fun captureAndPublish(service: SyncGenerationService): Pair<String, java.io.File> {
val captured =
run(
"python3",
generationTool,
"capture-desktop",
"--database",
desktopDb.absolutePath,
"--owned-root",
desktopOwned.absolutePath,
"--consumer-peer",
service.peerId(),
)
val generationId =
checkNotNull(Regex("generation_id=(gen_[^ ]+)").find(captured)).groupValues[1]
run(
"python3",
generationTool,
"publish",
"--database",
desktopDb.absolutePath,
"--generation-id",
generationId,
"--object-root",
desktopObjects.absolutePath,
)
return generationId to java.io.File(desktopObjects, "generations/$generationId")
}
fun acceptAndroidAck(generationId: String, acknowledgement: String): String {
val acknowledgementFile =
java.io.File(root, "$generationId-ack.json").apply {
writeText(acknowledgement)
}
return run(
"python3",
generationTool,
"accept-ack",
acknowledgementFile.absolutePath,
"--database",
desktopDb.absolutePath,
)
}
fun androidDeletionCount(): Int =
SQLiteDatabase.openDatabase(
context.getDatabasePath(androidName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT COUNT(*) FROM synced_program_deletions WHERE program_id=?",
arrayOf(programId),
)
.use {
assertTrue(it.moveToFirst())
it.getInt(0)
}
}
try {
val created = run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
val createdProgram = JSONObject(created.substringAfter("PROGRAM_CREATE=").trim())
val initialRevision = createdProgram.getString("revision_id")
assertEquals(programId, createdProgram.getString("program_id"))
var service = SyncGenerationService(android)
val (liveGenerationId, livePublished) = captureAndPublish(service)
val desktopPeer =
JSONObject(java.io.File(livePublished, "manifest.json").readText())
.getJSONObject("producer")
.getString("peer_id")
val livePrograms =
JSONObject(java.io.File(livePublished, "programs-v1.json").readText())
assertEquals(
programId,
livePrograms
.getJSONArray("programs")
.getJSONObject(0)
.getString("program_id"),
)
assertEquals(0, livePrograms.getJSONArray("deletions").length())
val liveAck = service.consume(livePublished)
assertEquals(programId, android.listSyncedPrograms().single().programId)
assertTrue(
acceptAndroidAck(liveGenerationId, liveAck).contains("ACK_ACCEPT=acknowledged")
)
val deleted =
run(
fixture.absolutePath,
desktopDb.absolutePath,
"program-delete",
initialRevision,
deletionId,
)
val replayedDelete =
run(
fixture.absolutePath,
desktopDb.absolutePath,
"program-delete",
initialRevision,
deletionId,
)
assertEquals(deleted, replayedDelete)
val deletionRevision =
JSONObject(deleted.substringAfter("PROGRAM_DELETE=").trim())
.getString("revision_id")
val (deletionGenerationId, deletionPublished) = captureAndPublish(service)
val deletedPrograms =
JSONObject(java.io.File(deletionPublished, "programs-v1.json").readText())
assertEquals(0, deletedPrograms.getJSONArray("programs").length())
assertEquals(1, deletedPrograms.getJSONArray("deletions").length())
val exportedDeletion = deletedPrograms.getJSONArray("deletions").getJSONObject(0)
assertEquals(deletionId, exportedDeletion.getString("deletion_id"))
assertEquals(initialRevision, exportedDeletion.getString("predecessor_revision_id"))
assertEquals(deletionRevision, exportedDeletion.getString("revision_id"))
val deletionAck = service.consume(deletionPublished)
assertTrue(android.listSyncedPrograms().isEmpty())
assertTrue(
acceptAndroidAck(deletionGenerationId, deletionAck)
.contains("ACK_ACCEPT=acknowledged")
)
val acknowledgedDeletion =
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT generation_id || '|' || (acknowledged_at IS NOT NULL) " +
"FROM program_deletions WHERE request_id='$deletionId';",
)
.trim()
assertEquals("$deletionGenerationId|1", acknowledgedDeletion)
android.close()
android = TrainlogRepository(context, androidName)
service = SyncGenerationService(android)
val reopenedDesktopPeer =
checkNotNull(
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
)
)
)
.groupValues[1]
assertEquals(desktopPeer, reopenedDesktopPeer)
assertEquals(liveAck, service.consume(livePublished))
assertTrue(android.listSyncedPrograms().isEmpty())
assertEquals(1, androidDeletionCount())
val (cleanGenerationId, cleanPublished) = captureAndPublish(service)
val cleanPrograms =
JSONObject(java.io.File(cleanPublished, "programs-v1.json").readText())
assertEquals(0, cleanPrograms.getJSONArray("programs").length())
assertEquals(0, cleanPrograms.getJSONArray("deletions").length())
val cleanAck = service.consume(cleanPublished)
assertTrue(android.listSyncedPrograms().isEmpty())
assertEquals(1, androidDeletionCount())
assertTrue(
acceptAndroidAck(cleanGenerationId, cleanAck).contains("ACK_ACCEPT=acknowledged")
)
assertEquals(
"1",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT COUNT(*) FROM program_deletions WHERE program_id='$programId';",
)
.trim(),
)
} finally {
android.close()
context.deleteDatabase(androidName)
root.deleteRecursively()
}
}
@Test
fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() {
val sourceName = "generation-resume-source-${UUID.randomUUID()}.db"

View file

@ -33,17 +33,45 @@ not assign `PASS` or `FROZEN` status.
## Validation recorded
- Full Meson suite: **88/88**.
- Full Meson suite: **89/89**.
- Focused ASan/UBSan suite: **3/3**.
- Web Vitest: **94** tests.
- Web production build completed.
- Android unit suite: **229** tests, **5 skipped**; `assembleDebug` completed.
- Android unit suite: **230** tests, **5 skipped**; `assembleDebug` completed.
- Python Programs and synchronization suites completed.
- Format and diff validators completed, including `git diff --check`.
- Real Firefox Programs screenshots passed and are retained as
[desktop cards, 1440x1000](evidence/web-programs-v1-card-desktop.png) and
[mobile deletion dialog, exact 390x844](evidence/web-programs-v1-delete-dialog-mobile-390x844.png).
### Pre-switch migration and exchange gates
`schema_v26_migration` deterministically migrates a populated v25 fixture with
24 sessions, 48 entries, preparation provenance, and historical evidence. It
asserts the exact typed post-migration projections rather than only table or
column presence.
The integrated Android `SyncGenerationServiceTest` executes the real production
chain from a live Program through capture and publication, Android consumption,
Program deletion and tombstone publication, the real correlated ACK, desktop
acceptance and reopen. An exact old generation is idempotently recognized and
ACKed while the durable tombstone prevents Program resurrection; a second
exchange adds no duplicate data.
### Private real-copy v25 to v26 migration evidence
A private real-copy migration gate preserved user-data confidentiality while
validating the source backup at v25 and its post-migration v26 result. The
post-migration integrity check passed, foreign-key violations numbered **0**,
and the business-data SHA-256 was identical before and after migration:
`b264dd1b5fd7cdc599b2f6e2a20c01af794949e7871b8e3107ff3b29a06c6e11`.
The program identity hash was `1b14...`; the 24 sessions hash was `593f...`;
the 164 entries hash was `b991...`; and the two preparations hash was
`2e273...`. The copy contained no `deleted_at` values and no
`program_deletions` rows. All 45 unchanged business tables matched their
canonical field-level hashes.
These results are implementation and presentation-validation evidence. They do
not substitute for separate deployment validation.

View file

@ -54,7 +54,10 @@ replay.
import, exact replay, responsive detail serialization, archive replay,
revision-guarded terminal deletion, response replay, preparation creation,
usage/provenance and absence of delivery. Schema migration coverage checks all
v26 tables and columns. Programs Vitest covers preview before the only importing
v26 tables and columns. `schema_v26_migration` additionally migrates a
deterministically populated v25 fixture containing 24 sessions and 48 entries,
with preparation provenance and history, and verifies exact typed projections
after migration. Programs Vitest covers preview before the only importing
mutation, explicit preparation creation/navigation, discrete trash confirmation,
focus restoration, and Escape/Cancel zero-mutation behavior. The full HTTP,
pagination, strict-parser and Core suites retain malformed, duplicate-key,
@ -63,10 +66,14 @@ bounds, catalog/profile/equipment, conflict and rollback coverage.
Android Programs projection tests cover schema-v23 to v24 migration, full
nondeleted `trainlog-programs` V1 snapshot import, durable deletion tombstones,
read-only Sessions list/detail data, exact replay, capability gating, and
correlated ACK behavior. Python Programs/synchronization suites cover the
staged companion's full snapshot, unacknowledged tombstone publication, and
ACK-driven acknowledgment without changing mobile V3, catalog, or preparation
artifacts.
correlated ACK behavior. The integrated `SyncGenerationServiceTest` invokes the
real production chain from a live Program through capture, publication, Android
consumption, deletion/tombstone publication, real ACK, desktop acceptance and
reopen; an exact old generation is idempotently recognized and ACKed while the
durable tombstone prevents Program resurrection, and a second exchange creates
no duplication. Python Programs/synchronization suites cover the staged
companion's full snapshot, unacknowledged tombstone publication, and ACK-driven
acknowledgment without changing mobile V3, catalog, or preparation artifacts.
`test_web_sessions_browser.py` runs headless Firefox against the embedded
production assets and C HTTP server with private temporary XDG roots. It creates

View file

@ -747,6 +747,15 @@ test_schema_v9_migration = executable(
test('schema_v9_migration', test_schema_v9_migration)
test_schema_v26_migration = executable(
'test_schema_v26_migration',
'tests/test_schema_v26_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v26_migration', test_schema_v26_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',

View file

@ -1,47 +1,222 @@
/* Test-only producer fixture through the public desktop persistence API. */
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "trainlog/database.h"
#include "trainlog/web_programs.h"
int main(int argc, char **argv)
{
TrainlogDatabase *database = NULL;
static const char PROGRAM_JSON[] =
"{\"format\":\"trainlog-program\",\"version\":1,\"program\":{"
"\"program_id\":\"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"title\":\"Generation program\",\"note\":null,\"state\":\"active\","
"\"start_date\":\"2026-09-21\",\"end_date\":null,\"sessions\":[{"
"\"program_session_id\":\"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\","
"\"title\":\"Session A\",\"session_type\":\"training\","
"\"planned_for\":null,\"note\":null,\"occurrences\":[{"
"\"entry_id\":\"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc\","
"\"exercise_id\":\"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd\","
"\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90,"
"\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null,"
"\"target_weight_kg\":40.0,\"notes\":null}]}]}}";
static int create_program(TrainlogDatabase *database) {
char *response = NULL;
size_t response_size = 0U;
TrainlogStatus status;
status = trainlog_database_insert_exercise_profiled(database,
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"Program exercise fixture",
"program exercise fixture",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U);
if (status != TRAINLOG_STATUS_OK) {
return 3;
}
status = trainlog_web_programs_import_json(
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size);
free(response);
response = NULL;
if (status != TRAINLOG_STATUS_OK ||
trainlog_web_programs_detail_json(
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) !=
TRAINLOG_STATUS_OK) {
free(response);
return 4;
}
(void)printf("PROGRAM_CREATE=%s\n", response);
free(response);
return 0;
}
static int
delete_program(TrainlogDatabase *database, const char *expected_revision, const char *request_id) {
char *response = NULL;
size_t response_size = 0U;
TrainlogStatus status =
trainlog_web_programs_delete_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expected_revision,
request_id,
&response,
&response_size);
if (status != TRAINLOG_STATUS_OK) {
free(response);
return 5;
}
(void)printf("PROGRAM_DELETE=%s\n", response);
free(response);
return 0;
}
static int create_session_fixture(TrainlogDatabase *database) {
TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5};
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
if (argc != 2 || trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) {
return 2;
}
if (trainlog_database_insert_exercise_profiled(
database, "ex_77777777-7777-4777-8777-777777777777",
"Desktop generation fixture", "desktop generation fixture",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 3;
if (trainlog_database_insert_exercise_profiled(database,
"ex_77777777-7777-4777-8777-777777777777",
"Desktop generation fixture",
"desktop generation fixture",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U) != TRAINLOG_STATUS_OK) {
return 3;
}
(void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s",
(void)snprintf(exercise.entry_id,
sizeof(exercise.entry_id),
"%s",
"sxe_88888888-8888-4888-8888-888888888888");
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s",
(void)snprintf(exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_77777777-7777-4777-8777-777777777777");
exercise.recording_mode = TRAINLOG_RECORDING_SETS;
exercise.tracking_mode = TRAINLOG_TRACKING_REPS;
exercise.sets = &set;
exercise.set_count = 1U;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
(void)snprintf(session.session_id,
sizeof(session.session_id),
"%s",
"se_99999999-9999-4999-8999-999999999999");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-17T10:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s",
"2026-09-17T10:30:00+02:00");
(void)snprintf(
session.started_at, sizeof(session.started_at), "%s", "2026-09-17T10:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-17T10:30:00+02:00");
session.exercises = &exercise;
session.exercise_count = 1U;
if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 4;
return 4;
}
trainlog_database_close(database);
(void)puts("SYNC_GENERATION_FIXTURE=PASS");
return 0;
}
static int
check_integrity(sqlite3 *connection, bool *output_integrity_ok, bool *output_foreign_keys_ok) {
sqlite3_stmt *statement = NULL;
const unsigned char *integrity_result;
int rc;
*output_integrity_ok = false;
*output_foreign_keys_ok = false;
rc = sqlite3_prepare_v2(connection, "PRAGMA integrity_check;", -1, &statement, NULL);
if (rc != SQLITE_OK) {
return 1;
}
rc = sqlite3_step(statement);
integrity_result = rc == SQLITE_ROW ? sqlite3_column_text(statement, 0) : NULL;
*output_integrity_ok =
integrity_result != NULL && strcmp((const char *)integrity_result, "ok") == 0;
if (sqlite3_finalize(statement) != SQLITE_OK || !*output_integrity_ok) {
return 1;
}
rc = sqlite3_prepare_v2(connection, "PRAGMA foreign_key_check;", -1, &statement, NULL);
if (rc != SQLITE_OK) {
return 1;
}
rc = sqlite3_step(statement);
*output_foreign_keys_ok = rc == SQLITE_DONE;
if (sqlite3_finalize(statement) != SQLITE_OK || !*output_foreign_keys_ok) {
return 1;
}
return 0;
}
static int migrate_only(const char *path) {
TrainlogDatabase *database = NULL;
char diagnostic[256];
sqlite3 *inspection = NULL;
bool integrity_ok;
bool foreign_key_check_ok;
int foreign_keys_enabled;
int schema_version;
int result = 2;
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
return result;
}
if (trainlog_database_schema_version(database, &schema_version) != TRAINLOG_STATUS_OK ||
trainlog_database_foreign_keys_enabled(database, &foreign_keys_enabled) !=
TRAINLOG_STATUS_OK) {
goto cleanup;
}
trainlog_database_close(database);
database = NULL;
if (sqlite3_open_v2(path, &inspection, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, NULL) !=
SQLITE_OK) {
goto cleanup;
}
if (check_integrity(inspection, &integrity_ok, &foreign_key_check_ok) != 0) {
goto cleanup;
}
(void)printf(
"MIGRATE_ONLY schema_version=%d integrity=%s foreign_keys=%s foreign_key_check=%s\n",
schema_version,
integrity_ok ? "ok" : "failed",
foreign_keys_enabled != 0 ? "enabled" : "disabled",
foreign_key_check_ok ? "ok" : "failed");
result = 0;
cleanup:
if (inspection != NULL) {
(void)sqlite3_close(inspection);
}
if (database != NULL) {
trainlog_database_close(database);
}
return result;
}
int main(int argc, char **argv) {
TrainlogDatabase *database = NULL;
int result;
if (argc == 3 && strcmp(argv[2], "migrate-only") == 0) {
return migrate_only(argv[1]);
}
if ((argc != 2 && argc != 3 && argc != 5) ||
trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) {
return 2;
}
if (argc == 2) {
result = create_session_fixture(database);
} else if (argc == 3 && strcmp(argv[2], "program-create") == 0) {
result = create_program(database);
} else if (argc == 5 && strcmp(argv[2], "program-delete") == 0) {
result = delete_program(database, argv[3], argv[4]);
} else {
result = 2;
}
trainlog_database_close(database);
return result;
}

View file

@ -0,0 +1,488 @@
/**
* @file test_schema_v26_migration.c
* @brief Lossless populated v25 to v26 migration evidence.
*/
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "trainlog/database.h"
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
goto cleanup; \
} \
} while (0)
typedef struct Projection {
char *text;
size_t length;
size_t capacity;
} Projection;
typedef struct ProjectionSpec {
const char *label;
const char *sql;
} ProjectionSpec;
static bool projection_append(Projection *projection, const char *value, size_t length) {
size_t required;
size_t capacity;
char *resized;
if (length > SIZE_MAX - projection->length - 1U) {
return false;
}
required = projection->length + length + 1U;
if (required > projection->capacity) {
capacity = projection->capacity == 0U ? 4096U : projection->capacity;
while (capacity < required) {
if (capacity > SIZE_MAX / 2U) {
return false;
}
capacity *= 2U;
}
resized = realloc(projection->text, capacity);
if (resized == NULL) {
return false;
}
projection->text = resized;
projection->capacity = capacity;
}
(void)memcpy(projection->text + projection->length, value, length);
projection->length += length;
projection->text[projection->length] = '\0';
return true;
}
static bool projection_append_value(Projection *projection, sqlite3_stmt *statement, int column) {
char number[128];
const unsigned char *text;
int length;
int written;
switch (sqlite3_column_type(statement, column)) {
case SQLITE_NULL:
return projection_append(projection, "N;", 2U);
case SQLITE_INTEGER:
written = snprintf(
number, sizeof(number), "I%lld;", (long long)sqlite3_column_int64(statement, column));
return written > 0 && (size_t)written < sizeof(number) &&
projection_append(projection, number, (size_t)written);
case SQLITE_FLOAT:
written =
snprintf(number, sizeof(number), "F%.17g;", sqlite3_column_double(statement, column));
return written > 0 && (size_t)written < sizeof(number) &&
projection_append(projection, number, (size_t)written);
case SQLITE_TEXT:
text = sqlite3_column_text(statement, column);
length = sqlite3_column_bytes(statement, column);
written = snprintf(number, sizeof(number), "T%d:", length);
return text != NULL && length >= 0 && written > 0 && (size_t)written < sizeof(number) &&
projection_append(projection, number, (size_t)written) &&
projection_append(projection, (const char *)text, (size_t)length) &&
projection_append(projection, ";", 1U);
default:
return false;
}
}
/* WHY: row counts cannot prove that nullable targets, floating-point loads, or
* ordered identities survived. CONTRACT: this helper records SQLite types,
* byte lengths, values, columns, and row boundaries for an explicitly ordered
* business query. INVARIANT: equal projections imply field-for-field equality. */
static bool capture_projection(sqlite3 *database,
const ProjectionSpec *specifications,
size_t specification_count,
Projection *projection) {
sqlite3_stmt *statement = NULL;
size_t index;
int column;
int result;
for (index = 0U; index < specification_count; ++index) {
if (!projection_append(
projection, specifications[index].label, strlen(specifications[index].label)) ||
!projection_append(projection, "\n", 1U) ||
sqlite3_prepare_v2(database, specifications[index].sql, -1, &statement, NULL) !=
SQLITE_OK) {
(void)sqlite3_finalize(statement);
return false;
}
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
for (column = 0; column < sqlite3_column_count(statement); ++column) {
if (!projection_append_value(projection, statement, column)) {
(void)sqlite3_finalize(statement);
return false;
}
}
if (!projection_append(projection, "\n", 1U)) {
(void)sqlite3_finalize(statement);
return false;
}
}
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
return false;
}
statement = NULL;
}
return true;
}
static bool execute(sqlite3 *database, const char *sql) {
char *error = NULL;
int result = sqlite3_exec(database, sql, NULL, NULL, &error);
if (result != SQLITE_OK) {
(void)fprintf(stderr, "fixture SQL failed: %s\n", error == NULL ? "no diagnostic" : error);
}
sqlite3_free(error);
return result == SQLITE_OK;
}
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 bool insert_program_sessions(sqlite3 *database) {
static const char INSERT_SESSION[] =
"INSERT INTO program_sessions(program_session_id,program_id,position,title,session_type,"
"planned_for,note) VALUES(?1,'pg_fixture',?2,?3,?4,?5,?6)";
static const char INSERT_ENTRY[] =
"INSERT INTO program_session_entries(program_session_id,entry_id,position,exercise_id,"
"equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,"
"target_reps,target_duration_seconds,target_weight_kg,notes)"
" VALUES(?1,?2,?3,?4,?5,'sets',?6,?7,?8,?9,?10,?11,?12,?13,?14)";
sqlite3_stmt *session = NULL;
sqlite3_stmt *entry = NULL;
char session_id[32];
char entry_id[32];
char title[64];
char planned_for[32];
char note[96];
int position;
int occurrence;
bool ok = false;
if (sqlite3_prepare_v2(database, INSERT_SESSION, -1, &session, NULL) != SQLITE_OK ||
sqlite3_prepare_v2(database, INSERT_ENTRY, -1, &entry, NULL) != SQLITE_OK) {
goto cleanup;
}
for (position = 0; position < 24; ++position) {
(void)snprintf(session_id, sizeof(session_id), "pgs_fixture_%02d", position + 1);
(void)snprintf(
title, sizeof(title), "Week %d · Session %d", position / 3 + 1, position + 1);
(void)snprintf(planned_for, sizeof(planned_for), "2026-10-%02dT18:00:00Z", position + 1);
(void)snprintf(
note, sizeof(note), "Progression block %d, session %d", position / 6 + 1, position + 1);
(void)sqlite3_bind_text(session, 1, session_id, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_int(session, 2, position);
(void)sqlite3_bind_text(session, 3, title, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(
session, 4, position == 23 ? "max_test" : "training", -1, SQLITE_STATIC);
(void)sqlite3_bind_text(session, 5, planned_for, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(session, 6, note, -1, SQLITE_TRANSIENT);
if (sqlite3_step(session) != SQLITE_DONE || sqlite3_reset(session) != SQLITE_OK ||
sqlite3_clear_bindings(session) != SQLITE_OK) {
goto cleanup;
}
for (occurrence = 0; occurrence < 2; ++occurrence) {
(void)snprintf(entry_id, sizeof(entry_id), "pge_%02d_%d", position + 1, occurrence + 1);
(void)snprintf(note,
sizeof(note),
occurrence == 0 ? "Controlled eccentric, RPE %d" : "Timed brace, RPE %d",
7 + position % 3);
(void)sqlite3_bind_text(entry, 1, session_id, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(entry, 2, entry_id, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_int(entry, 3, occurrence);
(void)sqlite3_bind_text(entry,
4,
occurrence == 0 ? "ex_fixture_press" : "ex_fixture_hold",
-1,
SQLITE_STATIC);
(void)sqlite3_bind_text(entry,
5,
occurrence == 0 ? "eq_fixture_stack" : "eq_fixture_mat",
-1,
SQLITE_STATIC);
(void)sqlite3_bind_text(
entry, 6, occurrence == 0 ? "reps" : "duration", -1, SQLITE_STATIC);
(void)sqlite3_bind_int(entry, 7, occurrence == 0 ? 1 : 2);
(void)sqlite3_bind_text(
entry, 8, occurrence == 0 ? "external" : "none", -1, SQLITE_STATIC);
(void)sqlite3_bind_int(entry, 9, occurrence == 0 ? 90 + position : 45 + position);
(void)sqlite3_bind_int(entry, 10, occurrence == 0 ? 4 : 3);
if (occurrence == 0) {
(void)sqlite3_bind_int(entry, 11, 8 + position % 5);
(void)sqlite3_bind_null(entry, 12);
(void)sqlite3_bind_double(entry, 13, 42.5 + (double)position * 1.25);
} else {
(void)sqlite3_bind_null(entry, 11);
(void)sqlite3_bind_int(entry, 12, 30 + position);
(void)sqlite3_bind_null(entry, 13);
}
(void)sqlite3_bind_text(entry, 14, note, -1, SQLITE_TRANSIENT);
if (sqlite3_step(entry) != SQLITE_DONE || sqlite3_reset(entry) != SQLITE_OK ||
sqlite3_clear_bindings(entry) != SQLITE_OK) {
goto cleanup;
}
}
}
ok = true;
cleanup:
(void)sqlite3_finalize(entry);
(void)sqlite3_finalize(session);
return ok;
}
/* CONTRACT: construct a populated v25 database without consulting any user
* path. The current initializer supplies all older schema objects; removing
* exactly the v26 additions recreates the immediate predecessor boundary. */
static bool build_v25_fixture(const char *path) {
TrainlogDatabase *production = NULL;
sqlite3 *database = NULL;
bool ok = false;
if (trainlog_database_open(path, &production) != TRAINLOG_STATUS_OK) {
return false;
}
if (trainlog_database_insert_exercise_profiled(production,
"ex_fixture_press",
"Cable press",
"cable press",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
1U) != TRAINLOG_STATUS_OK ||
trainlog_database_insert_exercise_profiled(production,
"ex_fixture_hold",
"Pallof hold",
"pallof hold",
TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_SETS,
2U) != TRAINLOG_STATUS_OK) {
trainlog_database_close(production);
return false;
}
trainlog_database_close(production);
if (sqlite3_open(path, &database) != SQLITE_OK ||
!execute(database,
"PRAGMA foreign_keys=OFF;"
"DROP INDEX program_deletions_pending;"
"DROP TABLE program_deletions;"
"ALTER TABLE programs DROP COLUMN deleted_at;"
"PRAGMA user_version=25;"
"PRAGMA foreign_keys=ON;") ||
!execute(
database,
"BEGIN IMMEDIATE;"
"INSERT INTO custom_equipment VALUES"
"('eq_fixture_stack','Cable stack','Stack','machine','external'),"
"('eq_fixture_mat','Exercise mat','Mat','surface','none');"
"INSERT INTO sessions(id,session_id,started_at,ended_at,session_type,notes) VALUES"
"(201,'se_fixture_history','2026-09-12T17:00:00Z','2026-09-12T17:52:00Z',"
"'training','Completed history sentinel');"
"INSERT INTO session_exercises(id,entry_id,session_row_id,exercise_row_id,"
"recording_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,"
"target_duration_seconds,target_weight_kg,equipment_id,notes,tracking_mode) VALUES"
"(301,'sxe_fixture_press',201,(SELECT id FROM exercises WHERE "
"exercise_id='ex_fixture_press'),'sets',1,0,'external',105,4,10,NULL,47.5,"
"'eq_fixture_stack','Historic occurrence sentinel','reps'),"
"(302,'sxe_fixture_hold',201,(SELECT id FROM exercises WHERE "
"exercise_id='ex_fixture_hold'),'sets',2,1,'none',45,3,NULL,35,NULL,"
"'eq_fixture_mat','Historic duration sentinel','duration');"
"INSERT INTO performed_sets(id,session_exercise_row_id,position,reps,duration_seconds,"
"weight_kg) VALUES(401,301,0,10,NULL,47.5),(402,301,1,9,NULL,47.5),"
"(403,302,0,NULL,35,NULL);"
"INSERT INTO body_observations(id,observation_id,observed_at,session_row_id,"
"body_weight_kg,waist_cm,left_arm_cm,right_arm_cm,notes) VALUES"
"(501,'bo_fixture','2026-09-12T16:55:00Z',201,78.4,82.1,35.2,35.4,"
"'Historical body sentinel');"
"INSERT INTO programs(program_id,title,note,state,start_date,end_date,created_at,"
"updated_at,revision_id,source_format,source_version,source_payload_sha256) VALUES"
"('pg_fixture','Eight-week strength progression','Imported coach plan','active',"
"'2026-10-01','2026-11-30','2026-09-15T08:00:00Z','2026-09-16T09:30:00Z',"
"'pgr_fixture_2','trainlog-program',1,"
"'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');") ||
!insert_program_sessions(database) ||
!execute(
database,
"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"
"('prep_fixture','prepr_fixture_2','2026-09-17T08:00:00Z',"
"'2026-09-17T08:15:00Z','ready','local',NULL,NULL,NULL,'pg_fixture','pgs_fixture_07');"
"INSERT INTO session_preparation_revisions(revision_id,preparation_id,"
"parent_revision_id,title,session_type,planned_for,notes,created_at) VALUES"
"('prepr_fixture_1','prep_fixture',NULL,'Week 3 · Session 7','training',"
"'2026-10-07T18:00:00Z','Initial Program-derived revision','2026-09-17T08:00:00Z'),"
"('prepr_fixture_2','prep_fixture','prepr_fixture_1','Week 3 · Session 7 adjusted',"
"'training','2026-10-07T18:30:00Z','Adjusted after review','2026-09-17T08:15:00Z');"
"INSERT INTO session_preparation_entries(revision_id,entry_id,position,exercise_id,"
"equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,"
"target_sets,target_reps,target_duration_seconds,target_weight_kg,notes) VALUES"
"('prepr_fixture_1','prepe_fixture_1a',0,'ex_fixture_press','eq_fixture_stack',"
"'sets','reps',1,'external',96,4,10,NULL,50.0,'Program provenance press'),"
"('prepr_fixture_1','prepe_fixture_1b',1,'ex_fixture_hold','eq_fixture_mat',"
"'sets','duration',2,'none',51,3,NULL,36,NULL,'Program provenance hold'),"
"('prepr_fixture_2','prepe_fixture_2a',0,'ex_fixture_press','eq_fixture_stack',"
"'sets','reps',1,'external',100,5,8,NULL,52.5,'Adjusted press'),"
"('prepr_fixture_2','prepe_fixture_2b',1,'ex_fixture_hold','eq_fixture_mat',"
"'sets','duration',2,'none',55,3,NULL,40,NULL,'Adjusted hold');"
"COMMIT;") ||
scalar(database, "PRAGMA user_version") != 25 ||
scalar(database, "SELECT COUNT(*) FROM program_sessions") != 24 ||
scalar(database, "SELECT COUNT(*) FROM program_session_entries") != 48 ||
scalar(database, "SELECT COUNT(*) FROM pragma_foreign_key_check") != 0) {
goto cleanup;
}
ok = true;
cleanup:
if (database != NULL && sqlite3_close(database) != SQLITE_OK) {
ok = false;
}
return ok;
}
static int run_test(void) {
static const ProjectionSpec BUSINESS_PROJECTIONS[] = {
{"programs",
"SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at,revision_id,"
"source_format,source_version,source_payload_sha256 FROM programs ORDER BY program_id"},
{"program_sessions",
"SELECT * FROM program_sessions ORDER BY program_id,position,program_session_id"},
{"program_session_entries",
"SELECT * FROM program_session_entries ORDER BY program_session_id,position,entry_id"},
{"session_preparations", "SELECT * FROM session_preparations ORDER BY preparation_id"},
{"session_preparation_revisions",
"SELECT * FROM session_preparation_revisions ORDER BY "
"preparation_id,created_at,revision_id"},
{"session_preparation_entries",
"SELECT * FROM session_preparation_entries ORDER BY revision_id,position,entry_id"},
{"exercises", "SELECT * FROM exercises ORDER BY id"},
{"custom_equipment", "SELECT * FROM custom_equipment ORDER BY equipment_id"},
{"sessions", "SELECT * FROM sessions ORDER BY id"},
{"session_exercises", "SELECT * FROM session_exercises ORDER BY id"},
{"performed_sets", "SELECT * FROM performed_sets ORDER BY id"},
{"body_observations", "SELECT * FROM body_observations ORDER BY id"},
{"profile_state", "SELECT * FROM exercise_profile_state ORDER BY exercise_row_id"},
{"profile_revisions",
"SELECT * FROM exercise_profile_revisions ORDER BY exercise_row_id,revision_id"},
};
static const ProjectionSpec UNCHANGED_SCHEMA[] = {
{"schema",
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' "
"AND name NOT IN('programs','program_deletions','program_deletions_pending') "
"ORDER BY type,name"},
};
char path[] = "/tmp/trainlog-v26-populated-XXXXXX";
char diagnostic[512] = {0};
int file_descriptor = -1;
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
Projection business_before = {0};
Projection business_after = {0};
Projection schema_before = {0};
Projection schema_after = {0};
int version = 0;
int schema_object_count_before = 0;
bool ok = false;
file_descriptor = mkstemp(path);
CHECK(file_descriptor >= 0);
CHECK(close(file_descriptor) == 0);
file_descriptor = -1;
CHECK(build_v25_fixture(path));
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 25);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 12);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE name IN('program_deletions',"
"'program_deletions_pending')") == 0);
schema_object_count_before =
scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'");
CHECK(schema_object_count_before > 0);
CHECK(capture_projection(raw,
BUSINESS_PROJECTIONS,
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
&business_before));
CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_before));
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
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 == 26);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 26);
CHECK(capture_projection(raw,
BUSINESS_PROJECTIONS,
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
&business_after));
CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_after));
CHECK(business_before.length == business_after.length);
CHECK(memcmp(business_before.text, business_after.text, business_before.length) == 0);
CHECK(schema_before.length == schema_after.length);
CHECK(memcmp(schema_before.text, schema_after.text, schema_before.length) == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
1);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND "
"type='TEXT' AND \"notnull\"=0") == 1);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE (type='table' AND "
"name='program_deletions') OR (type='index' AND "
"name='program_deletions_pending')") == 2);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") ==
schema_object_count_before + 2);
ok = true;
cleanup:
if (!ok && diagnostic[0] != '\0') {
(void)fprintf(stderr, "migration diagnostic: %s\n", diagnostic);
}
free(schema_after.text);
free(schema_before.text);
free(business_after.text);
free(business_before.text);
if (raw != NULL) {
(void)sqlite3_close(raw);
}
if (database != NULL) {
trainlog_database_close(database);
}
if (file_descriptor >= 0) {
(void)close(file_descriptor);
}
if (unlink(path) != 0) {
ok = false;
}
return ok ? 0 : 1;
}
int main(void) {
return run_test();
}