Compare commits
No commits in common. "292bcc82463c240c662e37c8560c6da40afd2720" and "c17394ece2f5451df49d3f0d395fecc45e9e529f" have entirely different histories.
292bcc8246
...
c17394ece2
113 changed files with 616 additions and 4888 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -8,13 +8,12 @@ Trainlog consists of:
|
|||
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
|
||||
visualization, and manual synchronization, using Notcurses as its completed
|
||||
active rendering/input backend;
|
||||
- a loopback-only local Web adapter with implemented Dashboard, Analyse,
|
||||
Programmes, Sessions, and Exercises routes on the 0.1.7 development line;
|
||||
- a loopback-only local Web adapter whose Dashboard is implemented on the
|
||||
0.1.2 development branch; its Analyse, Programmes, Sessions, and Exercises
|
||||
routes remain placeholders pending separate contracts;
|
||||
- a small user-session PC agent, `trainlog-syncd`, for Android-triggered
|
||||
synchronization;
|
||||
- versioned JSON synchronization artifacts exchanged through configured
|
||||
Bluetooth Classic RFCOMM, direct MTP recovery, or a separate private Drive
|
||||
workflow.
|
||||
- versioned JSON synchronization artifacts exchanged over direct MTP.
|
||||
|
||||
The desktop SQLite database is the canonical long-term history.
|
||||
|
||||
|
|
@ -171,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 v34 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
|
||||
|
|
@ -185,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 v37 and Android migrations reach v34 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`
|
||||
|
|
@ -242,16 +241,14 @@ Desktop SQLite schema is versioned with:
|
|||
PRAGMA user_version;
|
||||
```
|
||||
|
||||
The current desktop schema is v37. 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; v25 adds Programs; and v26 additively adds
|
||||
logical Program deletion. Later additive migrations through v37 add Sleep,
|
||||
heart-rate, Cardio and Program planning state. None changes
|
||||
`TRAINLOG_FORMAT_V1`.
|
||||
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
|
||||
|
||||
Every incompatible schema evolution requires an explicit migration and
|
||||
regression coverage.
|
||||
|
|
|
|||
82
CHANGELOG.md
82
CHANGELOG.md
|
|
@ -2,88 +2,6 @@
|
|||
|
||||
## Unreleased — 0.1.7
|
||||
|
||||
- Present Sleep PDF nights and completed-sport days newest first while
|
||||
preserving chronological timed content within each night and sport day.
|
||||
Add a restrained
|
||||
printable header, period and generation date, and pale row separation.
|
||||
Remove unhelpful validation labels from agenda rows and PDF warnings; the
|
||||
editor now names the exact-revision synchronization publication action and
|
||||
retains its meaningful state. Sleep facts, schema, sport rules, Android and
|
||||
synchronization behavior are unchanged. The private daily Web launcher serves
|
||||
package `e454c86`; Firefox and a downloaded five-page report verified its
|
||||
newest-first order and factual medication and sport content.
|
||||
|
||||
- Recovered the Android-matched Web body figures and factual completed-sport
|
||||
Sleep agenda/PDF overlay after the Program calendar package was built from
|
||||
`3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development
|
||||
branch now includes the later Sleep PDF, paging and Program analysis fixes.
|
||||
Kept the compact month calendar, individual drag and drop and accessible
|
||||
date action, green fixed completed cards, same-day sessions, and revisioned
|
||||
restoration of legacy ceded optional sessions. No Android source, database
|
||||
format, or synchronization protocol changed in this correction. The private
|
||||
daily Web launcher now serves package `700dfc0`; Firefox and a downloaded
|
||||
PDF verified its factual session overlay.
|
||||
|
||||
- Reconciled the canonical documentation against the `f4ddcfd` development
|
||||
lineage: current source schemas v37/v34, implemented Web routes and Program
|
||||
rescheduling, observed package provenance, Sleep row paging, capability
|
||||
selected exchange, Web packaging requirements and validation limits. The
|
||||
[documentation review](docs/reviews/documentation_deep_review_v1.md) records
|
||||
the evidence matrix. This entry changes no runtime behavior or format.
|
||||
|
||||
- Show the Web Sleep agenda newest first with one 7, 14, 21, 28, or All
|
||||
recorded-night selector (7 by default). Sleep reads its full available history
|
||||
without the Analysis period control; other Analysis sections retain their
|
||||
period choice. The browser-local 5/10/15 preference migrates to 7/14/21,
|
||||
and a capped full-history read fails explicitly. Page changes leave the
|
||||
selected night, editor, statistics, and inclusive PDF range untouched.
|
||||
Removed only the redundant textual sport list below the Web agenda; factual
|
||||
blue overlays, the sport legend, and the complete PDF sport appendix remain.
|
||||
|
||||
- Corrected the Dashboard/Analysis active-Program next-session projection to
|
||||
select title, identity, and current date from one pending Program session.
|
||||
Recorded rescheduling dates replace imported dates, ceded slots are excluded,
|
||||
and an overdue uncompleted session remains eligible. The read-only projection
|
||||
does not change Program history, preparation priority, or synchronization.
|
||||
|
||||
- Aligned the Web front/back BODY ZONES figures with Android's normalized
|
||||
Compose Canvas silhouettes. The Web now uses Android's head, neck and 11
|
||||
region paths per face, while retaining the existing Catppuccin intensity
|
||||
classes, 30-day session counts, legend, labels and keyboard focus behavior.
|
||||
No Android, domain, database or synchronization behavior changed.
|
||||
|
||||
- Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt
|
||||
and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs,
|
||||
separate medication intakes, accents, doses, units, and quantities visible.
|
||||
Row heights and page count now follow content; exceptional entries continue
|
||||
on clearly dated pages. The former truncated observations duplicate is gone.
|
||||
The 18:00 timeline and factual blue sport overlay use one shared PDF geometry;
|
||||
Sleep, sport, database, and synchronization data are unchanged.
|
||||
The private PC/Web rollout from `3758875` was verified with a four-page
|
||||
browser-downloaded PDF; synchronization services retained their Program paths.
|
||||
|
||||
- Added a read-only completed-session overlay to the Web Sleep agenda and its
|
||||
local vector PDF preview/export. Factual session start and end times share
|
||||
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a
|
||||
printable paginated listing expose recorded names and times. Missing ends
|
||||
remain point markers. The report flags sport without a recorded Sleep row,
|
||||
and export waits for the complete paginated history read. Sleep entries,
|
||||
session history, database schema, Android, and synchronization contracts
|
||||
remain unchanged.
|
||||
The private PC/Web rollout from `e2c07f1` was verified against its served
|
||||
agenda and an actual downloaded PDF; the phone installation was untouched.
|
||||
|
||||
The isolated `trainlog-program-recalage-v1` candidate adds an explicit
|
||||
preview-and-confirm cascade for unfinished Program sessions. It retains source
|
||||
and actual dates, stores absolute current dates and ceded-slot state, and
|
||||
selects a separate Programs V2 Android projection by capability. The source
|
||||
candidate remains isolated from the stable checkout while its private desktop
|
||||
and compatible Android build are installed for the coordinated rollout.
|
||||
Rollout packaging rejects a native build without the embedded Web frontend.
|
||||
The private rollout preserves parsed V1 Program number types during Android V2
|
||||
upgrade and raises the bounded local MTP operation budget to 90 seconds and
|
||||
retained outgoing generation capacity to 16 after interrupted exchanges.
|
||||
|
||||
Development opened after the stable v0.1.6 release. This line is restricted to
|
||||
observed bug fixes, regression fixes, directly related small UI corrections,
|
||||
documentation corrections, robustness, and associated tests. It does not admit
|
||||
|
|
|
|||
|
|
@ -145,20 +145,13 @@ cd ..
|
|||
|
||||
meson setup build-web -Dweb=enabled
|
||||
meson compile -C build-web
|
||||
meson test -C build-web --print-errorlogs
|
||||
./build-web/tui/trainlog --web
|
||||
```
|
||||
|
||||
The Web application listens on `127.0.0.1:8080` by default. Use
|
||||
`--port <port>` for an explicit alternative. For an existing build directory,
|
||||
use `meson configure build -Dweb=enabled` instead of running `meson setup`
|
||||
again. The separate `npm test` run checks Web source; the enabled Meson build
|
||||
registers embedded frontend tests and places its assets in the executable.
|
||||
The default `web=auto` configuration does not by itself prove embedded assets.
|
||||
For a compatible service bundle, use the versioned candidate packaging
|
||||
procedure in [rollout readiness](docs/sync_rollout_readiness.md); copying only
|
||||
`trainlog` omits its matching synchronization helpers. Restarting a service
|
||||
does not change the package selected by its launcher.
|
||||
again.
|
||||
|
||||
### Android
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
|
|||
LOCAL_PERSISTENCE=IMPLEMENTED
|
||||
DIRECT_STORAGE=IMPLEMENTED
|
||||
MTP_SYNC=IMPLEMENTED
|
||||
ANDROID_SCHEMA=34
|
||||
ANDROID_SCHEMA=33
|
||||
```
|
||||
|
||||
## Visual contract
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..34
|
||||
manifest.getInt("schema") in 17..33
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..34) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -370,7 +370,6 @@ internal class SyncGenerationCoordinator(
|
|||
ACK_RECOVERY_CAPABILITY,
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
"programs-v2",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,8 +86,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
(ANDROID_KINDS.values +
|
||||
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
||||
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
|
||||
Kind("trainlog-programs", 1, "programs-v1.json", false) +
|
||||
Kind("trainlog-programs", 2, "programs-v2.json", false))
|
||||
Kind("trainlog-programs", 1, "programs-v1.json", false))
|
||||
.map { it.format to it.version }
|
||||
.toSet()
|
||||
|
||||
|
|
@ -774,8 +773,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
throw SyncGenerationException("artifact digest mismatch")
|
||||
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
|
||||
}
|
||||
if ("programs-v1" in artifacts && "programs-v2" in artifacts)
|
||||
throw SyncGenerationException("multiple Program revisions in one generation")
|
||||
return ValidatedGeneration(manifest, sha256(raw), artifacts)
|
||||
}
|
||||
|
||||
|
|
@ -950,10 +947,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
apply("programs-v1") {
|
||||
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
|
||||
}
|
||||
if ("programs-v2" in a)
|
||||
apply("programs-v2") {
|
||||
repository.applyProgramsV2Json(checkNotNull(a["programs-v2"]))
|
||||
}
|
||||
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
|
||||
apply("exercise-profile-state") {
|
||||
repository.applyExerciseProfileStateJson(
|
||||
|
|
|
|||
|
|
@ -11825,39 +11825,7 @@ class TrainlogRepository(
|
|||
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
|
||||
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
|
||||
*/
|
||||
fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1)
|
||||
|
||||
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
|
||||
|
||||
private fun legacyProgramDigest(program: JSONObject): String {
|
||||
// WHY: serializing and reparsing JSONObject changes integral doubles (for
|
||||
// example 52.0 to 52), which changes a V1 revision's causal digest.
|
||||
// CONTRACT: discard only V2 fields while retaining the parsed V1 values.
|
||||
val legacy = JSONObject()
|
||||
program.keys().forEach { key ->
|
||||
when (key) {
|
||||
"revision_sequence" -> Unit
|
||||
"sessions" -> {
|
||||
val rows = JSONArray()
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
for (index in 0 until sessions.length()) {
|
||||
val source = sessions.getJSONObject(index)
|
||||
val row = JSONObject()
|
||||
source.keys().forEach { sessionKey ->
|
||||
if (sessionKey !in setOf("position", "current_for", "planning_state"))
|
||||
row.put(sessionKey, source.get(sessionKey))
|
||||
}
|
||||
rows.put(row)
|
||||
}
|
||||
legacy.put(key, rows)
|
||||
}
|
||||
else -> legacy.put(key, program.get(key))
|
||||
}
|
||||
}
|
||||
return causalDigest(legacy)
|
||||
}
|
||||
|
||||
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
|
||||
fun applyProgramsV1Json(json: String): ProgramsImportResult {
|
||||
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
||||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
|
|
@ -11885,7 +11853,7 @@ class TrainlogRepository(
|
|||
try {
|
||||
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
|
||||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
|
||||
root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) ||
|
||||
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
|
||||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Unsupported Programs document.")
|
||||
val programs = root.getJSONArray("programs")
|
||||
|
|
@ -11912,16 +11880,11 @@ class TrainlogRepository(
|
|||
for (programIndex in 0 until programs.length()) {
|
||||
val program = programs.optJSONObject(programIndex)
|
||||
?: throw ProgramsImportInvalid("Invalid program.")
|
||||
val programKeysValid = if (documentVersion == 2) program.exact(
|
||||
"program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date",
|
||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||
"source_payload_sha256", "sessions",
|
||||
) else program.exact(
|
||||
if (!program.exact(
|
||||
"program_id", "revision_id", "title", "note", "state", "start_date",
|
||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||
"source_payload_sha256", "sessions",
|
||||
)
|
||||
if (!programKeysValid ||
|
||||
) ||
|
||||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
|
||||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
|
||||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
|
||||
|
|
@ -11931,33 +11894,17 @@ class TrainlogRepository(
|
|||
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
|
||||
program.opt("sessions") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Invalid program metadata.")
|
||||
if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE))
|
||||
throw ProgramsImportInvalid("Invalid Program revision sequence.")
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
|
||||
for (sessionIndex in 0 until sessions.length()) {
|
||||
val session = sessions.optJSONObject(sessionIndex)
|
||||
?: throw ProgramsImportInvalid("Invalid program session.")
|
||||
val validKeys = if (documentVersion == 2) session.exact(
|
||||
"program_session_id", "position", "title", "session_type", "planned_for",
|
||||
"current_for", "planning_state", "note", "occurrences",
|
||||
) else session.exact(
|
||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences",
|
||||
)
|
||||
if (!validKeys ||
|
||||
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
|
||||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
|
||||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
|
||||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
|
||||
session.opt("occurrences") !is JSONArray)
|
||||
throw ProgramsImportInvalid("Invalid program session metadata.")
|
||||
if (documentVersion == 2 &&
|
||||
(!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex ||
|
||||
!date(session.opt("current_for")) ||
|
||||
session.optString("planning_state") !in setOf("active", "ceded") ||
|
||||
(session.optString("planning_state") == "ceded" && !session.isNull("current_for")) ||
|
||||
(session.optString("planning_state") == "active" &&
|
||||
session.isNull("current_for") && !session.isNull("planned_for"))))
|
||||
throw ProgramsImportInvalid("Invalid Program planning state.")
|
||||
val occurrences = session.getJSONArray("occurrences")
|
||||
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
|
||||
val entryIds = mutableSetOf<String>()
|
||||
|
|
@ -11983,12 +11930,6 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
data class ExistingProjection(
|
||||
val revisionId: String,
|
||||
val digest: String,
|
||||
val version: Int,
|
||||
val sequence: Long,
|
||||
)
|
||||
val db = database.writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
|
|
@ -12021,76 +11962,28 @@ class TrainlogRepository(
|
|||
.use { it.moveToFirst() }
|
||||
if (tombstoned) { skipped++; continue }
|
||||
val digest = causalDigest(program)
|
||||
val existing = db.rawQuery(
|
||||
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " +
|
||||
"FROM synced_programs WHERE program_id=?",
|
||||
arrayOf(id),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else ExistingProjection(
|
||||
cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3),
|
||||
)
|
||||
}
|
||||
val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L
|
||||
// WHY: a later V1 artifact cannot erase current dates or a
|
||||
// ceded slot after this phone has accepted Programs V2.
|
||||
if (existing != null && documentVersion < existing.version)
|
||||
throw ProgramsImportInvalid("Programs V2 capability required.")
|
||||
if (existing != null && documentVersion == 2 &&
|
||||
(sequence < existing.sequence ||
|
||||
(sequence == existing.sequence && existing.version == 2 &&
|
||||
existing.revisionId != program.getString("revision_id"))))
|
||||
throw ProgramsImportInvalid("Stale Program revision sequence.")
|
||||
if (existing?.revisionId == program.getString("revision_id")) {
|
||||
if (existing.digest == digest) { skipped++; continue }
|
||||
if (documentVersion != 2 || existing.version != 1 ||
|
||||
existing.digest != legacyProgramDigest(program))
|
||||
throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||
}
|
||||
if (documentVersion == 2) {
|
||||
val activeProgramSession = db.rawQuery(
|
||||
"SELECT source_program_session_id FROM active_session_draft " +
|
||||
"WHERE id=1 AND source_program_id=?",
|
||||
arrayOf(id),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (activeProgramSession != null) {
|
||||
val newSessions = program.getJSONArray("sessions")
|
||||
for (sessionIndex in 0 until newSessions.length()) {
|
||||
val candidate = newSessions.getJSONObject(sessionIndex)
|
||||
if (candidate.getString("program_session_id") == activeProgramSession) {
|
||||
val existingPlan = db.rawQuery(
|
||||
"SELECT current_for,planning_state FROM synced_program_sessions " +
|
||||
"WHERE program_session_id=?",
|
||||
arrayOf(activeProgramSession),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else
|
||||
(if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1)
|
||||
}
|
||||
if (existingPlan != null &&
|
||||
(existingPlan.first != candidate.optStringOrNull("current_for") ||
|
||||
existingPlan.second != candidate.getString("planning_state")))
|
||||
throw ProgramsImportInvalid("Active Program draft blocks reschedule.")
|
||||
}
|
||||
}
|
||||
}
|
||||
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
|
||||
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
|
||||
if (existing?.first == program.getString("revision_id")) {
|
||||
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
db.delete("synced_programs", "program_id=?", arrayOf(id))
|
||||
db.execSQL(
|
||||
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
|
||||
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
|
||||
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
|
||||
program.getInt("source_version"), program.getString("source_payload_sha256"), digest,
|
||||
documentVersion, sequence),
|
||||
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
|
||||
)
|
||||
val sessions = program.getJSONArray("sessions")
|
||||
for (sessionIndex in 0 until sessions.length()) {
|
||||
val session = sessions.getJSONObject(sessionIndex)
|
||||
val sessionId = session.getString("program_session_id")
|
||||
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
|
||||
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
|
||||
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
|
||||
session.optStringOrNull("planned_for"), session.optStringOrNull("note"),
|
||||
if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"),
|
||||
if (documentVersion == 2) session.getString("planning_state") else "active"))
|
||||
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
|
||||
val entries = session.getJSONArray("occurrences")
|
||||
for (entryIndex in 0 until entries.length()) {
|
||||
val entry = entries.getJSONObject(entryIndex)
|
||||
|
|
@ -12159,7 +12052,7 @@ class TrainlogRepository(
|
|||
} ?: return null
|
||||
val sessions = mutableListOf<SyncedProgramSession>()
|
||||
db.rawQuery(
|
||||
"SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " +
|
||||
"SELECT program_session_id,title,session_type,planned_for,note " +
|
||||
"FROM synced_program_sessions WHERE program_id=? " +
|
||||
"ORDER BY position,program_session_id",
|
||||
arrayOf(programId),
|
||||
|
|
@ -12202,8 +12095,6 @@ class TrainlogRepository(
|
|||
sessionCursor.getString(1),
|
||||
sessionCursor.getString(2),
|
||||
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
||||
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
|
||||
sessionCursor.getString(6),
|
||||
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
||||
execution.first,
|
||||
execution.second,
|
||||
|
|
@ -12269,8 +12160,7 @@ class TrainlogRepository(
|
|||
val available = db.rawQuery(
|
||||
"SELECT s.session_type FROM synced_program_sessions s " +
|
||||
"JOIN synced_programs p ON p.program_id=s.program_id " +
|
||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " +
|
||||
"AND s.planning_state='active'",
|
||||
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'",
|
||||
arrayOf(programId, programSessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
?: return StartProgramSessionResult.NotAvailable
|
||||
|
|
@ -12564,7 +12454,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
34,
|
||||
33,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -12918,34 +12808,6 @@ private class TrainlogDatabaseHelper(
|
|||
createSleepImmutabilityTriggers(db)
|
||||
version = 33
|
||||
}
|
||||
if (version < 34 && newVersion >= 34) {
|
||||
/* WHY: V1 retains the source date; V2 needs an independent current
|
||||
* target and a durable ceded-slot state. INVARIANT: no local draft
|
||||
* or completed workout is rewritten by the projection migration. */
|
||||
if (!tableHasColumn(db, "synced_programs", "projection_version")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " +
|
||||
"DEFAULT 1 CHECK(projection_version IN(1,2))",
|
||||
)
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_programs", "revision_sequence")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " +
|
||||
"DEFAULT 0 CHECK(revision_sequence>=0)",
|
||||
)
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_program_sessions", "current_for")) {
|
||||
db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT")
|
||||
db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for")
|
||||
}
|
||||
if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " +
|
||||
"DEFAULT 'active' CHECK(planning_state IN('active','ceded'))",
|
||||
)
|
||||
}
|
||||
version = 34
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
|
|
@ -12962,9 +12824,7 @@ private class TrainlogDatabaseHelper(
|
|||
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
|
||||
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
|
||||
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL,
|
||||
projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)),
|
||||
revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0)
|
||||
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
|
|
@ -12973,9 +12833,7 @@ private class TrainlogDatabaseHelper(
|
|||
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
|
||||
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
|
||||
planned_for TEXT, note TEXT, current_for TEXT,
|
||||
planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')),
|
||||
UNIQUE(program_id,position)
|
||||
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ data class SyncedProgramSession(
|
|||
val title: String,
|
||||
val sessionType: String,
|
||||
val plannedFor: String?,
|
||||
val currentFor: String?,
|
||||
val planningState: String,
|
||||
val note: String?,
|
||||
val executionState: ProgramSessionExecutionState,
|
||||
val executionSessionId: String?,
|
||||
|
|
|
|||
|
|
@ -118,12 +118,7 @@ fun ProgramDetailScreen(
|
|||
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||
program.note?.let { TrainlogInfo(it) }
|
||||
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
|
||||
val ceded = program.sessions.count { it.planningState == "ceded" }
|
||||
TrainlogInfo(
|
||||
"$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" +
|
||||
if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "",
|
||||
if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted,
|
||||
)
|
||||
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
|
||||
}
|
||||
message?.let { TrainlogInfo(it) }
|
||||
program.sessions.forEachIndexed { index, session ->
|
||||
|
|
@ -150,16 +145,12 @@ fun ProgramDetailScreen(
|
|||
color = LocalTrainlogColors.current.text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
ProgramStateBadge(
|
||||
if (session.planningState == "ceded" && !completed) "Créneau cédé"
|
||||
else strings.getString(stateLabel),
|
||||
completed,
|
||||
)
|
||||
ProgramStateBadge(strings.getString(stateLabel), completed)
|
||||
}
|
||||
TrainlogInfo(
|
||||
listOfNotNull(
|
||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
||||
session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
|
||||
).joinToString(" · "),
|
||||
)
|
||||
|
|
@ -173,7 +164,7 @@ fun ProgramDetailScreen(
|
|||
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
||||
)
|
||||
when (session.executionState) {
|
||||
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton(
|
||||
ProgramSessionExecutionState.TODO -> TrainlogButton(
|
||||
strings.getString(R.string.program_session_start),
|
||||
{ start(session.programSessionId) },
|
||||
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(34, it.version) }
|
||||
).use { assertEquals(33, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(34, it.version) }
|
||||
).use { assertEquals(33, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(34, db.version)
|
||||
assertEquals(33, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(34, db.version)
|
||||
assertEquals(33, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_type,session_kind FROM sessions",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(34, db.version)
|
||||
assertEquals(33, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM exercises WHERE " +
|
||||
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
|
|||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(34, db.version)
|
||||
assertEquals(33, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
|
|||
migrated.listSleepDiary()
|
||||
migrated.close()
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
1,
|
||||
scalar(
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(34, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -70,91 +70,6 @@ class ProgramsProjectionRepositoryTest {
|
|||
.put("deletions", deletions)
|
||||
.toString()
|
||||
|
||||
private fun artifactV2(
|
||||
program: JSONObject,
|
||||
sequence: Int = 0,
|
||||
currentFor: String = "2026-10-01",
|
||||
): String {
|
||||
program.put("revision_sequence", sequence)
|
||||
val session = program.getJSONArray("sessions").getJSONObject(0)
|
||||
session.put("position", 0)
|
||||
session.put("current_for", currentFor)
|
||||
session.put("planning_state", "active")
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-programs")
|
||||
.put("version", 2)
|
||||
.put("generated_at", "2026-09-18T12:00:00Z")
|
||||
.put("programs", JSONArray().put(program))
|
||||
.put("deletions", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() {
|
||||
val name = "program-v2-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
||||
)
|
||||
val upgraded = program()
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(upgraded)),
|
||||
)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
val stale = artifactV2(program())
|
||||
val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")),
|
||||
)
|
||||
assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid)
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
|
||||
is ProgramsImportResult.Invalid)
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2UpgradeKeepsV1DigestForIntegralDecimalWeights() {
|
||||
val name = "program-decimal-upgrade-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
// The desktop JSON exporter writes SQLite REAL values as 40.0.
|
||||
// JSONObject's own serialization writes the same value as 40.
|
||||
val v1 = artifact(JSONArray().put(program()))
|
||||
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
|
||||
val v2 = artifactV2(program())
|
||||
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
|
||||
assertTrue(v1.contains("\"target_weight_kg\":40.0"))
|
||||
assertTrue(v2.contains("\"target_weight_kg\":40.0"))
|
||||
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(v1))
|
||||
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV2Json(v2))
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(program().getString("program_id"))!!
|
||||
.sessions.single().currentFor)
|
||||
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV2Json(v2))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preparationArtifact(
|
||||
includeProgramProvenance: Boolean = true,
|
||||
deliveryState: String = "pending",
|
||||
|
|
@ -296,12 +211,6 @@ class ProgramsProjectionRepositoryTest {
|
|||
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(programId, active.sourceProgramId)
|
||||
assertEquals(programSessionId, active.sourceProgramSessionId)
|
||||
val changedPlan = program().apply {
|
||||
put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
|
||||
}
|
||||
assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan))
|
||||
is ProgramsImportResult.Invalid)
|
||||
assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor)
|
||||
val performed = active.copy(
|
||||
exercises = active.exercises.map { occurrence ->
|
||||
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
||||
|
|
@ -309,12 +218,6 @@ class ProgramsProjectionRepositoryTest {
|
|||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV2Json(artifactV2(changedPlan)),
|
||||
)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(programId)!!
|
||||
.sessions.single().currentFor)
|
||||
assertEquals(
|
||||
ProgramSessionExecutionState.COMPLETED,
|
||||
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
||||
|
|
@ -335,8 +238,6 @@ class ProgramsProjectionRepositoryTest {
|
|||
try {
|
||||
val detail = repository.listSyncedPrograms().single()
|
||||
assertEquals(1, detail.sessionCount)
|
||||
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
|
||||
.sessions.single().currentFor)
|
||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||
assertEquals(1, repository.listSessions().size)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("34", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ class SleepDiaryRepositoryTest {
|
|||
SQLiteDatabase.OPEN_READWRITE,
|
||||
)
|
||||
try {
|
||||
assertEquals(34, raw.version)
|
||||
assertEquals(33, raw.version)
|
||||
assertThrows(SQLiteException::class.java) {
|
||||
raw.execSQL(
|
||||
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(34, it.getInt(0))
|
||||
assertEquals(33, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(34, cursor.getInt(0))
|
||||
assertEquals(33, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(34, cursor.getInt(0))
|
||||
assertEquals(33, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ evidence and chronology.
|
|||
existing-artifact synchronization boundary.
|
||||
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
|
||||
history and Android-delivery workflow.
|
||||
- [Sleep Diary guide](sleep_diary.md) explains capture, agenda row paging,
|
||||
factual sport and heart-rate displays, and independent PDF range selection.
|
||||
- [Program format V1](program_format_v1.md) owns the strict desktop planning
|
||||
import contract and program lifecycle used by the Sessions Programs subtab.
|
||||
|
||||
|
|
@ -82,9 +80,6 @@ evidence and chronology.
|
|||
|
||||
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
|
||||
and validation evidence. Their status and paths are historical.
|
||||
- [Documentation deep review V1](reviews/documentation_deep_review_v1.md)
|
||||
records the audited 0.1.7 source baseline, corrected claims, evidence limits,
|
||||
and coverage matrix.
|
||||
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
|
||||
records the implemented, not-deployed validation checkpoint and real-browser
|
||||
screenshot evidence.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,5 @@
|
|||
# Android application
|
||||
|
||||
## Schema v34: read-only Programs V2 projection
|
||||
|
||||
The audited Program rescheduling development line additively stores a projection
|
||||
version and revision sequence on synchronized Programs, plus `current_for` and
|
||||
`planning_state` on synchronized Program sessions. The original `planned_for`
|
||||
and all local completed sessions and drafts remain separate. Existing V1 rows
|
||||
hydrate as active with their original date. Android still has no Program editor.
|
||||
|
||||
Programs V2 replaces one Program snapshot transactionally. A lower revision
|
||||
sequence or a later Programs V1 snapshot cannot overwrite accepted V2 planning.
|
||||
If the singleton active draft refers to a session whose current date or ceded
|
||||
state changes, V2 import rejects the generation; it does not alter the draft.
|
||||
The Program screen uses current dates for unfinished sessions and does not offer
|
||||
Start on a ceded slot. The private Program rollout records a compatible v34
|
||||
Android installation; this source snapshot alone does not prove another device.
|
||||
|
||||
## Sleep Diary V1 and active V2 companion
|
||||
|
||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@
|
|||
## Synchronization transport boundary
|
||||
|
||||
Generation capture, validation, causal import, publication and ACK acceptance
|
||||
are transport-neutral. `sync_peer_worker.py` owns that conversation. The paired
|
||||
private automatic path selects authenticated Bluetooth first and direct MTP as
|
||||
wired recovery. The separately configured legacy USB/Drive workflow probes the
|
||||
expected MTP peer before selecting USB. A successful USB run is committed
|
||||
independently, then mirrored to Drive best-effort; Drive failure cannot reopen
|
||||
its SQLite transactions. If MTP is unavailable in that workflow, the same
|
||||
worker runs over the Drive byte adapter. No transport carries a SQLite file.
|
||||
are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
|
||||
and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
|
||||
probes the expected MTP peer before preferring USB. A successful USB run is
|
||||
committed independently, then mirrored to Drive best-effort; Drive failure can
|
||||
never reopen its SQLite transactions. If MTP is unavailable, the same worker
|
||||
runs over the Drive byte adapter.
|
||||
|
||||
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
|
||||
SyncScreen, the visible USB foreground service, and the private-folder Drive
|
||||
|
|
@ -43,7 +42,7 @@ Android field client
|
|||
Android shared storage
|
||||
Documents/Trainlog
|
||||
|
|
||||
| Bluetooth RFCOMM primary; direct MTP recovery
|
||||
| direct MTP
|
||||
v
|
||||
shared desktop sync engine
|
||||
/ \
|
||||
|
|
@ -359,16 +358,11 @@ SVG was selected over Canvas because the bounded series has very few elements
|
|||
and vector sharpness/responsive scaling matter more than high-volume drawing.
|
||||
All colours are read from the live Catppuccin CSS variables.
|
||||
|
||||
The muscle figure is a code-native SVG whose geometry is ported from the
|
||||
normalized Compose Canvas in Android `HomeScreen.kt`: `drawOval()` for the
|
||||
head, `neckPath()` for the neck, and `bodyRegions()` for 11 paths on each
|
||||
face. The SVG viewBox uses Android fractions multiplied by 100 and scales to
|
||||
a portrait ratio based on Android's 236dp tall two-panel Home layout.
|
||||
Canonical localized mappings are: `chest` to
|
||||
bilateral front chest; `back` to the contiguous rear trunk; `shoulders` to
|
||||
bilateral front/rear shoulders; `arms` to bilateral front/rear arms; `core` to
|
||||
the front trunk; `glutes` to bilateral rear glutes; `thighs` and `calves` to
|
||||
bilateral front/rear legs. Aggregate zones
|
||||
The muscle figure is an original code-native SVG. Canonical localized mappings
|
||||
are: `chest` to bilateral front chest; `back` to rear upper back and bilateral
|
||||
lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral
|
||||
front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes;
|
||||
`thighs` and `calves` to bilateral front/rear legs. Aggregate zones
|
||||
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
|
||||
remain in the complete text list. Visual levels are a deterministic ratio to
|
||||
the maximum `session_count` in the current snapshot: zero, up to one third, up
|
||||
|
|
@ -525,14 +519,12 @@ silently rendered as unclassified.
|
|||
|
||||
### Desktop
|
||||
|
||||
Desktop SQLite schema v37 is canonical long-term history in the audited
|
||||
development source. The additive v37 migration stores absolute Program current
|
||||
dates, ceded slots and revision ordering. The earlier v25 -> v26 migration
|
||||
added terminal Program logical deletion and its durable replay, generation and
|
||||
acknowledgement ledger without deleting imported definitions, derived
|
||||
preparations or workout facts. Android schema v34 extends the read-only
|
||||
Programs projection with capability-selected V2 planning state; v24 introduced
|
||||
its original V1 projection. Neither Programs companion replaces mobile V3 or changes
|
||||
Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
|
||||
v26 migration adds terminal Program logical deletion and its durable replay,
|
||||
generation, and acknowledgement ledger without deleting imported definitions,
|
||||
derived preparations, or workout facts. Android schema v24 maintains the
|
||||
separate read-only synchronized Programs projection; the optional staged
|
||||
`trainlog-programs` V1 companion does not replace mobile V3 or change
|
||||
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
|
||||
manual-preparation withdrawal identity without deleting revisions, deliveries
|
||||
or workout facts. Its additive v18 ->
|
||||
|
|
@ -592,7 +584,7 @@ exercise_body_zone_sync
|
|||
|
||||
### Android
|
||||
|
||||
Android has an independent local SQLite schema, v34 in the audited source. Completed and
|
||||
Android has an independent local SQLite schema, currently v14. Completed and
|
||||
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
||||
resuming a completed Test max records its stable source session in the one
|
||||
durable draft.
|
||||
|
|
|
|||
|
|
@ -1,47 +1,5 @@
|
|||
# Current implementation state
|
||||
|
||||
## Audited development line and private installation
|
||||
|
||||
This 0.1.7 correction joins the audited `f4ddcfd` development lineage,
|
||||
including the Program projection fix `8995843`, Android-matched Web body
|
||||
figures `c2935b8`, and the Sleep PDF, sport and paging changes, to the compact
|
||||
Program month calendar. The uncommitted Dashboard layout work in the separate
|
||||
`trainlog-dashboard-polish-v1` worktree remains outside this snapshot.
|
||||
|
||||
The private daily Program rollout uses desktop schema v37, Android schema v34,
|
||||
and the capability-selected `trainlog-programs` V2 projection. The later
|
||||
`program-calendar-dnd-drop-failure-v1-final` PC/Web package declared source
|
||||
commit `3a4ec34` and carried the uncommitted calendar UI, but omitted the
|
||||
subsequent body-figure and Sleep sport commits. The corrected package from
|
||||
`700dfc0` is installed as the daily Web launcher; the live service executable,
|
||||
health endpoint and Firefox views were checked on 2026-10-07. The corrected
|
||||
Web uses Android Compose Canvas head, neck, and 22 body
|
||||
paths on Dashboard muscle distribution, Analysis overview, and Analysis body
|
||||
distribution. Firefox captures of all three served views show complete figures
|
||||
at the intended aspect ratio, with the existing color scale, legend, and focus
|
||||
interaction. The candidate geometry was compared with reconstructed Android
|
||||
source paths; no Android runtime screenshot was available because no device or
|
||||
emulator was connected. No Android, schema, domain, or synchronization change
|
||||
accompanied this Web rollout.
|
||||
|
||||
Firefox downloaded a four-page PDF from the served Web. The earlier PDF layout
|
||||
validation rendered and inspected every page: its 65 individual medication
|
||||
lines match its summary, its remarks remain visible, and no extracted word
|
||||
exceeds the page margins. After the body figure rollout, a fresh PDF export
|
||||
again contained the treatments and remarks sections; actual sport markers
|
||||
remained blue in Web, and a recorded start time matched the PDF. The earlier
|
||||
PDF inspection also confirmed black-outlined blue sport bands. A separate
|
||||
synthetic set of 7, 14, 21, and 30 nights in French and English, plus long
|
||||
continuations, was rendered page by page and checked in grayscale. Rollout
|
||||
packaging requires a Meson `-Dweb=enabled` build with embedded frontend tests.
|
||||
The corrected build was checked in Firefox first with a snapshot and then
|
||||
against the live daily Web service without mutating user data. The latest seven
|
||||
Sleep rows showed six blue sport marks,
|
||||
including the completed 2026-10-07 session at 06:59–08:02. The downloaded
|
||||
five-page PDF listed that same session and its 1 h 03 min duration on its
|
||||
sport appendix. The October Program calendar showed 35 day cells, seven
|
||||
completed fixed cards and twelve movable cards without a data mutation.
|
||||
|
||||
Full-generation synchronization uses one generation/ACK business engine across
|
||||
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
||||
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
||||
|
|
@ -88,7 +46,7 @@ callbacks complete before the mutable generation reference is published; the
|
|||
reference and Android ACK use the same confirmation. Bounded diagnostic logs
|
||||
contain only run/generation identities, artifact names, phases and results.
|
||||
The desktop worker performs lightweight local correlation checks continuously
|
||||
but opens a full MTP pull no more than once every three seconds. A 90-second
|
||||
but opens a full MTP pull no more than once every three seconds. A 30-second
|
||||
individual adapter budget is distinct from the overall conversation deadline,
|
||||
so an absent correlated generation reports a protocol wait timeout while an
|
||||
actually blocked adapter reports `transport_timeout`.
|
||||
|
|
@ -183,8 +141,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | source schema v37; v37 adds Program current dates, ceded slots and revision ordering; v36 makes Sleep revision payload append-only; v35 adds intake quantity |
|
||||
| Android SQLite | source schema v34; v34 adds the read-only Programs V2 projection; v33 makes Sleep revision payload append-only; v32 adds intake quantity and quick publication |
|
||||
| Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
|
||||
| Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
|
||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||
| Desktop terminal backend | Notcurses only |
|
||||
| Trainlog product version | `0.1.7` bugfix-only development, synchronized across Android and desktop; latest stable release: `v0.1.6` |
|
||||
|
|
@ -248,53 +206,13 @@ parallel implementation of its rules.
|
|||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||
Trainlog JSON V1 contract.
|
||||
|
||||
The active Sleep V2 view reads the complete available Sleep history independently
|
||||
of the Analysis period used by other sections. Every returned night remains
|
||||
available on the shared 18:00-to-18:00 geometry. Sleep has one agenda display
|
||||
selector: the newest nights first, seven recorded nights per page by default,
|
||||
with 7, 14, 21, 28, and All choices. These are row counts, not calendar windows.
|
||||
The browser-local choice survives reloads; old 5/10/15 choices migrate to
|
||||
7/14/21. At the server's 3660-night read cap, the Web reports that completeness
|
||||
cannot be established instead of presenting a truncated agenda as All. Paging
|
||||
changes only the visible rows; PDF selection, statistics, and the current editor
|
||||
remain based on the complete loaded history. Selecting a row is
|
||||
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
|
||||
night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
|
||||
presentation-only and atomically drives the detail dates, editor content, and
|
||||
full-width heart-rate detail; it performs no database mutation. PDF preview
|
||||
and download instead share an explicit inclusive range over night start dates,
|
||||
initialized to the loaded history bounds and independent of detail selection.
|
||||
The vector PDF now presents selected nights newest first, while retaining
|
||||
chronological timed content within each night; its sport appendix shows newest
|
||||
days first and chronological sessions within each day. A light header, period,
|
||||
generation date and pale alternating rows
|
||||
improve print hierarchy in color and grayscale. Agenda rows and PDF omit the
|
||||
publication/validation labels. The editor still exposes the exact-revision
|
||||
publication action and state required for synchronization; PDF selection and
|
||||
local editing do not depend on that state.
|
||||
The private daily Web launcher now serves the 0.1.7 PDF polish package from
|
||||
`e454c86`. Read-only Firefox inspection of the served application on
|
||||
2026-10-07 downloaded a five-page report from existing data: its first night
|
||||
was 2026-10-06, all 17 nights and 98 medication intakes remained present, and
|
||||
the completed 2026-10-07 sport session retained its factual times and duration.
|
||||
Color and grayscale renders remained legible. No personal Sleep data were
|
||||
edited for this validation.
|
||||
The completed-sport overlay reads factual `sessions.started_at` and `ended_at`
|
||||
from desktop history through a read-only, paginated Web endpoint.
|
||||
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for
|
||||
Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals
|
||||
intersect `[start,end)` and are clipped only for display; missing or invalid
|
||||
ends are point markers without invented duration. The Web keeps a separate
|
||||
blue lane with accessible labels and tooltips; the redundant textual Web sport
|
||||
list is removed. Preview and download wait for the
|
||||
complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text,
|
||||
width-aware wrapping, content-driven row heights, and dated continuation pages
|
||||
for exceptional notes. Each selected intake and remark is printed once without
|
||||
a fixed character limit. The shorter 430 pt timeline shares one coordinate
|
||||
mapping across hour ticks, grid, sleep markers, medication markers, and blue
|
||||
sport bands. The PDF prints blue
|
||||
outlined bands and paginated names, factual times, and durations, marking
|
||||
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
|
||||
creates no Sleep event and changes no sleep total, session, schema, or sync
|
||||
contract. The HR detail's rise/fall overlays are descriptive relative measured
|
||||
initialized to the loaded agenda bounds and independent of detail selection. Its
|
||||
rise/fall overlays are descriptive relative measured
|
||||
variation only (five-minute trailing reference excluding the current 60-second
|
||||
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
||||
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
||||
|
|
@ -502,7 +420,7 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no
|
|||
automatic browser launch. Sessions provides complete paged preparation,
|
||||
resume and history views, stable details, optimistic manual-preparation writes,
|
||||
explicit proposal derivation and generation-backed Android delivery. The
|
||||
top-level `/programmes` route is a compact monthly active-Program calendar;
|
||||
top-level `/programmes` route is an operational daily active-Program calendar;
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. `/exercices` is the operational desktop
|
||||
catalogue administration surface. `/analyse` is implemented with Overview,
|
||||
|
|
@ -531,7 +449,7 @@ catalogue state. Built-in identities cannot be retired, equipment associations
|
|||
are presented read-only, and profile edits affect future use only: occurrence
|
||||
snapshots, performed sets, feedback and MAX history are never rewritten. A
|
||||
successful mutation changes only the canonical desktop database; the existing
|
||||
configured generation synchronization later carries the existing
|
||||
USB-priority, Drive mirror/fallback synchronization later carries the existing
|
||||
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
|
||||
|
||||
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
||||
|
|
@ -539,21 +457,18 @@ The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
|
|||
or execution state. With no active Program it shows an empty state and
|
||||
navigation to Sessions administration; with one it selects it automatically;
|
||||
with several it offers a non-persisted selector. Its continuous Monday–Sunday
|
||||
weeks derive from Program bounds, each unfinished session's current date
|
||||
(`current_for` when present, otherwise imported `planned_for`), and the actual
|
||||
weeks derive from Program bounds, imported `planned_for` dates, and the actual
|
||||
`sessions.started_at` instant for linked completed executions, presented on the
|
||||
same browser-local civil day as Web session history. The Program detail Web API
|
||||
adds the nullable read-only `execution_started_at` projection field; the
|
||||
Programs V2 projection carries the current dates and ceded-slot state to
|
||||
capable Android peers without changing the strict Programs V1 artifact. A completed
|
||||
Programs synchronization artifact and its strict fields are unchanged. A completed
|
||||
session moved from its imported date shows both dates without changing history
|
||||
or Program identity. Several cards may share a day; rest days contain no
|
||||
invented session. A completed execution with no valid history timestamp stays
|
||||
undated rather than appearing to have occurred on its planned day. Unfinished
|
||||
sessions show their current dates; ceded unfinished slots remain visible but
|
||||
are excluded from next-session selection and preparation, and are not counted
|
||||
as performed. It shows all five Core execution states; only eligible `todo`
|
||||
offers Prepare. Each Prepare result
|
||||
sessions still show their imported dates because no durable current-plan or
|
||||
ceded-optional-slot state exists in the stabilized Programs exchange. It shows
|
||||
all five Core execution states; only `todo` offers Prepare. Each Prepare result
|
||||
is followed by an authoritative Core reread and may expose the returned
|
||||
preparation link. This calendar adds no Android delivery, start, or execution
|
||||
behavior.
|
||||
|
|
@ -792,12 +707,9 @@ partial, invalid and transport-error states never synthesize values.
|
|||
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache
|
||||
ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real
|
||||
point remains visible, `smooth=false`, legacy zeroes are preserved, and only
|
||||
Core-owned `improved` flags alter markers. A React SVG ports the exact
|
||||
normalized head, neck, and 11 front/11 back BODY ZONES paths from Android
|
||||
`HomeScreen.kt` `bodyRegions()` and `neckPath()`. Its discrete Catppuccin colour
|
||||
level still depends solely on `session_count`; the factual text list remains
|
||||
authoritative and accessible. The figure geometry changes no zone mapping or
|
||||
30-day calculation.
|
||||
Core-owned `improved` flags alter markers. An original React SVG supplies
|
||||
front/back BODY ZONES regions whose discrete colour level depends solely on
|
||||
`session_count`; the factual text list remains authoritative and accessible.
|
||||
|
||||
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
|
||||
formatting, layout-invariance and source-derived text-boundary tests, plus
|
||||
|
|
|
|||
|
|
@ -1,21 +1,5 @@
|
|||
# Desktop database
|
||||
|
||||
## Schema v37: Program planning
|
||||
|
||||
Schema v37 is additive to the Program rescheduling development line. It
|
||||
creates `program_session_planning` for absolute current dates and ceded-slot
|
||||
state, `program_reschedule_operations` for exact request replay, and
|
||||
`program_revision_sequences` for monotonically ordered Program snapshots.
|
||||
A trigger increments the per-Program sequence whenever its revision changes,
|
||||
including archive, reschedule and logical deletion. No date uniqueness or
|
||||
historical session mutation is introduced. Missing planning rows mean the
|
||||
original `planned_for` date and active planning state. The source format and
|
||||
published Programs V1 artifact remain strict and unchanged.
|
||||
|
||||
This schema is present in the audited development commit. Its presence in
|
||||
source does not establish the schema of another installation; a deployment
|
||||
requires a compatible package, backup and migration verification.
|
||||
|
||||
## Causal heart-rate tail correction (schema v36, no migration)
|
||||
|
||||
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
||||
|
|
@ -256,8 +240,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=37
|
||||
DATABASE_SCHEMA_V37=IMPLEMENTED
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=28
|
||||
DATABASE_SCHEMA_V28=IMPLEMENTED
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -276,7 +260,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
37
|
||||
27
|
||||
```
|
||||
|
||||
The independent actual-set loads documented in the current desktop, Android
|
||||
|
|
@ -735,7 +719,7 @@ distance km
|
|||
|
||||
The Android SQLite database is independent.
|
||||
|
||||
Current Android-local version: **34**. The historical migration chain adds the
|
||||
Current Android-local version: **17**. The explicit migration chain adds the
|
||||
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
|
||||
identity/multi-occurrence support in v7, and the widened custom-equipment
|
||||
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
|
||||
|
|
|
|||
|
|
@ -68,43 +68,3 @@ source definition and derived preparations. A deleted program cannot be
|
|||
resurrected or prepared; an archived program cannot be prepared. The optional
|
||||
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
|
||||
projection and does not alter this import format.
|
||||
|
||||
## Program calendar planning in the audited development line
|
||||
|
||||
The `trainlog-program-recalage-v1` line adds a separate planning
|
||||
state. `planned_for` remains the imported date. `current_for` is an absolute
|
||||
current target for an active, unfinished session; `ceded` releases one selected
|
||||
future slot without completing or deleting its session identity. Completed
|
||||
cards still use the linked session's actual start timestamp. Positions from the
|
||||
imported Program determine the cascade order; titles never determine priority.
|
||||
|
||||
The compact Web month calendar moves one eligible unfinished session to a
|
||||
chosen civil date by drag and drop or an accessible date action. Other sessions
|
||||
retain their current dates, and several cards may occupy one day. Completed
|
||||
cards use factual execution dates and cannot move. FACULTATIVE is descriptive
|
||||
metadata and never authorizes automatic deletion. The calendar no longer offers
|
||||
the cascade or ceded-slot selection. Existing ceded rows remain readable and
|
||||
can be restored to their imported dates with the revisioned, idempotent
|
||||
planning command. The Programs V2 companion projects the resulting current
|
||||
dates through normal synchronization.
|
||||
|
||||
The retained ordered-rescheduling API selects a first and last unfinished
|
||||
session, a resumption date, and the exact session identities whose slots are
|
||||
ceded. Its available days are
|
||||
the dated original Program slots in that interval on or after resumption.
|
||||
The preview lists original, prior current and proposed dates. It rejects a
|
||||
missing slot, a date beyond the Program end, a completed/in-progress/prepared
|
||||
session, or a stale revision. It does not invent rest-day or post-end slots.
|
||||
An explicitly ceded session keeps its identity and remains visible as ceded.
|
||||
Unchanged sessions in the selected range are displayed in the preview but need
|
||||
no planning row. Other sessions and Programs are not moved. Multiple actual or
|
||||
independent sessions may still share one civil day.
|
||||
|
||||
Confirmation binds the preview fingerprint to the current Program projection
|
||||
and writes absolute dates, ceded states, the Program revision and a durable
|
||||
operation response in one transaction. Exact operation replay returns that
|
||||
response; changed content under the same operation ID conflicts. A failed
|
||||
write rolls back every row. The development implementation requires a recent acknowledged
|
||||
Programs V2 generation for every previously contacted Android peer. A newly
|
||||
started offline Android Program draft remains protected by Android's V2 import
|
||||
rejection; the sync run then reports a conflict for explicit resolution.
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
# Documentation deep review V1 — 2026-10-02
|
||||
|
||||
## Scope and evidence hierarchy
|
||||
|
||||
This review covers the linear development source at
|
||||
`f4ddcfdb05e9627397f7225c05f40212e9de1ea6`
|
||||
(`trainlog-sleep-single-selector-weeks-v1`). Its ancestors include `3a4ec34`
|
||||
(Program rollout), `8995843` (current Program projection), `c2935b8` (Web body
|
||||
figures), and the Sleep sport/PDF/paging changes. It does not integrate or
|
||||
describe as delivered the uncommitted Dashboard layout work in the separate
|
||||
`trainlog-dashboard-polish-v1` worktree. Stable v0.1.6 and the 0.1.7
|
||||
development line are different publication states.
|
||||
|
||||
Normative frozen contracts take priority over source implementation; source and
|
||||
tests establish capabilities of this commit; dated rollout records establish
|
||||
only the installation they observed; design/review files retain history. The
|
||||
observed PC/Web launcher path names the `f4ddcfd` package. That observation
|
||||
alone is not a byte comparison of a running process, an Android APK inspection,
|
||||
or proof of every synchronization service's package. The earlier verified
|
||||
Program rollout records desktop/Android schemas v37/v34. No private health
|
||||
data, PDF, database or device identifier was read for this review.
|
||||
|
||||
## Coverage inventory
|
||||
|
||||
The 105 tracked baseline Markdown files include root `README.md`, `AGENTS.md`,
|
||||
`CHANGELOG.md`, `android/README.md`, `format/README.md`, 17 primary `docs/*.md`
|
||||
owners, domain and design records, release notes and historical reviews. This
|
||||
revision adds an indexed Sleep guide and this review. The primary owners were
|
||||
inspected against code, migration constants, Meson/Gradle settings, tests and
|
||||
retained rollout records. Designs, releases and reviews were mapped as dated
|
||||
evidence rather than rewritten into current status. The tracked Markdown
|
||||
relative-link scan found no missing target before publication. External link
|
||||
availability was not verified.
|
||||
|
||||
| Document/section | Claim | Evidence (commit, path, symbol or test) | Gap | Correction | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `AGENTS.md` scope | Analyse, Programmes, Sessions and Exercises are placeholders | `web/src/routes/`; `docs/web_sessions.md`; `docs/web_exercises.md` at `f4ddcfd` | Obsolete present tense | Names implemented routes | Corrected |
|
||||
| `AGENTS.md`, database and Android status | Current schemas v26/v24, v28/v17 or v36/v33 | `tui/include/trainlog/database.h` `TRAINLOG_DATABASE_SCHEMA_VERSION=37`; `TrainlogRepository.kt` helper `version=34`; migration tests | Mixed historical and current versions | Current source v37/v34; historical migration steps retained | Corrected |
|
||||
| `current_state.md`, roadmap | `c2935b8`/`3758875` Web package served | Observed launcher target names `sleep-single-selector-weeks-v1-f4ddcfd`; ancestor graph | Package claim stale; running bytes unverified | Reports launcher observation and separate Program rollout | Corrected with limit |
|
||||
| Sleep agenda | Old selector or calendar-week interpretation | `SleepDiaryWorkspace.tsx` `initialAgendaPageSize`, `recentEntries`, `visibleEntries`; Sleep component tests | Seven recorded rows can span more than seven dates | Current 7/14/21/28/All row counts, default seven; old values migration only | Corrected |
|
||||
| Sleep PDF and sport | Page controls could be confused with export range | `SleepDiaryWorkspace.tsx` `pdfStartDate`/`pdfEndDate`; `sleepVisualProjection`; Web PDF tests | Selection and visible page have different scopes | Documents independent inclusive range and factual sport bands | Verified in source |
|
||||
| Programs | Rescheduling labelled future/candidate | `docs/program_format_v1.md`; `docs/sync_exchange.md`; `tui/tests/test_web_programs.c`; Android v34 projection | Development capability mislabelled future | Describes implemented development contract and partial V1-peer convergence | Corrected |
|
||||
| Web package | `npm test` could be mistaken for embedded runtime proof | `meson_options.txt` `web`; `tui/meson.build` frontend target/tests; `tools/package_sync_candidate.py` asset gate | Source tests and package differ | README requires `-Dweb=enabled`, Meson tests, compatible bundle | Corrected |
|
||||
| Synchronization | One ACK might imply universal convergence | `docs/sync_exchange.md` Programs V2 matrix; generation and ACK implementation | V1-only peer omits rescheduled Program while other domains proceed | Added domain/format/selection/ACK matrix | Corrected |
|
||||
| Transport narrative | Current Web edits and architecture described USB as universal priority | `docs/sync_exchange.md` Bluetooth selector; `tools/sync_peer_worker.py`; private rollout record | Conflated paired Bluetooth path with separate USB/Drive workflow | Separated automatic paired choice from legacy configured workflow | Corrected |
|
||||
| Rollout readiness | Historical eight-generation limit could look universal/current | `tools/sync_generation_exchange.py` `MAX_RETAINED_PER_PEER=16`; Android `SyncGenerationService.MAX_RETAINED_PER_PEER=8`; `tools/sync_peer_worker.py` MTP timeout | Desktop/Android ceilings differ; timeout is not success proof | Separated limits and historical bridge instructions | Corrected |
|
||||
| Sleep user path | Capture, agenda and PDF behavior had no short indexed guide | `TrainlogRepository.kt`; `SleepDiaryWorkspace.tsx`; Sleep projection/PDF tests | Readers had to assemble workflow from state and code | Added indexed factual guide with privacy and evidence boundaries | Corrected |
|
||||
| Backup/rollback | Old executable could be mistaken for schema rollback | `tools/backup_trainlog_sqlite.py` SQLite backup API; `docs/sync_rollout_readiness.md` | Needs paired compatibility and retained backups | Existing procedure retained and linked from README | Verified in documentation |
|
||||
| Tests | Largest historical totals could look like current validation | `docs/tests.md` dated checkpoints; `tui/meson.build`; Web scripts | Build configuration and skipped tests were implicit | Added evidence-reading rule | Corrected |
|
||||
| 0.1.8 | Possible training ideas could look implemented | `docs/roadmap.md`; present catalog/feedback/Sleep/Cardio owners | No approved algorithm or medical rule | Proposed-only section with human validation | Corrected |
|
||||
|
||||
## Validation performed in the isolated documentation worktree
|
||||
|
||||
`python3 tools/validate_json.py` and
|
||||
`python3 tools/validate_import_contract.py` passed. With a local
|
||||
`-Dweb=enabled` Meson build, strict compilation and **114/114** registered
|
||||
tests passed, including the embedded frontend and package self-containment
|
||||
checks. A preliminary `-Dweb=disabled` run passed 111/112; its deployment
|
||||
package test rejected the intentionally absent Web assets as designed. The
|
||||
changed C/H format gate passed with no changed C/H file. Relative links across
|
||||
the tracked baseline plus new Markdown files and `git diff --check` passed.
|
||||
These checks used build artifacts in this worktree and synthetic test data;
|
||||
they did not exercise a personal device, database or service.
|
||||
|
||||
## Boundaries and open evidence
|
||||
|
||||
The source can prove format selection, persistence rules and test coverage; it
|
||||
cannot prove that a particular uninspected phone or service is currently
|
||||
running that source. The `f4ddcfd` launcher observation identifies a package
|
||||
path, not a live process hash. The private rollout and Firefox/PDF records are
|
||||
dated evidence for their stated configurations. Android path reconstruction for
|
||||
the Web silhouette is source parity evidence, not an Android runtime
|
||||
screenshot. The local desktop 16-generation and Android eight-generation
|
||||
admission limits, and the 90-second MTP operation budget, are bounded
|
||||
configuration, not a guarantee against interruption or proof that transient
|
||||
storage occupation is always lower than either ceiling.
|
||||
|
||||
No code defect was confirmed by this documentation pass. A broad line-by-line
|
||||
revalidation of every historical review and every external URL is outside the
|
||||
verified coverage; their current status must not be inferred from this matrix.
|
||||
No application, database, service, synchronization or personal-data operation
|
||||
belongs to this documentation branch.
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
# Roadmap
|
||||
|
||||
The Program rescheduling service is deployed in the private daily installation
|
||||
from `trainlog-program-recalage-v1` at `3a4ec34`. A later compact-calendar
|
||||
PC/Web package was built from that older source base and omitted the Web body
|
||||
figure and Sleep sport commits. The corrected 0.1.7 package from `700dfc0`
|
||||
combines the calendar with those later changes and is installed on the daily
|
||||
Web launcher. Firefox and a downloaded PDF verified the served result on
|
||||
2026-10-07; synchronization agents retain their separate Program rollout.
|
||||
|
||||
The controlled private rollout is complete for the paired daily installation:
|
||||
physical libmtp, verified backups, signing continuity, migrations, correlated
|
||||
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||
|
|
@ -39,8 +31,7 @@ estimates.
|
|||
## Current baseline
|
||||
|
||||
Stable v0.1.6 is the release baseline for the v0.1.7 stabilization cycle.
|
||||
The audited development source has desktop schema v37 and Android schema v34.
|
||||
Stable v0.1.6 had desktop schema v36 and Android schema v33. Notcurses, direct `Documents/Trainlog`
|
||||
Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog`
|
||||
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
||||
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
||||
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
||||
|
|
@ -99,20 +90,12 @@ protocol evolution, new Android/Web/TUI capabilities, and speculative
|
|||
optimization are out of scope. Work outside this boundary waits for a later
|
||||
feature cycle rather than widening 0.1.7.
|
||||
|
||||
### Program rescheduling in the audited development line
|
||||
### Deferred Program rescheduling
|
||||
|
||||
The Web month calendar performs an individual revisioned move by drag and drop
|
||||
or an accessible date action. It preserves every Program session, including
|
||||
FACULTATIVE sessions, and offers explicit restoration of legacy ceded rows.
|
||||
The earlier ordered cascade remains an API compatibility surface but is no
|
||||
longer offered by the calendar. Completed sessions remain fixed to factual
|
||||
execution dates. The corrected PC/Web bundle is served by the daily launcher;
|
||||
its calendar, body figures and Sleep sport PDF were checked in Firefox.
|
||||
|
||||
Ordered Program rescheduling is implemented in the audited development line
|
||||
and deployed in the recorded private Program rollout. The retained cascade API
|
||||
selects the starting session, restart date, affected successors, and usable
|
||||
slots; previews the old and new dates; then durably records one decision. Only
|
||||
Ordered Program rescheduling requires a separate, versioned shared-contract
|
||||
decision after the actual-date display correction. One explicit user action
|
||||
must select the starting session, restart date, affected successors, and usable
|
||||
slots; preview the old and new dates; then durably record one decision. Only
|
||||
unfinished sessions move, in Program position order. Explicitly ceded optional
|
||||
slots may absorb required sessions without marking optional sessions completed
|
||||
or removing historical identities. At most one principal Program session per
|
||||
|
|
@ -120,12 +103,12 @@ selected slot is a rule for this action, not a limit on other same-day workouts.
|
|||
Rest days and Program bounds cannot be silently overridden. Preparations and
|
||||
in-progress executions require checks before applying the decision.
|
||||
|
||||
The result does not cascade again on display, import, replay, or Bluetooth
|
||||
The result must not cascade again on display, import, replay, or Bluetooth
|
||||
reconnection. PC and Android must receive the same business state; a Web-only
|
||||
plan is insufficient. Compatibility requires proof in both supported exchange
|
||||
directions and across revisions or reexports, including explicit behavior for
|
||||
older clients unable to represent the decision. Payload acceptance alone does
|
||||
not establish convergence. This development contract does not reopen the
|
||||
not establish convergence. This future contract study does not reopen the
|
||||
Bluetooth transport, Sleep, HR, or general sync invariants. Before any real
|
||||
application, reread executions and preparations, confirm the restart date and
|
||||
slot choices, and preview the resulting plan again.
|
||||
|
|
@ -190,8 +173,7 @@ remaining per-installation opt-ins are deployment policy, not the v0.1.3 cursor.
|
|||
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
|
||||
system, permanent Header/Footer, empty-data states and health status. The
|
||||
Dashboard slices below are complete; this does not authorize implementation of
|
||||
the then-remaining route placeholders; those routes are implemented on the
|
||||
audited 0.1.7 development line.
|
||||
the remaining route placeholders.
|
||||
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
|
||||
explicit edit mode, drag/resize, keyboard alternative and derived responsive
|
||||
projections without persisting layout state.
|
||||
|
|
@ -432,20 +414,6 @@ normal capture.
|
|||
|
||||
## Later
|
||||
|
||||
### Proposed 0.1.8 training assistance (not implemented)
|
||||
|
||||
Explore gradual, explained progression or maintaining a load from existing
|
||||
performance and subjective feedback. Compare exercises and equipment only when
|
||||
their context is genuinely comparable; account for effort, discomfort and
|
||||
missing observations. Explore requested or evidenced targeted strengthening,
|
||||
reasoned exercise diversity and dumbbell use without diagnosing weakness.
|
||||
Define cautious cardio reference points before any gradual introduction, with
|
||||
no maximal test or generated prescription from this documentation work. Reuse
|
||||
the existing medication diary, Sleep and Cardio facts as context without a
|
||||
second intake workflow or asserted medical causality. A human must validate
|
||||
any proposed training change before it alters a workout. These are ideas, not
|
||||
an implementation decision, delivery date, recovery score or clinical rule.
|
||||
|
||||
- evolve training analytics only where sufficient real history supports the
|
||||
metric, without aggregating incomparable loads;
|
||||
- add body baseline and trend comparisons while keeping direct measurements
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
# Sleep Diary: capture, review and PDF
|
||||
|
||||
This guide describes the Web and Android behavior in the 0.1.7 development
|
||||
source. The [current state](current_state.md) owns rollout
|
||||
and validation status; [synchronization exchange](sync_exchange.md) owns the
|
||||
versioned Sleep companion. Sleep facts and medication intakes are not a medical
|
||||
interpretation or an automatic training decision.
|
||||
|
||||
## Capture and correction
|
||||
|
||||
Android's quick actions record bedtime (`Couché`), a waking interval
|
||||
(`Réveil`), final get-up (`Levé`) and time-stamped medication intakes. An intake
|
||||
may be recorded during the day or before bedtime. A pre-bed intake creates or
|
||||
reuses the same durable night/day entry; `Couché` later adds to that entry.
|
||||
There is no separate medicine log to fill out. Quantity is a count of the
|
||||
separately stored per-unit dose. Each meaningful edit advances the entry's
|
||||
causal revision; correction uses the existing entry rather than inventing
|
||||
another night. The Android owner and draft/quick-publication rules are detailed
|
||||
in [Android](android.md#sleep-diary-v1-and-active-v2-companion).
|
||||
|
||||
The Sleep day is assigned using the local 18:00-to-18:00 boundary. Each Web
|
||||
agenda row and its heart-rate detail use that night's exact offset and a
|
||||
24-hour elapsed timeline. A missing factual sleep interval may receive a
|
||||
clearly labelled presentation estimate when both bedtime and final get-up
|
||||
exist: bedtime plus 45 minutes through final get-up minus 10 minutes, minus
|
||||
structured waking overlap for the displayed duration. It does not write a
|
||||
Sleep fact. No missing heart-rate samples or sleep stages are manufactured.
|
||||
|
||||
## Review the agenda
|
||||
|
||||
In Analyse → Sommeil, the agenda is newest first. Its single display selector
|
||||
shows 7, 14, 21, 28 or All **recorded entries** per page; seven is the first-use
|
||||
default. These are row counts, not complete calendar weeks: seven entries can
|
||||
span more than seven dates. The choice is browser-local and survives reload;
|
||||
previous 5/10/15 choices migrate to 7/14/21. The other Analyse sections keep
|
||||
their own period control. Select a night to inspect and edit that entry and
|
||||
its measured heart-rate timeline. Paging does not discard the selected night,
|
||||
editor state, loaded statistics or PDF range.
|
||||
|
||||
Saving a Web entry creates a durable local revision. The editor's **Allow
|
||||
synchronization** action publishes that exact revision; only a current
|
||||
published revision is eligible for the Sleep synchronization companion. A
|
||||
subsequent edit needs a new publication action. The editor shows local draft,
|
||||
ready, synchronized or modified state so this choice remains explicit. Agenda
|
||||
rows and the PDF do not display publication status because it does not describe
|
||||
the recorded sleep or affect PDF inclusion. The publication API and durable
|
||||
state are unchanged.
|
||||
|
||||
Blue sport bands come from completed session start/end history, not the
|
||||
Program plan. A missing or invalid end is shown as a point marker with no
|
||||
invented duration. The Web agenda omits the redundant sport text list; the PDF
|
||||
retains a dated sport appendix. Sport does not become a Sleep event or change a
|
||||
sleep total. If the full read reaches its 3,660-night cap, the Web reports that
|
||||
completeness cannot be established instead of silently treating a truncated
|
||||
set as All. Missing data and load errors have explicit states.
|
||||
|
||||
## Preview and export
|
||||
|
||||
The vector PDF preview and download share an inclusive range of **night start
|
||||
dates**. That range is initialized from loaded history and is independent of
|
||||
both the visible agenda page and selected detail night. Sport is read completely
|
||||
before preview/export. The PDF uses the same 18:00 geometry as Web, prints
|
||||
factual sport intervals in blue, and lists sessions even when there is no
|
||||
matching Sleep row. Treatments and remarks wrap by available width and continue
|
||||
on dated pages instead of truncating silently. Review the chosen range before
|
||||
export; a PDF is a local copy of potentially sensitive information.
|
||||
|
||||
The PDF prints the newest selected night first and orders its sport appendix by
|
||||
actual session day, newest first, with sessions chronological within each day.
|
||||
Events and medication intakes within each night keep their chronological order.
|
||||
A light, printable header shows the
|
||||
selected period and generation date; alternating pale rows and a restrained
|
||||
accent distinguish nights. Publication status and global validation warnings
|
||||
are omitted from the report. This presentation does not alter Sleep facts,
|
||||
timing, sport attribution, synchronization or the database schema.
|
||||
|
||||
The descriptive heart-rate rise/fall overlays compare measured BPM with a
|
||||
recent reference. They do not infer sleep stages, causes, readiness or medical
|
||||
conditions. The source-backed projection and evidence limits are summarized in
|
||||
[current state](current_state.md#versions-and-compatibility) and the
|
||||
[documentation review](reviews/documentation_deep_review_v1.md).
|
||||
|
|
@ -261,28 +261,6 @@ does not publish to it or let it override an artifact in the new endpoint.
|
|||
|
||||
## 3. Artifact table
|
||||
|
||||
The following is a navigation matrix for the audited `f4ddcfd` source. A
|
||||
capability advertisement selects a companion; file presence alone is not an
|
||||
ACK or proof that every domain converged. The detailed versioned rules below
|
||||
remain authoritative.
|
||||
|
||||
| Domain | Producer → consumer | Format/version | Selection and persistence | ACK/proof and limit |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Completed sessions | Android ↔ desktop | `trainlog-mobile-export` V3; V1/V2 legacy input | Default automatic snapshot; stable IDs in separate SQLite stores | V3 import/replay is idempotent; this is not a full-generation ACK |
|
||||
| Manual preparations | Desktop → Android | `trainlog-session-preparations` V2 | Full generation on capable peer; revisions, deliveries and withdrawals retained | Correlated generation ACK closes delivery/withdrawal; an active draft is preserved |
|
||||
| Programs | Desktop → Android | `trainlog-programs` V1 or V2 | Exactly one selected by peer capability; V2 carries current dates, ceded state and revision sequence; desktop owns plan | Correlated ACK; V1-only peer cannot represent a rescheduled live plan, so Program convergence may remain incomplete while other domains proceed |
|
||||
| Program executions | Android → desktop | `trainlog-program-executions` V1 | Stable in-progress/completed provenance, separate from `trainlog-program` V1 desktop import | Generation validation and ACK; no invented completed session |
|
||||
| Sleep diary | Android ↔ desktop | `sleep-diary-v2`; frozen V1 readable | Current causal revision and immutable history; v2 carries intake quantity | Generation/ACK plus revision validation; concurrent ambiguous edits can reject |
|
||||
| Heart rate and Cardio | Android → desktop | Heart Rate V1, session timeline V1, cardio sessions/calibration/guidance V1; separate heart-rate corrections V1 | Factual captures and versioned companions, selected by capabilities | Correlated generation/ACK; no sensor or medical inference on desktop |
|
||||
| Exercise/equipment context | Both directions | Catalog, aliases, BODY ZONES and equipment companions below | Stable IDs, revision and causal rules apply per domain | A generation ACK does not override a per-domain conflict |
|
||||
|
||||
`trainlog-program` V1 is a strict desktop file import defined in
|
||||
[Program format V1](program_format_v1.md); it is never the synchronized
|
||||
`trainlog-programs` companion. `/api/v1` is the local Web API, not a JSON
|
||||
exchange format. Bluetooth, MTP and private Drive move bytes for the same
|
||||
full-generation business protocol; default V3 compatibility and installation
|
||||
opt-in remain separate from format capability.
|
||||
|
||||
| Direction | File | Format |
|
||||
| --- | --- | --- |
|
||||
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
|
||||
|
|
@ -299,7 +277,6 @@ opt-in remain separate from format capability.
|
|||
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
||||
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
||||
| PC -> Android | `programs-v2.json` | capability-selected `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same deletion tombstones |
|
||||
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
|
||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
|
||||
|
|
@ -322,33 +299,6 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
|
|||
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
|
||||
semantics.
|
||||
|
||||
### Programs V2 capability matrix in the audited development line
|
||||
|
||||
`trainlog-programs` V2 is a separate strict artifact selected only for a peer
|
||||
advertising `programs-v2`. Its session adds `position`, nullable `current_for`
|
||||
and `planning_state=active|ceded`; its Program adds `revision_sequence`.
|
||||
`planned_for`, stable IDs, source entries and tombstones retain their V1
|
||||
meaning. One generation contains exactly one Programs version. Android v34
|
||||
stores the V2 projection transactionally and rejects a lower sequence, a
|
||||
same-sequence different revision, a later V1 downgrade, or a changed plan for
|
||||
its currently active Program draft. Execution facts return through the
|
||||
unchanged Program-executions V1 companion and retain their actual timestamps.
|
||||
|
||||
| Desktop state | Android capability | Program result | Other domains |
|
||||
|---|---|---|---|
|
||||
| No reschedule | `programs-v1` only | V1 original plan | Normal generation |
|
||||
| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation |
|
||||
| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation |
|
||||
| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize |
|
||||
| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable |
|
||||
|
||||
The desktop commit requires that the latest generation to each previously
|
||||
contacted Android peer was an acknowledged V2 Program generation no older than
|
||||
five minutes. This bounds, but cannot eliminate, a phone-side race: Android's
|
||||
active-draft check is the final guard. A failed or downgraded peer cannot be
|
||||
reported as current for the Program domain. The existing generation, request,
|
||||
ACK, tombstone, transport, Sleep and HR wire contracts are unchanged.
|
||||
|
||||
## Optional Program executions companion V1
|
||||
|
||||
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
||||
|
|
@ -1134,7 +1084,7 @@ named by that validated reference. It does not recursively mirror retained
|
|||
Android generations, desktop publications, or unrelated exchange history.
|
||||
Local correlation is checked every 50 ms without USB work; complete MTP pulls
|
||||
are throttled to a three-second cadence. Each MTP operation has its own
|
||||
90-second ceiling while the conversation retains its configured global
|
||||
30-second ceiling while the conversation retains its configured global
|
||||
deadline. Exhausting the global deadline while waiting for a correlated
|
||||
reference reports `timeout waiting for correlated android-generation-v1.json`;
|
||||
only an adapter that exceeds its independent operation budget reports
|
||||
|
|
|
|||
|
|
@ -51,29 +51,26 @@ generalizes pairing to another phone or authorizes an unconfigured desktop.
|
|||
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
|
||||
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
|
||||
foreign-key checks.
|
||||
4. V3 is not an Android backup. At the original September 2026 rollout, the
|
||||
installed schema-v17 phone required a pinned bridge candidate, verified
|
||||
signing/version continuity, and a complete retained user backup before the
|
||||
newer Android package. That is historical procedure evidence, not a claim
|
||||
about the currently installed phone schema or an instruction to repeat the
|
||||
bridge. A new rollout must inspect its actual versions and backups first.
|
||||
4. V3 is not an Android backup. Current Android has a complete verified backup
|
||||
container. For the installed schema-v17 release, build the pinned bridge
|
||||
candidate, verify signing/version continuity separately, install only with
|
||||
explicit approval, create and verify the user backup, and retain it before
|
||||
installing current Android. None of those device/user operations occurred in
|
||||
this software mission.
|
||||
5. Replacing a binary does not roll back a schema. Before any exchange, a
|
||||
mutually compatible captured pair may be restored. After causal writes or
|
||||
ACKs, never restore only one peer: stop synchronization, preserve both stores
|
||||
and all artifacts, and use an explicit paired recovery plan.
|
||||
6. Per-peer retained-generation ceilings are admission gates: the audited
|
||||
desktop `sync_generation_exchange.py` permits 16 active outgoing entries;
|
||||
Android `SyncGenerationService` permits eight. Exhaustion preserves pending
|
||||
generations, tombstones, finalizations and ACK ledgers; it never triggers
|
||||
hidden eviction. These counts are not a bound on all transient staging or
|
||||
transport objects.
|
||||
6. The eight-generation-per-peer ceiling is an admission gate. Exhaustion must
|
||||
preserve pending generations, tombstones, finalizations and ACK ledgers; it
|
||||
must never trigger hidden eviction.
|
||||
|
||||
The grouped rollout approval covered the signed APK, desktop bundle, verified
|
||||
user backups, coordinated service quiescence, pairing, synthetic smoke and
|
||||
non-destructive hardware smoke. Those steps are complete for the recorded
|
||||
private installation. Drive remains separate and untested.
|
||||
|
||||
The software-only candidate recorded for the original rollout was rebuilt under the private root
|
||||
The latest software-only candidate set was rebuilt under the private root
|
||||
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
|
||||
`dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every
|
||||
packaged file digest plus schemas, protocols, entry points and runtime
|
||||
|
|
@ -87,7 +84,7 @@ synthetic schema-v17 backup SHA-256 is
|
|||
`06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`.
|
||||
These are unsigned development evidence, not deployment authorization.
|
||||
|
||||
## Compatible set for the original rollout
|
||||
## Compatible set
|
||||
|
||||
- desktop binary containing schema v21, the Web API and the generation worker;
|
||||
- `sync_generation_exchange.py` and all matching transaction-neutral codecs;
|
||||
|
|
@ -113,16 +110,11 @@ These are unsigned development evidence, not deployment authorization.
|
|||
|
||||
Do not delete an unacknowledged generation, the last acknowledged generation,
|
||||
its previous valid generation, or causal evidence. An ACK timeout is unresolved,
|
||||
not rollback. Sixteen retained outgoing generations exhaust admission and require
|
||||
not rollback. Eight retained outgoing generations exhaust admission and require
|
||||
operator diagnosis; this tranche adds no tombstone or evidence garbage
|
||||
collection. A migrated database is recovered from its verified backup, never by
|
||||
assuming an older binary reverses its migration.
|
||||
|
||||
The audited MTP peer worker has a 90-second per-operation budget, further
|
||||
limited by the remaining conversation deadline. It is a timeout bound, not a
|
||||
success guarantee or proof that an interruption cause has disappeared.
|
||||
|
||||
The recorded private installation completed physical MTP, service coordination
|
||||
and APK/desktop deployment. Actual Drive configuration remains untested; the
|
||||
Android eight-generation admission bound and foreground-phone requirement
|
||||
remain; the audited desktop limit is 16.
|
||||
eight-generation admission bound and foreground-phone requirement remain.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
# Tests and validation
|
||||
|
||||
## Reading the evidence
|
||||
|
||||
Counts below belong to the stated checkpoint, source revision and build
|
||||
configuration. They are historical evidence, not a claim that the present
|
||||
documentation checkout ran those tests. A native build with `-Dweb=enabled`
|
||||
registers embedded Web asset tests in Meson; `npm test` separately runs Vitest
|
||||
against Web source. A default `web=auto` build can omit embedded assets, so a
|
||||
green source test alone does not validate the packaged Web application.
|
||||
Skipped Android fixture tests, unrun instrumented tests and physical Bluetooth,
|
||||
MTP or Drive evidence are distinct outcomes. The primary personal phone is
|
||||
reserved for authorized manual smoke checks, not host automation.
|
||||
|
||||
## Sleep V2 concurrent-edit selection in 0.1.7
|
||||
|
||||
Synthetic Desktop and Android regressions cover both edit-time orderings,
|
||||
|
|
@ -409,29 +397,6 @@ subset summaries cannot inherit facts from unselected nights. The same real
|
|||
Firefox scenario exports a seven-night French PDF from the embedded production
|
||||
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
|
||||
with `pdftoppm` for retained visual inspection.
|
||||
The 0.1.7 PDF polish tests also assert newest-first night and sport day order,
|
||||
chronological sessions within each day, unchanged chronological intervals and
|
||||
medication intakes within a night, 7/14/21/28-night pagination, and absence of
|
||||
global validation warnings. Web
|
||||
tests keep the exact-revision publication action and editing while excluding
|
||||
publication labels from agenda rows.
|
||||
|
||||
The completed-sport Sleep overlay is covered by C read-only history
|
||||
intersection/pagination checks, Web pagination and failure-state tests, and
|
||||
pure geometry tests for 05:00 placement, midnight, 18:00 clipping, missing and
|
||||
invalid ends, and the Europe/Paris fall offset. PDF tests assert vector blue
|
||||
bands, black outlines, factual labels, and unchanged Sleep totals. Synthetic
|
||||
Firefox validation uses an isolated schema-v37 database and the embedded
|
||||
`-Dweb=enabled` binary, then inspects a downloaded PDF rendered by `pdftoppm`.
|
||||
Synthetic French and English reports of 7, 14, 21, and 30 nights are rasterized
|
||||
for page, margin, and monochrome inspection. No personal health artifact is
|
||||
stored in the repository.
|
||||
The private PC/Web deployment was checked separately after the rollout switch:
|
||||
the running executable and the served JavaScript/CSS hashes matched the
|
||||
`e2c07f1` bundle. Read-only Firefox inspection found 9 blue segments across
|
||||
11 existing Sleep rows; a downloaded three-page PDF contained the same sampled
|
||||
factual start time and a printable sport listing. Its private images and PDF
|
||||
remain outside the repository.
|
||||
|
||||
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
||||
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
||||
|
|
|
|||
|
|
@ -51,7 +51,5 @@ Create, update and retirement first commit only to the desktop database. They
|
|||
do not call synchronization and do not require a phone. The next user-triggered
|
||||
normal synchronization reuses the existing PC catalogue, exercise profile
|
||||
state, BODY ZONES, equipment companions and causal deletion artifacts through
|
||||
the configured generation engine (Bluetooth primary with direct MTP recovery
|
||||
on the paired installation, or the separate USB/Drive workflow). Without that
|
||||
opt-in, the default V3 path retains its documented compatibility limits. No Web
|
||||
the existing USB-priority engine with Drive as mirror/fallback. No Web
|
||||
Exercises artifact or protocol version was introduced.
|
||||
|
|
|
|||
|
|
@ -84,8 +84,7 @@ Inactive execution drafts and completed sessions use the existing causal
|
|||
operation/state boundary. Active or stale drafts conflict, and completed-session
|
||||
deletion records a finalization so older snapshots cannot recreate the session.
|
||||
|
||||
Programs are planning data owned by desktop SQLite (introduced in v25 and
|
||||
extended through v37). The Programs subtab
|
||||
Programs are planning data owned by desktop schema v26. The Programs subtab
|
||||
uses responsive cards and a timeline detail view backed by real imported
|
||||
definitions, sessions, usage, and provenance data. Import first runs the
|
||||
same strict Core validator in preview mode, then commits only after explicit
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import export_programs
|
||||
import export_programs_v2
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
|
|
@ -80,41 +79,6 @@ class ProgramsExchangeTest(unittest.TestCase):
|
|||
"requested_at": "2026-09-18T12:00:00Z",
|
||||
}], payload["deletions"])
|
||||
|
||||
def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self):
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE program_session_planning(
|
||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,
|
||||
state TEXT,operation_id TEXT,updated_at TEXT);
|
||||
CREATE TABLE program_revision_sequences(
|
||||
program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL);
|
||||
INSERT INTO program_revision_sequences VALUES('pg_live',1);
|
||||
INSERT INTO program_session_planning VALUES(
|
||||
'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z');
|
||||
PRAGMA user_version=37;
|
||||
""")
|
||||
db.commit()
|
||||
initial = export_programs.export_programs(self.database)
|
||||
current = export_programs_v2.export_programs_v2(self.database)
|
||||
self.assertEqual(1, initial["version"])
|
||||
self.assertEqual(2, current["version"])
|
||||
original = initial["programs"][0]["sessions"][0]
|
||||
planned = current["programs"][0]["sessions"][0]
|
||||
self.assertIsNone(original["planned_for"])
|
||||
self.assertEqual(set(original), {
|
||||
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences"
|
||||
})
|
||||
self.assertEqual("2026-10-01", planned["current_for"])
|
||||
self.assertEqual(1, current["programs"][0]["revision_sequence"])
|
||||
self.assertEqual("active", planned["planning_state"])
|
||||
self.assertEqual(0, planned["position"])
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'")
|
||||
db.commit()
|
||||
ceded = export_programs_v2.export_programs_v2(self.database)
|
||||
self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"])
|
||||
self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -77,11 +77,10 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||
self.assertEqual(inventory["product_version"], "0.1.7")
|
||||
self.assertEqual(inventory["desktop_schema"], 37)
|
||||
self.assertEqual(inventory["android_schema"], 34)
|
||||
self.assertEqual(inventory["desktop_schema"], 36)
|
||||
self.assertEqual(inventory["android_schema"], 32)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
self.assertIn("programs-v2", inventory["protocols"])
|
||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||
self.assertIn("sleep-diary-v2", inventory["protocols"])
|
||||
self.assertIn("cardio-sessions-v1", inventory["protocols"])
|
||||
|
|
|
|||
|
|
@ -128,25 +128,21 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
|||
)
|
||||
|
||||
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
|
||||
operation_budget = sync_peer_worker.MTP_OPERATION_TIMEOUT_SECONDS
|
||||
timeout = subprocess.TimeoutExpired(
|
||||
["adapter", "push"], operation_budget,
|
||||
)
|
||||
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
transport = Path(directory) / "transport"
|
||||
transport.mkdir()
|
||||
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
rf"^transport_timeout: MTP push did not finish within "
|
||||
rf"{operation_budget:g} seconds$",
|
||||
r"^transport_timeout: MTP push did not finish within 30 seconds$",
|
||||
) as caught:
|
||||
sync_peer_worker.run_adapter(
|
||||
Path("/adapter"),
|
||||
"push",
|
||||
"peer_00000000-0000-4000-8000-000000000001",
|
||||
transport,
|
||||
sync_peer_worker.time.monotonic() + operation_budget + 10,
|
||||
sync_peer_worker.time.monotonic() + 60,
|
||||
)
|
||||
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
|
||||
|
||||
|
|
|
|||
|
|
@ -71,58 +71,6 @@ class GenerationTest(unittest.TestCase):
|
|||
with closing(sqlite3.connect(destination)) as reopened:
|
||||
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
|
||||
|
||||
def test_rescheduled_program_requires_v2_capability_before_publication(self):
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE program_session_planning(
|
||||
program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT);
|
||||
CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT);
|
||||
CREATE TABLE program_sessions(
|
||||
program_session_id TEXT PRIMARY KEY,program_id TEXT);
|
||||
INSERT INTO programs VALUES('pg_fixture',NULL);
|
||||
INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture');
|
||||
INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active');
|
||||
PRAGMA user_version=37;
|
||||
""")
|
||||
db.commit()
|
||||
artifacts = (
|
||||
("history", "trainlog-mobile-export", 4, "history.json", True,
|
||||
"controlled.py", ()),
|
||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
||||
"export_programs_v2.py", ()),
|
||||
)
|
||||
|
||||
def exporter(tool, _extra, output, _snapshot):
|
||||
if tool == "controlled.py":
|
||||
output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4}))
|
||||
return
|
||||
output.write_text(json.dumps({"format": "trainlog-programs",
|
||||
"version": 2 if tool.endswith("_v2.py") else 1,
|
||||
"programs": [], "deletions": []}))
|
||||
|
||||
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
||||
mock.patch.object(generation, "run_export", exporter):
|
||||
with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"):
|
||||
generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN)
|
||||
self.assertFalse((self.root / "legacy" / "staging" / GEN).exists())
|
||||
legacy_stage, legacy_manifest, _ = generation.capture_desktop(
|
||||
self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN,
|
||||
set(), True,
|
||||
)
|
||||
self.assertFalse((legacy_stage / "programs-v1.json").exists())
|
||||
self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]])
|
||||
stage, manifest, _ = generation.capture_desktop(
|
||||
self.database, self.root / "modern", PEER_B, RUN,
|
||||
"gen_55555555-5555-4555-8555-555555555555",
|
||||
{"programs-v2"},
|
||||
)
|
||||
self.assertTrue((stage / "programs-v2.json").is_file())
|
||||
self.assertFalse((stage / "programs-v1.json").exists())
|
||||
self.assertEqual(["history", "programs-v2"],
|
||||
[a["logical_name"] for a in manifest["artifacts"]])
|
||||
|
||||
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
|
||||
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
|
|
|
|||
|
|
@ -58,8 +58,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
|
||||
clock=lambda: 50.0,
|
||||
self.transport, 100.0, clock=lambda: 50.0,
|
||||
)
|
||||
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
|
||||
|
||||
|
|
@ -113,8 +112,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
|
||||
clock=lambda: 50.0,
|
||||
self.transport, 100.0, clock=lambda: 50.0,
|
||||
)
|
||||
|
||||
def test_delayed_correlated_generation_survives_multiple_polls(self):
|
||||
|
|
|
|||
|
|
@ -271,8 +271,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37):
|
||||
fail("desktop schema v33-v37 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
|
||||
fail("desktop schema v33-v36 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -395,8 +395,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37):
|
||||
fail("desktop schema v34-v37 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
|
||||
fail("desktop schema v34-v36 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise CausalError(f"desktop schema v20-v37 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise CausalError(f"desktop schema v20-v36 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise LifecycleError(f"desktop schema v19-v37 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise LifecycleError(f"desktop schema v19-v36 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
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-v37 requis")
|
||||
raise ValueError("schema desktop v15-v36 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -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, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v37 requis")
|
||||
raise ValueError("schema desktop v18-v36 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 38))
|
||||
supported_versions = tuple(range(8, 37))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v37 requis")
|
||||
raise ValueError("schema desktop v8 à v36 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"))
|
||||
|
|
|
|||
|
|
@ -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, 38))
|
||||
supported_versions = tuple(range(8, 37))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v37 requis")
|
||||
raise ValueError("schema desktop v8 à v36 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(
|
||||
|
|
|
|||
|
|
@ -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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ValueError("schema desktop v12-v37 requis")
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise ValueError("schema desktop v12-v36 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 38))
|
||||
supported_versions = tuple(range(11, 37))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v37 requis")
|
||||
raise ValueError("schema desktop v11-v36 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
exercises = []
|
||||
|
|
|
|||
|
|
@ -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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in range(8, 38):
|
||||
if version not in range(8, 37):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ 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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 38)
|
||||
supported_versions = range(11, 37)
|
||||
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-v37 requis (v10 accepté pour export V2 explicite)")
|
||||
raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)")
|
||||
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ 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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise ValueError("desktop schema v26 through v32 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export the versioned desktop-owned Program planning projection."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from export_programs import export_programs
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
def export_programs_v2(database: Path) -> dict:
|
||||
# WHY: V1's strict session fields are frozen. V2 carries absolute current
|
||||
# dates and ceded-slot state without changing the imported source date.
|
||||
value = export_programs(database)
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 37:
|
||||
raise ValueError("desktop schema v37 required for Programs V2")
|
||||
for program in value["programs"]:
|
||||
sequence = db.execute(
|
||||
"SELECT revision_sequence FROM program_revision_sequences WHERE program_id=?",
|
||||
(program["program_id"],),
|
||||
).fetchone()
|
||||
program["revision_sequence"] = sequence[0] if sequence is not None else 0
|
||||
planning = {
|
||||
row[0]: (row[1], row[2])
|
||||
for row in db.execute(
|
||||
"SELECT p.program_session_id,p.current_for,p.state "
|
||||
"FROM program_session_planning p JOIN program_sessions s "
|
||||
"ON s.program_session_id=p.program_session_id WHERE s.program_id=?",
|
||||
(program["program_id"],),
|
||||
)
|
||||
}
|
||||
for position, session in enumerate(program["sessions"]):
|
||||
current, state = planning.get(
|
||||
session["program_session_id"], (session["planned_for"], "active")
|
||||
)
|
||||
session["position"] = position
|
||||
session["current_for"] = current
|
||||
session["planning_state"] = state
|
||||
value["version"] = 2
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.write_text(
|
||||
json.dumps(export_programs_v2(args.database), ensure_ascii=False,
|
||||
sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
||||
deliveries = []
|
||||
|
|
|
|||
|
|
@ -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, 38)
|
||||
supported_versions = range(15, 37)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v37 requis")
|
||||
raise ValueError("schema desktop v15-v36 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()
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in range(27, 38):
|
||||
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}")
|
||||
if version not in range(27, 37):
|
||||
raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
|
||||
|
||||
|
||||
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -282,8 +282,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v30-v37 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||
fail("desktop schema v30-v36 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -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, 38)
|
||||
supported_versions = range(18, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v37 requis")
|
||||
raise ImportFailure("schema desktop v18-v36 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")
|
||||
|
|
|
|||
|
|
@ -206,9 +206,9 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(8, 38)
|
||||
supported_versions = range(8, 37)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v37 requis")
|
||||
fail("schema desktop v8 à v36 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ 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, 38)
|
||||
supported_versions = range(8, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v37 requis")
|
||||
fail("schema desktop v8 à v36 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
fail("causal equipment protection requires the staged artifact")
|
||||
imported = skipped = 0
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 38)
|
||||
supported_versions = range(12, 37)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v37 requis")
|
||||
fail("schema desktop v12-v36 requis")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
if target is None: fail(f"cible canonique absente: {canonical}")
|
||||
|
|
|
|||
|
|
@ -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, 38)
|
||||
supported_versions = range(11, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v37 requis")
|
||||
raise ImportFailure("schema desktop v11-v36 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
|
||||
|
|
|
|||
|
|
@ -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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("schema desktop v17-v37 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
fail("schema desktop v17-v36 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -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, 38):
|
||||
if version not in range(5, 37):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v37 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v36 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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,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, 38)
|
||||
supported_versions = range(15, 37)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v37 requis")
|
||||
fail("schema desktop v15-v36 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"]):
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ RUNTIME_TOOLS = [
|
|||
"post_sync_ai_drive.py",
|
||||
"export_session_preparations.py",
|
||||
"export_programs.py",
|
||||
"export_programs_v2.py",
|
||||
"program_execution_exchange.py",
|
||||
]
|
||||
|
||||
|
|
@ -82,14 +81,6 @@ def main() -> int:
|
|||
raise RuntimeError("compiled generation MTP adapter is missing")
|
||||
if not sync_once.is_file():
|
||||
raise RuntimeError("compiled legacy synchronization helper is missing")
|
||||
# WHY: web=auto can build a native executable without any embedded UI.
|
||||
# CONTRACT: a rollout bundle must contain the frontend it will serve.
|
||||
build_options = json.loads(subprocess.check_output(
|
||||
["meson", "introspect", "--buildoptions", str(args.build)], text=True
|
||||
))
|
||||
web = next((option["value"] for option in build_options if option["name"] == "web"), None)
|
||||
if web != "enabled":
|
||||
raise RuntimeError("candidate requires an explicitly embedded Web frontend")
|
||||
(args.output / "bin").mkdir(parents=True)
|
||||
(args.output / "libexec").mkdir(parents=True)
|
||||
(args.output / "tools").mkdir(parents=True)
|
||||
|
|
@ -129,8 +120,8 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.7",
|
||||
"desktop_schema": 37,
|
||||
"android_schema": 34,
|
||||
"desktop_schema": 36,
|
||||
"android_schema": 32,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -144,7 +135,6 @@ def main() -> int:
|
|||
"session-preparations-v2",
|
||||
"ai-session-drafts-v2",
|
||||
"programs-v1",
|
||||
"programs-v2",
|
||||
"program-executions-v1",
|
||||
"sleep-diary-v2",
|
||||
"cardio-sessions-v1",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ 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, 29, 30, 31, 32, 33, 34, 35, 36, 37):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||
fail("desktop schema v27 through v32 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
|
|
|
|||
|
|
@ -184,8 +184,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v31-v37 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
|
||||
fail("desktop schema v31-v36 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -637,8 +637,8 @@ def main() -> None:
|
|||
import_command.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||
fail("desktop schema v30-v37 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||
fail("desktop schema v30-v36 required")
|
||||
if args.command == "export":
|
||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -51,11 +51,7 @@ MAX_GENERATION = 256 * 1024 * 1024
|
|||
MAX_NAME = 64
|
||||
MAX_PATH = 240
|
||||
MAX_DIAGNOSTIC = 1024
|
||||
# WHY: interrupted MTP publications can leave immutable generations awaiting
|
||||
# an ACK alongside the two acknowledged lineage members kept for recovery.
|
||||
# CONTRACT: admission remains bounded and never erases unresolved generations.
|
||||
# INVARIANT: captured, published and waiting generations still consume a slot.
|
||||
MAX_RETAINED_PER_PEER = 16
|
||||
MAX_RETAINED_PER_PEER = 8
|
||||
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
HEX = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
|
@ -86,8 +82,6 @@ ARTIFACTS = (
|
|||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
|
||||
"export_programs_v2.py", ()),
|
||||
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
|
||||
"sleep_diary_exchange.py", ("export",)),
|
||||
)
|
||||
|
|
@ -227,9 +221,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24 through v37 required")
|
||||
raise GenerationError("desktop schema v24 through v36 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -372,9 +366,7 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path)
|
|||
|
||||
|
||||
def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||
run_id: str | None = None, generation_id: str | None = None,
|
||||
peer_capabilities: set[str] | None = None,
|
||||
allow_program_skip: bool = False):
|
||||
run_id: str | None = None, generation_id: str | None = None):
|
||||
if not valid_id(consumer, "peer"):
|
||||
raise GenerationError("invalid expected consumer")
|
||||
generation = generation_id or "gen_" + str(uuid.uuid4())
|
||||
|
|
@ -413,35 +405,9 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
producer = peer_identity(source, "desktop")
|
||||
source.commit()
|
||||
source.backup(target)
|
||||
with closing(connect_database(snapshot)) as planning_db:
|
||||
has_planning_table = planning_db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
||||
"AND name='program_session_planning'"
|
||||
).fetchone() is not None
|
||||
has_reschedule = has_planning_table and planning_db.execute(
|
||||
"SELECT 1 FROM program_session_planning plan "
|
||||
"JOIN program_sessions session ON session.program_session_id=plan.program_session_id "
|
||||
"JOIN programs program ON program.program_id=session.program_id "
|
||||
"WHERE program.deleted_at IS NULL LIMIT 1"
|
||||
).fetchone() is not None
|
||||
supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities
|
||||
# CONTRACT: one generation contains exactly one Program revision per
|
||||
# identity. A V1-only peer cannot preserve a changed current schedule.
|
||||
# INVARIANT: reject before publication rather than claiming convergence.
|
||||
skip_programs = has_reschedule and not supports_v2
|
||||
if skip_programs and not allow_program_skip:
|
||||
raise GenerationError("Programs V2 capability required for rescheduled programs")
|
||||
if supports_v2 and not has_planning_table:
|
||||
raise GenerationError("Programs V2 requires desktop schema v37")
|
||||
descriptors = []
|
||||
total = 0
|
||||
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
|
||||
if skip_programs and logical in ("programs-v1", "programs-v2"):
|
||||
continue
|
||||
if logical == "programs-v1" and supports_v2:
|
||||
continue
|
||||
if logical == "programs-v2" and not supports_v2:
|
||||
continue
|
||||
output = stage / filename
|
||||
run_export(tool, extra, output, snapshot)
|
||||
size, checksum = digest_file(output)
|
||||
|
|
@ -555,7 +521,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
|
||||
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
||||
(operation["operation_id"], generation, 0 if existed else 1))
|
||||
programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json")
|
||||
programs_path = stage / "programs-v1.json"
|
||||
if programs_path.is_file():
|
||||
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
|
||||
for deletion in captured_programs["deletions"]:
|
||||
|
|
|
|||
|
|
@ -37,11 +37,7 @@ CAPS = {
|
|||
|
||||
ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
|
||||
|
||||
# WHY: a verified personal phone can need more than 30 seconds to publish an
|
||||
# immutable Programs generation over MTP. CONTRACT: this bounds each adapter
|
||||
# call independently of the configured overall conversation deadline.
|
||||
# INVARIANT: an adapter overrun remains an ambiguous transport failure.
|
||||
MTP_OPERATION_TIMEOUT_SECONDS = 90.0
|
||||
MTP_OPERATION_TIMEOUT_SECONDS = 30.0
|
||||
MTP_POLL_INTERVAL_SECONDS = 3.0
|
||||
|
||||
|
||||
|
|
@ -831,13 +827,8 @@ def main() -> int:
|
|||
# diagnostics to the separately bounded stderr channel.
|
||||
with redirect_stdout(sys.stderr):
|
||||
stage, manifest, digest = generation.capture_desktop(
|
||||
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id,
|
||||
set(peer["capabilities"]), True,
|
||||
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
|
||||
)
|
||||
program_domain_incompatible = not any(
|
||||
artifact["logical_name"] in ("programs-v1", "programs-v2")
|
||||
for artifact in manifest["artifacts"]
|
||||
)
|
||||
del stage
|
||||
published = generation.publish(
|
||||
args.database, outgoing_id, args.transport_root / "desktop-objects"
|
||||
|
|
@ -935,14 +926,6 @@ def main() -> int:
|
|||
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
||||
raise RuntimeError("generation evidence did not corroborate completion")
|
||||
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
|
||||
if program_domain_incompatible:
|
||||
# WHY: the old peer cannot retain the new absolute schedule, but its
|
||||
# unrelated domains can still converge through the same generation.
|
||||
# CONTRACT: never report whole-run success while its Program view is stale.
|
||||
raise RuntimeError(
|
||||
"Programs V2 capability required: other domains synchronized, "
|
||||
"Program planning not published to this peer"
|
||||
)
|
||||
ai_post_sync = publish_ai_export_to_android(args, deadline)
|
||||
report = {
|
||||
"format": "trainlog-sync-worker-report",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 37
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,17 +63,4 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
|||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* WHY: original Program dates are immutable source evidence. CONTRACT: the
|
||||
* preview is read only; commit requires its exact fact/choice fingerprint,
|
||||
* current Program revision and a stable operation ID. INVARIANT: one commit
|
||||
* changes absolute planning dates in one transaction, never history rows. */
|
||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
const char *operation_id,
|
||||
const char *body,
|
||||
size_t body_size,
|
||||
bool commit,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -41,19 +41,6 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
|||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* WHY: the Sleep agenda needs factual completed sessions without fetching one
|
||||
* detail per row. CONTRACT: start/end are bounded ISO instants; intervals
|
||||
* intersect [start,end), and a session with no valid end is a point at start.
|
||||
* Pages are ordered by factual start and stable ID. No state is mutated.
|
||||
* INVARIANT: the caller owns output_json and must paginate until more=false. */
|
||||
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
|
||||
const char *start,
|
||||
const char *end,
|
||||
size_t offset,
|
||||
size_t limit,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
|
||||
* returned detail is a factual snapshot and performs no lifecycle transition. */
|
||||
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
|
||||
|
|
|
|||
|
|
@ -239,7 +239,6 @@ trainlog_core_sources = files(
|
|||
'src/web_sessions.c',
|
||||
'src/web_session_deletions.c',
|
||||
'src/web_programs.c',
|
||||
'src/web_program_reschedule.c',
|
||||
'src/web_sleep.c',
|
||||
'src/web_exercises.c',
|
||||
'src/web_equipment.c',
|
||||
|
|
|
|||
|
|
@ -1578,39 +1578,6 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
|
|||
"immutable');END;"
|
||||
"PRAGMA user_version=36;COMMIT;";
|
||||
|
||||
/* WHY: a Program's imported date is evidence, while an explicit reschedule
|
||||
* owns its current target and an optional relinquishment of a future slot.
|
||||
* CONTRACT: absent planning rows retain the original date and active state;
|
||||
* operation identities bind replay to an exact request digest. INVARIANT:
|
||||
* migration changes no execution, preparation, source date, or history row. */
|
||||
static const char *const MIGRATE_V36_TO_V37_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE IF NOT EXISTS program_session_planning("
|
||||
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
||||
"ON DELETE RESTRICT,"
|
||||
"current_for TEXT,"
|
||||
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
|
||||
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
||||
"CHECK((state='active' AND current_for IS NOT NULL) OR "
|
||||
"(state='ceded' AND current_for IS NULL)));"
|
||||
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
|
||||
"operation_id TEXT PRIMARY KEY,"
|
||||
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
||||
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
|
||||
"expected_revision TEXT NOT NULL,"
|
||||
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
||||
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
|
||||
"ON program_reschedule_operations(program_id,created_at);"
|
||||
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
|
||||
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
||||
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
|
||||
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
|
||||
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
|
||||
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
|
||||
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
|
||||
"revision_sequence=revision_sequence+1;END;"
|
||||
"PRAGMA user_version=37;COMMIT;";
|
||||
|
||||
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
||||
|
|
@ -2446,7 +2413,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
||||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
||||
version == 36 || version == 37) {
|
||||
version == 36) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2618,9 +2585,6 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
||||
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 37) {
|
||||
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -2640,7 +2604,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 v37" : "migrate database to schema v37",
|
||||
version == 0 ? "create schema v36" : "migrate database to schema v36",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -646,26 +646,26 @@ static bool add_measurements(TrainlogDatabase *database,
|
|||
static bool
|
||||
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
|
||||
static const char SQL[] =
|
||||
/* WHY: imported dates are historical after Program rescheduling.
|
||||
* CONTRACT: select one pending, non-ceded session with its current date.
|
||||
* INVARIANT: a present planning row with NULL current_for stays undated. */
|
||||
"WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position,"
|
||||
"CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END "
|
||||
"AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON "
|
||||
"pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 "
|
||||
"ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')"
|
||||
"!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted')),"
|
||||
"ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY "
|
||||
"CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position,"
|
||||
"program_session_id) AS rank FROM eligible) "
|
||||
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
|
||||
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
|
||||
"next.title,next.program_session_id,next.current_for "
|
||||
"(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON "
|
||||
"pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
|
||||
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
|
||||
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1),"
|
||||
"(SELECT ps2.program_session_id FROM program_sessions ps2 LEFT JOIN "
|
||||
"program_session_executions pe2 ON pe2.program_session_id=ps2.program_session_id WHERE "
|
||||
"ps2.program_id=p.program_id AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted') "
|
||||
"ORDER BY CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position "
|
||||
"LIMIT 1),"
|
||||
"(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions "
|
||||
"pe2 "
|
||||
"ON pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
|
||||
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
|
||||
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) "
|
||||
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
|
||||
"program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN "
|
||||
"ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE "
|
||||
"program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE "
|
||||
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
|
||||
"DESC,p.program_id "
|
||||
"DESC "
|
||||
"LIMIT 1";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *program = yyjson_mut_obj(document);
|
||||
|
|
|
|||
|
|
@ -1,660 +0,0 @@
|
|||
#include "trainlog/web_programs.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <sqlite3.h>
|
||||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
typedef struct PlanningRow {
|
||||
const char *id;
|
||||
const char *original;
|
||||
const char *current;
|
||||
const char *execution;
|
||||
const char *planning;
|
||||
const char *assigned;
|
||||
bool cede;
|
||||
} PlanningRow;
|
||||
|
||||
static TrainlogStatus
|
||||
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
|
||||
size_t length = strlen(reason) + 16U;
|
||||
*output = malloc(length);
|
||||
if (*output == NULL) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
|
||||
return status;
|
||||
}
|
||||
|
||||
static bool planning_digest(
|
||||
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
|
||||
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
||||
unsigned char digest[32];
|
||||
unsigned int length = 0U;
|
||||
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
||||
EVP_DigestUpdate(context, first, first_size) == 1 &&
|
||||
EVP_DigestUpdate(context, second, second_size) == 1 &&
|
||||
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
|
||||
EVP_MD_CTX_free(context);
|
||||
if (!okay) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index < 32U; ++index) {
|
||||
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
|
||||
}
|
||||
output[64] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool valid_civil_date(const char *value) {
|
||||
unsigned year, month, day;
|
||||
int days;
|
||||
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
|
||||
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
|
||||
month < 1U || month > 12U) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index < 10U; ++index) {
|
||||
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
||||
days = MONTH_DAYS[month - 1U];
|
||||
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
|
||||
days = 29;
|
||||
}
|
||||
return day >= 1U && day <= (unsigned)days;
|
||||
}
|
||||
|
||||
static bool
|
||||
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
|
||||
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
|
||||
: yyjson_mut_obj_add_strcpy(document, object, key, value);
|
||||
}
|
||||
|
||||
static bool request_valid(yyjson_val *root, bool commit) {
|
||||
yyjson_obj_iter iterator;
|
||||
yyjson_val *key;
|
||||
size_t count = 0U;
|
||||
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
|
||||
return false;
|
||||
}
|
||||
iterator = yyjson_obj_iter_with(root);
|
||||
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
|
||||
const char *name = yyjson_get_str(key);
|
||||
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
|
||||
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
|
||||
strcmp(name, "expected_revision") != 0 &&
|
||||
!(commit && strcmp(name, "preview_sha256") == 0)) {
|
||||
return false;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
|
||||
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
|
||||
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
|
||||
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
|
||||
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
|
||||
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
|
||||
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
|
||||
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
|
||||
}
|
||||
|
||||
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
bool present = true;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM session_preparations WHERE "
|
||||
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) == SQLITE_OK &&
|
||||
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
|
||||
present = sqlite3_step(statement) != SQLITE_DONE;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return present;
|
||||
}
|
||||
|
||||
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
|
||||
static const char SQL[] =
|
||||
"SELECT EXISTS(SELECT 1 FROM "
|
||||
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
|
||||
"WHERE producer_kind='desktop') peer WHERE "
|
||||
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
|
||||
"JOIN sync_generation_artifacts artifact "
|
||||
"ON artifact.generation_id=generation.generation_id "
|
||||
"WHERE generation.generation_id=(SELECT latest.generation_id "
|
||||
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
|
||||
"AND latest.producer_kind='desktop' "
|
||||
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
|
||||
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
|
||||
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
status =
|
||||
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus build_preview(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
yyjson_val *request,
|
||||
char **output,
|
||||
size_t *output_size) {
|
||||
char *detail = NULL;
|
||||
size_t detail_size = 0U;
|
||||
yyjson_doc *source = NULL;
|
||||
yyjson_mut_doc *response = NULL;
|
||||
yyjson_val *program;
|
||||
yyjson_val *sessions;
|
||||
yyjson_val *ceded;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *moves;
|
||||
PlanningRow rows[64] = {0};
|
||||
const char *slots[64] = {0};
|
||||
size_t first = 64U;
|
||||
size_t last = 64U;
|
||||
size_t count;
|
||||
size_t slot_count = 0U;
|
||||
size_t next_slot = 0U;
|
||||
char fingerprint[65];
|
||||
char *choices = NULL;
|
||||
size_t choices_size = 0U;
|
||||
TrainlogStatus status =
|
||||
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
|
||||
program = source == NULL ? NULL : yyjson_doc_get_root(source);
|
||||
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
|
||||
ceded = yyjson_obj_get(request, "ceded_session_ids");
|
||||
count = yyjson_arr_size(sessions);
|
||||
if (program == NULL || count > 64U ||
|
||||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
|
||||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
|
||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision")))) {
|
||||
status = planning_error(
|
||||
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < count; ++index) {
|
||||
yyjson_val *session = yyjson_arr_get(sessions, index);
|
||||
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
|
||||
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
|
||||
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
|
||||
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
|
||||
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
|
||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
|
||||
0) {
|
||||
first = index;
|
||||
}
|
||||
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
|
||||
0) {
|
||||
last = index;
|
||||
}
|
||||
}
|
||||
if (first >= count || last >= count || first > last ||
|
||||
(first != last && (!yyjson_is_str(yyjson_obj_get(program, "end_date")) ||
|
||||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
|
||||
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0))) {
|
||||
status = planning_error(
|
||||
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
||||
yyjson_val *id = yyjson_arr_get(ceded, index);
|
||||
bool matched = false;
|
||||
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
|
||||
status = planning_error(
|
||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
for (size_t candidate = first; candidate <= last; ++candidate) {
|
||||
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
|
||||
rows[candidate].cede = true;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
status = planning_error(
|
||||
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
/* WHY: calendar moves target one identity, including a formerly ceded
|
||||
* identity. CONTRACT: its requested civil day is the new current date,
|
||||
* even when another session already occupies that day. INVARIANT: no
|
||||
* other planning row is included in the resulting mutation. */
|
||||
if (first == last && yyjson_arr_size(ceded) == 0U) {
|
||||
PlanningRow *row = &rows[first];
|
||||
if (strcmp(row->execution, "todo") != 0 || has_preparation(database, row->id)) {
|
||||
status = planning_error(
|
||||
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
row->assigned = yyjson_get_str(yyjson_obj_get(request, "start_date"));
|
||||
goto assigned;
|
||||
}
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
if (strcmp(rows[index].execution, "todo") != 0 ||
|
||||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
|
||||
!valid_civil_date(rows[index].original)) {
|
||||
status = planning_error(
|
||||
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
|
||||
status = planning_error(
|
||||
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
|
||||
goto done;
|
||||
}
|
||||
// WHY: a second explicit decision starts from the durable current
|
||||
// schedule. A ceded identity has no current date, so its original slot
|
||||
// is available only when the user selects that identity again.
|
||||
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
|
||||
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
|
||||
slots[slot_count++] = slot;
|
||||
}
|
||||
}
|
||||
// Selected Program slots define permitted days; no rest day is synthesized.
|
||||
// Position order may differ from dates, so sort candidate dates explicitly.
|
||||
for (size_t outer = 0U; outer < slot_count; ++outer) {
|
||||
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
|
||||
if (strcmp(slots[inner], slots[outer]) < 0) {
|
||||
const char *swap = slots[outer];
|
||||
slots[outer] = slots[inner];
|
||||
slots[inner] = swap;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A previously ceded original day may now be occupied by an active
|
||||
// session. It remains one calendar slot for this operation.
|
||||
size_t unique_slots = 0U;
|
||||
for (size_t index = 0U; index < slot_count; ++index) {
|
||||
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
|
||||
slots[unique_slots++] = slots[index];
|
||||
}
|
||||
}
|
||||
slot_count = unique_slots;
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
if (rows[index].cede) {
|
||||
continue;
|
||||
}
|
||||
if (next_slot >= slot_count ||
|
||||
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
|
||||
status = planning_error("insufficient_or_duplicate_slots",
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
||||
output,
|
||||
output_size);
|
||||
goto done;
|
||||
}
|
||||
rows[index].assigned = slots[next_slot++];
|
||||
}
|
||||
assigned:
|
||||
// CONTRACT: the choice digest includes stable identities in their supplied
|
||||
// order. The fact digest includes the complete current Program projection.
|
||||
{
|
||||
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *choice_root =
|
||||
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
|
||||
if (choice_document == NULL || choice_root == NULL) {
|
||||
yyjson_mut_doc_free(choice_document);
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
yyjson_mut_doc_set_root(choice_document, choice_root);
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
choice_document,
|
||||
choice_root,
|
||||
"start_session_id",
|
||||
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
choice_document,
|
||||
choice_root,
|
||||
"through_session_id",
|
||||
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(choice_document,
|
||||
choice_root,
|
||||
"start_date",
|
||||
yyjson_get_str(yyjson_obj_get(request, "start_date")));
|
||||
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
|
||||
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
|
||||
(void)yyjson_mut_arr_add_strcpy(
|
||||
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
|
||||
}
|
||||
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
|
||||
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
|
||||
yyjson_mut_doc_free(choice_document);
|
||||
}
|
||||
if (choices == NULL ||
|
||||
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
response = yyjson_mut_doc_new(NULL);
|
||||
root = response == NULL ? NULL : yyjson_mut_obj(response);
|
||||
moves = response == NULL ? NULL : yyjson_mut_arr(response);
|
||||
if (response == NULL || root == NULL || moves == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
yyjson_mut_doc_set_root(response, root);
|
||||
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
|
||||
(void)yyjson_mut_obj_add_strcpy(
|
||||
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
|
||||
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
|
||||
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
|
||||
for (size_t index = first; index <= last; ++index) {
|
||||
yyjson_mut_val *move = yyjson_mut_obj(response);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
|
||||
(void)add_nullable(response, move, "original_for", rows[index].original);
|
||||
(void)add_nullable(response, move, "old_for", rows[index].current);
|
||||
(void)add_nullable(response, move, "new_for", rows[index].assigned);
|
||||
const char *change =
|
||||
rows[index].cede ? "ceded"
|
||||
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
|
||||
? "unchanged"
|
||||
: "rescheduled";
|
||||
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
|
||||
(void)yyjson_mut_arr_add_val(moves, move);
|
||||
}
|
||||
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
|
||||
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||
done:
|
||||
free(choices);
|
||||
yyjson_mut_doc_free(response);
|
||||
yyjson_doc_free(source);
|
||||
free(detail);
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus apply_preview(TrainlogDatabase *database,
|
||||
const char *operation_id,
|
||||
const char *program_id,
|
||||
const char *expected_revision,
|
||||
const char *request_digest,
|
||||
const char *preview,
|
||||
char **output,
|
||||
size_t *output_size) {
|
||||
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
|
||||
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
|
||||
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char revision[80];
|
||||
char now[32];
|
||||
time_t clock_now = time(NULL);
|
||||
struct tm utc;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
|
||||
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
|
||||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
|
||||
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
status = TRAINLOG_STATUS_CONFLICT;
|
||||
goto done;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO program_session_planning "
|
||||
"(program_session_id,current_for,state,operation_id,updated_at) "
|
||||
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
|
||||
"current_for=excluded.current_for,state=excluded.state,"
|
||||
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK) {
|
||||
goto done;
|
||||
}
|
||||
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
|
||||
yyjson_val *move = yyjson_arr_get(moves, index);
|
||||
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
|
||||
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
|
||||
continue;
|
||||
}
|
||||
sqlite3_reset(statement);
|
||||
sqlite3_clear_bindings(statement);
|
||||
if (sqlite3_bind_text(statement,
|
||||
1,
|
||||
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
|
||||
-1,
|
||||
SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
(yyjson_is_null(new_for)
|
||||
? sqlite3_bind_null(statement, 2)
|
||||
: sqlite3_bind_text(
|
||||
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement,
|
||||
3,
|
||||
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
|
||||
: "active",
|
||||
-1,
|
||||
SQLITE_STATIC) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
{
|
||||
size_t length = strlen(preview) + strlen(revision) + 52U;
|
||||
*output = malloc(length);
|
||||
if (*output == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto done;
|
||||
}
|
||||
*output_size = (size_t)snprintf(*output,
|
||||
length,
|
||||
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
|
||||
(int)strlen(preview) - 1,
|
||||
preview,
|
||||
revision);
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
goto done;
|
||||
}
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
done:
|
||||
(void)sqlite3_finalize(statement);
|
||||
yyjson_doc_free(document);
|
||||
return status;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
|
||||
const char *program_id,
|
||||
const char *operation_id,
|
||||
const char *body,
|
||||
size_t body_size,
|
||||
bool commit,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
yyjson_doc *request_doc = NULL;
|
||||
yyjson_val *request;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char request_digest[65];
|
||||
char *preview = NULL;
|
||||
size_t preview_size = 0U;
|
||||
TrainlogStatus status;
|
||||
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
|
||||
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
|
||||
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
|
||||
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
|
||||
if (!request_valid(request, commit) ||
|
||||
!planning_digest(body, body_size, "", 0U, request_digest)) {
|
||||
yyjson_doc_free(request_doc);
|
||||
return planning_error("invalid_reschedule_request",
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
if (sqlite3_exec(
|
||||
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
|
||||
SQLITE_OK) {
|
||||
yyjson_doc_free(request_doc);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (commit) {
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT program_id,request_sha256,response_json FROM "
|
||||
"program_reschedule_operations WHERE operation_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
|
||||
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
|
||||
const char *saved = (const char *)sqlite3_column_text(statement, 2);
|
||||
if (known_program != NULL && known_digest != NULL && saved != NULL &&
|
||||
strcmp(known_program, program_id) == 0 &&
|
||||
strcmp(known_digest, request_digest) == 0) {
|
||||
*output_json = strdup(saved);
|
||||
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
|
||||
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
status = planning_error("operation_identity_conflict",
|
||||
TRAINLOG_STATUS_CONFLICT,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_requests WHERE request_id=? "
|
||||
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
int cross_command = sqlite3_step(statement);
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
if (cross_command != SQLITE_DONE) {
|
||||
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
|
||||
TRAINLOG_STATUS_CONFLICT,
|
||||
output_json,
|
||||
output_size)
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
// WHY: the desktop's last imported draft snapshot cannot prove that
|
||||
// Android stayed idle while offline. A recent acknowledged V2 Program
|
||||
// generation is required; Android independently rejects a changed
|
||||
// schedule if a Program draft starts after that acknowledgement.
|
||||
status = peer_planning_fresh(database);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
}
|
||||
status = build_preview(database, program_id, request, &preview, &preview_size);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
if (preview != NULL) {
|
||||
*output_json = preview;
|
||||
*output_size = preview_size;
|
||||
preview = NULL;
|
||||
}
|
||||
goto finish;
|
||||
}
|
||||
if (!commit) {
|
||||
*output_json = preview;
|
||||
*output_size = preview_size;
|
||||
preview = NULL;
|
||||
goto finish;
|
||||
}
|
||||
{
|
||||
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
|
||||
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
|
||||
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
|
||||
if (fingerprint == NULL ||
|
||||
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
|
||||
status = planning_error(
|
||||
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
|
||||
} else {
|
||||
status = apply_preview(database,
|
||||
operation_id,
|
||||
program_id,
|
||||
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
|
||||
request_digest,
|
||||
preview,
|
||||
output_json,
|
||||
output_size);
|
||||
}
|
||||
yyjson_doc_free(preview_doc);
|
||||
}
|
||||
finish:
|
||||
(void)sqlite3_finalize(statement);
|
||||
free(preview);
|
||||
yyjson_doc_free(request_doc);
|
||||
if (status == TRAINLOG_STATUS_OK && commit) {
|
||||
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||
return status;
|
||||
}
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
|
||||
return planning_error("reschedule_unavailable", status, output_json, output_size);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
|
@ -365,12 +365,10 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
|
|||
!add_column_text(document, session, "title", statement, 2) ||
|
||||
!add_column_text(document, session, "session_type", statement, 3) ||
|
||||
!add_column_text(document, session, "planned_for", statement, 4) ||
|
||||
!add_column_text(document, session, "current_for", statement, 5) ||
|
||||
!add_column_text(document, session, "planning_state", statement, 6) ||
|
||||
!add_column_text(document, session, "note", statement, 7) ||
|
||||
!add_column_text(document, session, "execution_state", statement, 8) ||
|
||||
!add_column_text(document, session, "execution_session_id", statement, 9) ||
|
||||
!add_column_text(document, session, "execution_started_at", statement, 10) ||
|
||||
!add_column_text(document, session, "note", statement, 5) ||
|
||||
!add_column_text(document, session, "execution_state", statement, 6) ||
|
||||
!add_column_text(document, session, "execution_session_id", statement, 7) ||
|
||||
!add_column_text(document, session, "execution_started_at", statement, 8) ||
|
||||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
|
||||
!yyjson_mut_arr_add_val(sessions, session)) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
|
|
@ -385,9 +383,7 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
|||
yyjson_mut_val *sessions) {
|
||||
static const char SESSION_SQL[] =
|
||||
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
|
||||
"CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END,"
|
||||
"COALESCE(pp.state,'active'),ps.note,"
|
||||
"CASE WHEN pe.state='completed' THEN 'completed' "
|
||||
"ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
|
||||
"WHEN pe.state='in_progress' THEN 'in_progress' "
|
||||
"WHEN pe.state='deleted' THEN 'deleted' "
|
||||
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
|
||||
|
|
@ -398,9 +394,7 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
|||
* CONTRACT: expose that timestamp only for completed executions.
|
||||
* INVARIANT: the imported planned_for date and history are read only. */
|
||||
"CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END "
|
||||
"FROM program_sessions ps LEFT JOIN program_session_planning pp "
|
||||
"ON pp.program_session_id=ps.program_session_id "
|
||||
"LEFT JOIN program_session_executions pe "
|
||||
"FROM program_sessions ps LEFT JOIN program_session_executions pe "
|
||||
"ON pe.program_session_id=ps.program_session_id "
|
||||
"LEFT JOIN sessions actual ON actual.session_id=pe.session_id "
|
||||
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
|
||||
|
|
@ -1171,25 +1165,7 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
|
|||
result = SQLITE_OK;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
if (result != SQLITE_OK || *found) {
|
||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_reschedule_operations WHERE operation_id=?",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT
|
||||
: result == SQLITE_DONE ? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
|
||||
|
|
@ -1381,9 +1357,7 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
|
|||
}
|
||||
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM program_requests WHERE request_id=?1 "
|
||||
"UNION ALL SELECT 1 FROM program_reschedule_operations "
|
||||
"WHERE operation_id=?1",
|
||||
"SELECT 1 FROM program_requests WHERE request_id=?1",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
|
|
@ -1635,7 +1609,7 @@ static char *program_build_preparation_body(yyjson_val *session,
|
|||
command,
|
||||
root,
|
||||
"planned_for",
|
||||
yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for")));
|
||||
yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for")));
|
||||
(void)yyjson_mut_obj_add_val(
|
||||
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
|
||||
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
|
||||
|
|
@ -1678,10 +1652,6 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
|||
yyjson_doc_free(document);
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) {
|
||||
yyjson_doc_free(document);
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
|
||||
yyjson_doc_free(document);
|
||||
if (body == NULL) {
|
||||
|
|
|
|||
|
|
@ -705,22 +705,14 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti
|
|||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
enum MHD_Result result =
|
||||
queue_json(connection,
|
||||
MHD_HTTP_CONFLICT,
|
||||
json == NULL ? "{\"error\":\"program_conflict\"}\n" : json,
|
||||
NULL);
|
||||
free(json);
|
||||
return result;
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||
enum MHD_Result result =
|
||||
queue_json(connection,
|
||||
MHD_HTTP_UNPROCESSABLE_CONTENT,
|
||||
json == NULL ? "{\"error\":\"invalid_program\"}\n" : json,
|
||||
NULL);
|
||||
free(json);
|
||||
return result;
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
|
||||
}
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
|
|
@ -938,8 +930,6 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
|||
static const char PREFIX[] = "/api/v1/sessions/program/";
|
||||
static const char ARCHIVE_SUFFIX[] = "/archive";
|
||||
static const char DELETE_SUFFIX[] = "/delete";
|
||||
static const char PREVIEW_SUFFIX[] = "/reschedule/preview";
|
||||
static const char APPLY_SUFFIX[] = "/reschedule/apply";
|
||||
const char *identity;
|
||||
const char *sessions_marker;
|
||||
char *json = NULL;
|
||||
|
|
@ -960,38 +950,6 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
|||
identity = url + strlen(PREFIX);
|
||||
identity_length = strlen(identity);
|
||||
sessions_marker = strstr(identity, "/sessions/");
|
||||
if ((identity_length > strlen(PREVIEW_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) ||
|
||||
(identity_length > strlen(APPLY_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) {
|
||||
bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0;
|
||||
size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX);
|
||||
char program_id[TRAINLOG_ID_MAX + 1U];
|
||||
const char *operation_id =
|
||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"POST");
|
||||
}
|
||||
if (!program_mutation_allowed(context, connection) ||
|
||||
!copy_path_component(
|
||||
program_id, sizeof(program_id), identity, identity_length - suffix_length) ||
|
||||
(commit && (operation_id == NULL || operation_id[0] == '\0'))) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||
}
|
||||
status = trainlog_web_programs_reschedule_json(context->database,
|
||||
program_id,
|
||||
operation_id,
|
||||
request->body,
|
||||
request->body_size,
|
||||
commit,
|
||||
&json,
|
||||
&json_size);
|
||||
return queue_owned_programs_json(connection, status, json, json_size);
|
||||
}
|
||||
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
|
||||
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
||||
return handle_program_archive(context, connection, identity, identity_length, method);
|
||||
|
|
@ -1935,14 +1893,6 @@ static enum MHD_Result handle_request(void *closure,
|
|||
context->database, search, offset, limit, &json, &json_size);
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
if (strcmp(url, "/api/v1/sessions/sport") == 0) {
|
||||
const char *start =
|
||||
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start");
|
||||
const char *end = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end");
|
||||
TrainlogStatus status = trainlog_web_sessions_sport_json(
|
||||
context->database, start, end, offset, limit, &json, &json_size);
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
|
||||
if (strcmp(url, collections[index].path) == 0) {
|
||||
TrainlogWebSessionsPageQuery query = {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "timestamp.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
typedef struct ListDefinition {
|
||||
|
|
@ -96,117 +95,6 @@ write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size
|
|||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
|
||||
const char *start,
|
||||
const char *end,
|
||||
size_t offset,
|
||||
size_t limit,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
static const char SQL[] =
|
||||
"SELECT s.session_id,s.session_type,s.started_at,s.ended_at,"
|
||||
"COALESCE((SELECT group_concat(name, ', ') FROM (SELECT DISTINCT x.name AS name FROM "
|
||||
"session_exercises se JOIN exercises x ON x.id=se.exercise_row_id WHERE "
|
||||
"se.session_row_id=s.id ORDER BY se.position LIMIT 8)),s.session_type) "
|
||||
"FROM sessions s WHERE unixepoch(s.started_at) IS NOT NULL AND "
|
||||
"unixepoch(s.started_at)<unixepoch(?2) AND "
|
||||
"(CASE WHEN unixepoch(s.ended_at)>unixepoch(s.started_at) "
|
||||
"THEN unixepoch(s.ended_at)>unixepoch(?1) ELSE unixepoch(s.started_at)>=unixepoch(?1) "
|
||||
"END) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
|
||||
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=1) "
|
||||
"ORDER BY unixepoch(s.started_at),s.session_id LIMIT ?3 OFFSET ?4";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_doc *document = NULL;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *items;
|
||||
TrainlogStatus status;
|
||||
TrainlogStatus end_status;
|
||||
size_t count = 0U;
|
||||
int step = SQLITE_DONE;
|
||||
TrainlogTimestampKey start_key;
|
||||
TrainlogTimestampKey end_key;
|
||||
|
||||
if (database == NULL || start == NULL || end == NULL || output_json == NULL ||
|
||||
output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX ||
|
||||
offset > INT64_MAX || strlen(start) > 40U || strlen(end) > 40U ||
|
||||
!trainlog_timestamp_parse(start, strlen(start), &start_key) ||
|
||||
!trainlog_timestamp_parse(end, strlen(end), &end_key) ||
|
||||
trainlog_timestamp_compare(&start_key, &end_key) >= 0) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, start, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, end, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U)) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 4, (sqlite3_int64)offset) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
document = yyjson_mut_doc_new(NULL);
|
||||
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
items = document == NULL ? NULL : yyjson_mut_arr(document);
|
||||
if (root == NULL || items == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||
!yyjson_mut_obj_add_uint(document, root, "offset", offset) ||
|
||||
!yyjson_mut_obj_add_val(document, root, "items", items)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *item;
|
||||
if (count == limit) {
|
||||
++count;
|
||||
break;
|
||||
}
|
||||
item = yyjson_mut_obj(document);
|
||||
if (item == NULL || !add_nullable_text(document, item, "identity", statement, 0) ||
|
||||
!add_nullable_text(document, item, "session_type", statement, 1) ||
|
||||
!add_nullable_text(document, item, "started_at", statement, 2) ||
|
||||
!add_nullable_text(document, item, "ended_at", statement, 3) ||
|
||||
!add_nullable_text(document, item, "label", statement, 4) ||
|
||||
!yyjson_mut_arr_add_val(items, item)) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
if (step != SQLITE_DONE && count <= limit) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
if (!yyjson_mut_obj_add_bool(document, root, "more", count > limit) ||
|
||||
!yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish_sport;
|
||||
}
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
|
||||
finish_sport:
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||
status == TRAINLOG_STATUS_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = end_status;
|
||||
}
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return status;
|
||||
}
|
||||
return write_document(document, output_json, output_size);
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
||||
const TrainlogWebSessionsPageQuery *query,
|
||||
char **output_json,
|
||||
|
|
|
|||
|
|
@ -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 v37: SQLite rc=",
|
||||
strlen("migrate database to schema v37: SQLite rc=")) == 0);
|
||||
"migrate database to schema v36: SQLite rc=",
|
||||
strlen("migrate database to schema v36: 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=38;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", 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 38 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -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 v37") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -225,8 +225,7 @@ static bool test_factual_models(void) {
|
|||
{
|
||||
yyjson_val *program = yyjson_obj_get(root, "active_program");
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") ==
|
||||
0);
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == 0);
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
|
||||
"2026-09-21") == 0);
|
||||
}
|
||||
|
|
@ -251,123 +250,6 @@ static bool test_factual_models(void) {
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool check_next_session(TrainlogDatabase *database,
|
||||
const char *expected_id,
|
||||
const char *expected_title,
|
||||
const char *expected_date) {
|
||||
yyjson_doc *document = NULL;
|
||||
yyjson_val *program;
|
||||
yyjson_val *id;
|
||||
yyjson_val *title;
|
||||
yyjson_val *date;
|
||||
|
||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
||||
program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program");
|
||||
CHECK(!yyjson_is_null(program));
|
||||
id = yyjson_obj_get(program, "next_session_id");
|
||||
title = yyjson_obj_get(program, "next_session_title");
|
||||
date = yyjson_obj_get(program, "next_session_planned_for");
|
||||
CHECK(expected_id == NULL ? yyjson_is_null(id)
|
||||
: yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0);
|
||||
CHECK(expected_title == NULL
|
||||
? yyjson_is_null(title)
|
||||
: yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0);
|
||||
CHECK(expected_date == NULL
|
||||
? yyjson_is_null(date)
|
||||
: yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0);
|
||||
yyjson_doc_free(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_current_program_plan(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
yyjson_doc *document = NULL;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
/* WHY: source dates remain historical; current planning and execution are
|
||||
* independent facts. These rows exercise their precedence without touching
|
||||
* any user database or invoking the rescheduling command. */
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL,"
|
||||
"NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
|
||||
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||
"NULL);"
|
||||
"INSERT INTO program_sessions VALUES"
|
||||
"('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL),"
|
||||
"('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL),"
|
||||
"('ps_later','pg_current',2,'Later','training','2026-10-03',NULL),"
|
||||
"('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL),"
|
||||
"('ps_undated','pg_current',4,'Undated','training',NULL,NULL);",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29"));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_planning VALUES"
|
||||
"('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z'),"
|
||||
"('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES"
|
||||
"('ps_s2c','pg_current','se_fixture_1','in_progress',"
|
||||
"'2026-10-02T12:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE program_session_executions SET state='completed' "
|
||||
"WHERE program_session_id='ps_s2c';",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02"));
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES"
|
||||
"('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z'),"
|
||||
"('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, "ps_undated", "Undated", NULL));
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES"
|
||||
"('ps_undated','pg_current','se_fixture_4','completed',"
|
||||
"'2026-10-04T12:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(check_next_session(database, NULL, NULL, NULL));
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE programs SET state='archived' WHERE program_id='pg_current';",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
||||
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
||||
yyjson_doc_free(document);
|
||||
document = NULL;
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' "
|
||||
"WHERE program_id='pg_current';",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
||||
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
||||
yyjson_doc_free(document);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1;
|
||||
return test_empty_and_periods() && test_factual_models() ? 0 : 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -608,447 +608,10 @@ static bool legacy_integer_weight_zero_prepares_editable_draft(void) {
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool ordered_reschedule_is_atomic_and_idempotent(void) {
|
||||
static const char *const DATES[] = {
|
||||
"2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24", "2026-09-25", "2026-09-26",
|
||||
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03",
|
||||
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", "2026-10-10",
|
||||
"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17",
|
||||
};
|
||||
static const char REQUEST[] = "{\"start_session_id\":\"pgs_fixture_07\","
|
||||
"\"through_session_id\":\"pgs_fixture_22\","
|
||||
"\"start_date\":\"2026-10-01\","
|
||||
"\"ceded_session_ids\":[\"pgs_fixture_11\",\"pgs_fixture_17\"],"
|
||||
"\"expected_revision\":\"pgr_fixture\"}";
|
||||
TrainlogDatabase *database = NULL;
|
||||
char *preview = NULL;
|
||||
char *applied = NULL;
|
||||
char *replayed = NULL;
|
||||
char *request = NULL;
|
||||
size_t size = 0U;
|
||||
size_t applied_size = 0U;
|
||||
char fingerprint[80];
|
||||
char sql[1024];
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
|
||||
"created_at,updated_at,revision_id,source_format,source_version,"
|
||||
"source_payload_sha256) VALUES('pg_fixture','Fixture','active',"
|
||||
"'2026-09-21','2026-10-18','2026-09-20T00:00:00Z',"
|
||||
"'2026-09-20T00:00:00Z','pgr_fixture','trainlog-program',1,"
|
||||
"'0000000000000000000000000000000000000000000000000000000000000000')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
for (size_t index = 0U; index < 24U; ++index) {
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"INSERT INTO program_sessions VALUES('pgs_fixture_%02zu','pg_fixture',"
|
||||
"%zu,'Session %zu','training','%s',NULL)",
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
DATES[index]);
|
||||
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
|
||||
if (index < 7U) {
|
||||
const char *actual = index == 5U ? "2026-09-28T09:00:00+02:00"
|
||||
: index == 6U ? "2026-09-30T17:00:00+02:00"
|
||||
: "2026-09-21T09:00:00+02:00";
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"INSERT INTO sessions(session_id,started_at) "
|
||||
"VALUES('se_fixture_%02zu','%s')",
|
||||
index,
|
||||
actual);
|
||||
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"INSERT INTO program_session_executions VALUES("
|
||||
"'pgs_fixture_%02zu','pg_fixture','se_fixture_%02zu',"
|
||||
"'completed','2026-09-30T00:00:00Z')",
|
||||
index,
|
||||
index);
|
||||
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
|
||||
}
|
||||
}
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO sessions(session_id,started_at) "
|
||||
"VALUES('se_evening_stretch','2026-10-01T20:00:00+02:00')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
char *invalid_date = replace_once(REQUEST, "2026-10-01", "2026-02-30");
|
||||
CHECK(invalid_date != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
NULL,
|
||||
invalid_date,
|
||||
strlen(invalid_date),
|
||||
false,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
free(invalid_date);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *too_late = replace_once(REQUEST, "2026-10-01", "2026-10-16");
|
||||
CHECK(too_late != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(
|
||||
database, "pg_fixture", NULL, too_late, strlen(too_late), false, &replayed, &size) ==
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(strstr(replayed, "insufficient_or_duplicate_slots") != NULL);
|
||||
free(too_late);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
// The DST transition on October 25 is a rest day in this fixture.
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
|
||||
"created_at,updated_at,revision_id,source_format,source_version,"
|
||||
"source_payload_sha256) VALUES('pg_dst','DST','active','2026-10-24',"
|
||||
"'2026-10-27','2026-10-20T00:00:00Z','2026-10-20T00:00:00Z',"
|
||||
"'pgr_dst','trainlog-program',1,"
|
||||
"'0000000000000000000000000000000000000000000000000000000000000000');"
|
||||
"INSERT INTO program_sessions VALUES"
|
||||
"('pgs_dst_0','pg_dst',0,'Before','training','2026-10-24',NULL),"
|
||||
"('pgs_dst_1','pg_dst',1,'After','training','2026-10-26',NULL),"
|
||||
"('pgs_dst_2','pg_dst',2,'Next','training','2026-10-27',NULL)",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
static const char DST_REQUEST[] =
|
||||
"{\"start_session_id\":\"pgs_dst_0\",\"through_session_id\":\"pgs_dst_2\","
|
||||
"\"start_date\":\"2026-10-24\",\"ceded_session_ids\":[],"
|
||||
"\"expected_revision\":\"pgr_dst\"}";
|
||||
CHECK(
|
||||
trainlog_web_programs_reschedule_json(
|
||||
database, "pg_dst", NULL, DST_REQUEST, strlen(DST_REQUEST), false, &replayed, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(replayed, "\"new_for\":\"2026-10-26\"") != NULL);
|
||||
CHECK(strstr(replayed, "2026-10-25") == NULL);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
CHECK(trainlog_web_programs_reschedule_json(
|
||||
database, "pg_fixture", NULL, REQUEST, strlen(REQUEST), false, &preview, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint)));
|
||||
CHECK(strstr(preview,
|
||||
"\"program_session_id\":\"pgs_fixture_07\","
|
||||
"\"original_for\":\"2026-09-29\","
|
||||
"\"old_for\":\"2026-09-29\","
|
||||
"\"new_for\":\"2026-10-01\"") != NULL);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
|
||||
request = malloc(strlen(REQUEST) + strlen(fingerprint) + 32U);
|
||||
CHECK(request != NULL);
|
||||
(void)snprintf(request,
|
||||
strlen(REQUEST) + strlen(fingerprint) + 32U,
|
||||
"%.*s,\"preview_sha256\":\"%s\"}",
|
||||
(int)strlen(REQUEST) - 1,
|
||||
REQUEST,
|
||||
fingerprint);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO sync_generations(generation_id,run_id,producer_peer_id,"
|
||||
"consumer_peer_id,producer_kind,generated_at,manifest_sha256,status,"
|
||||
"manifest_json,staging_path) VALUES('gen_fixture','sy_fixture',"
|
||||
"'peer_desktop','peer_phone','desktop',"
|
||||
"strftime('%Y-%m-%dT%H:%M:%SZ','now'),'digest','captured','{}','/tmp')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO sync_generation_artifacts VALUES('gen_fixture','programs-v2',"
|
||||
"'trainlog-programs',2,'programs-v2.json',0,'digest',0)",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(strstr(replayed, "program_peer_not_fresh") != NULL);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"UPDATE sync_generations SET status='acknowledged',"
|
||||
"acknowledged_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
|
||||
"WHERE generation_id='gen_fixture'",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&applied,
|
||||
&applied_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 11);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning WHERE state='ceded'") ==
|
||||
2);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(DISTINCT current_for) FROM program_session_planning "
|
||||
"WHERE state='active'") == 9);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_executions "
|
||||
"WHERE state='completed'") == 7);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM sessions WHERE "
|
||||
"session_id='se_fixture_05' AND started_at='2026-09-28T09:00:00+02:00'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM sessions WHERE "
|
||||
"session_id='se_fixture_06' AND started_at='2026-09-30T17:00:00+02:00'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM sessions WHERE "
|
||||
"session_id='se_evening_stretch' AND started_at='2026-10-01T20:00:00+02:00'") ==
|
||||
1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_sessions WHERE "
|
||||
"planned_for='2026-09-29' AND program_session_id='pgs_fixture_07'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_planning WHERE "
|
||||
"program_session_id='pgs_fixture_09' AND current_for='2026-10-03'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_planning WHERE "
|
||||
"program_session_id='pgs_fixture_22'") == 0);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(applied, replayed) == 0);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *different_request = replace_once(request, "2026-10-01", "2026-10-02");
|
||||
CHECK(different_request != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_fixture",
|
||||
different_request,
|
||||
strlen(different_request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_CONFLICT);
|
||||
free(different_request);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char new_revision[80];
|
||||
char next_fingerprint[80];
|
||||
CHECK(extract_string(applied, "new_revision_id", new_revision, sizeof(new_revision)));
|
||||
CHECK(scalar(database,
|
||||
"SELECT revision_sequence FROM program_revision_sequences "
|
||||
"WHERE program_id='pg_fixture'") == 1);
|
||||
char second_choice[512];
|
||||
(void)snprintf(second_choice,
|
||||
sizeof(second_choice),
|
||||
"{\"start_session_id\":\"pgs_fixture_07\","
|
||||
"\"through_session_id\":\"pgs_fixture_09\","
|
||||
"\"start_date\":\"2026-10-01\",\"ceded_session_ids\":[],"
|
||||
"\"expected_revision\":\"%s\"}",
|
||||
new_revision);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
NULL,
|
||||
second_choice,
|
||||
strlen(second_choice),
|
||||
false,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(replayed,
|
||||
"\"program_session_id\":\"pgs_fixture_09\","
|
||||
"\"original_for\":\"2026-10-01\","
|
||||
"\"old_for\":\"2026-10-03\","
|
||||
"\"new_for\":\"2026-10-03\"") != NULL);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *next_choice = replace_once(REQUEST, "pgr_fixture", new_revision);
|
||||
CHECK(next_choice != NULL);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
NULL,
|
||||
next_choice,
|
||||
strlen(next_choice),
|
||||
false,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(extract_string(replayed, "preview_sha256", next_fingerprint, sizeof(next_fingerprint)));
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
char *next_request = malloc(strlen(next_choice) + strlen(next_fingerprint) + 32U);
|
||||
CHECK(next_request != NULL);
|
||||
(void)snprintf(next_request,
|
||||
strlen(next_choice) + strlen(next_fingerprint) + 32U,
|
||||
"%.*s,\"preview_sha256\":\"%s\"}",
|
||||
(int)strlen(next_choice) - 1,
|
||||
next_choice,
|
||||
next_fingerprint);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"CREATE TRIGGER fail_reschedule_fixture BEFORE INSERT ON "
|
||||
"program_reschedule_operations WHEN NEW.operation_id='op_failure' "
|
||||
"BEGIN SELECT RAISE(ABORT,'fixture failure'); END",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_failure",
|
||||
next_request,
|
||||
strlen(next_request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
free(replayed);
|
||||
replayed = NULL;
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
|
||||
(void)snprintf(sql,
|
||||
sizeof(sql),
|
||||
"SELECT COUNT(*) FROM programs WHERE program_id='pg_fixture' "
|
||||
"AND revision_id='%s'",
|
||||
new_revision);
|
||||
CHECK(scalar(database, sql) == 1);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions VALUES("
|
||||
"'pgs_fixture_07','pg_fixture','se_late','completed',"
|
||||
"'2026-10-01T10:00:00Z')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_fixture",
|
||||
"op_stale",
|
||||
next_request,
|
||||
strlen(next_request),
|
||||
true,
|
||||
&replayed,
|
||||
&size) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(strstr(replayed, "prepared_or_executed_session") != NULL);
|
||||
free(replayed);
|
||||
free(next_request);
|
||||
free(next_choice);
|
||||
free(applied);
|
||||
free(preview);
|
||||
free(request);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool calendar_move_preserves_other_sessions_and_restores_ceded(void) {
|
||||
/* CONTRACT: an occupied target and a legacy ceded optional identity are
|
||||
* independent Program sessions. The repeated command proves durable
|
||||
* idempotence without changing the imported definitions. */
|
||||
TrainlogDatabase *database = NULL;
|
||||
char *preview = NULL;
|
||||
char *applied = NULL;
|
||||
char *replay = NULL;
|
||||
char *request = NULL;
|
||||
size_t size = 0U;
|
||||
char fingerprint[80];
|
||||
char revision[80] = "pgr_calendar";
|
||||
static const char *const TARGETS[] = {"2026-10-04", "2026-10-03", "2026-10-10"};
|
||||
static const char *const IDENTITIES[] = {"pgs_main", "pgs_main", "pgs_optional"};
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
|
||||
"created_at,updated_at,revision_id,source_format,source_version,"
|
||||
"source_payload_sha256) VALUES('pg_calendar','Fixture','active',"
|
||||
"'2026-10-01','2026-10-18','2026-10-01T00:00:00Z',"
|
||||
"'2026-10-01T00:00:00Z','pgr_calendar','trainlog-program',1,"
|
||||
"'0000000000000000000000000000000000000000000000000000000000000000');"
|
||||
"INSERT INTO program_sessions VALUES"
|
||||
"('pgs_main','pg_calendar',0,'S2 D','training','2026-10-01',NULL),"
|
||||
"('pgs_other','pg_calendar',1,'S2 E','training','2026-10-03',NULL),"
|
||||
"('pgs_optional','pg_calendar',2,'S2 F FACULTATIVE','training',"
|
||||
"'2026-10-10',NULL);"
|
||||
"INSERT INTO program_session_planning(program_session_id,current_for,"
|
||||
"state,operation_id,updated_at) VALUES"
|
||||
"('pgs_optional',NULL,'ceded','pr_old','2026-10-01T00:00:00Z')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
for (size_t index = 0U; index < 3U; ++index) {
|
||||
char choice[512];
|
||||
char operation_id[48];
|
||||
(void)snprintf(operation_id, sizeof(operation_id), "pr_calendar_move_%zu", index);
|
||||
(void)snprintf(choice,
|
||||
sizeof(choice),
|
||||
"{\"start_session_id\":\"%s\",\"through_session_id\":\"%s\","
|
||||
"\"start_date\":\"%s\",\"ceded_session_ids\":[],"
|
||||
"\"expected_revision\":\"%s\"}",
|
||||
IDENTITIES[index],
|
||||
IDENTITIES[index],
|
||||
TARGETS[index],
|
||||
revision);
|
||||
CHECK(trainlog_web_programs_reschedule_json(
|
||||
database, "pg_calendar", NULL, choice, strlen(choice), false, &preview, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint)));
|
||||
CHECK(strstr(preview, "\"change\":\"ceded\"") == NULL);
|
||||
request = malloc(strlen(choice) + strlen(fingerprint) + 32U);
|
||||
CHECK(request != NULL);
|
||||
(void)snprintf(request,
|
||||
strlen(choice) + strlen(fingerprint) + 32U,
|
||||
"%.*s,\"preview_sha256\":\"%s\"}",
|
||||
(int)strlen(choice) - 1,
|
||||
choice,
|
||||
fingerprint);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_calendar",
|
||||
operation_id,
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&applied,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_web_programs_reschedule_json(database,
|
||||
"pg_calendar",
|
||||
operation_id,
|
||||
request,
|
||||
strlen(request),
|
||||
true,
|
||||
&replay,
|
||||
&size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(applied, replay) == 0);
|
||||
CHECK(extract_string(applied, "new_revision_id", revision, sizeof(revision)));
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_sessions WHERE "
|
||||
"program_id='pg_calendar'") == 3);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_planning WHERE "
|
||||
"state='ceded'") == (index < 2U ? 1 : 0));
|
||||
free(request);
|
||||
free(preview);
|
||||
free(applied);
|
||||
free(replay);
|
||||
request = NULL;
|
||||
preview = NULL;
|
||||
applied = NULL;
|
||||
replay = NULL;
|
||||
}
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_session_planning WHERE "
|
||||
"current_for='2026-10-03'") == 1);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM program_sessions WHERE "
|
||||
"planned_for='2026-10-03'") == 1);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return import_archive_and_prepare() && strict_import_rejections() &&
|
||||
mixed_profile_prepare_regression() &&
|
||||
legacy_integer_weight_zero_prepares_editable_draft() &&
|
||||
ordered_reschedule_is_atomic_and_idempotent() &&
|
||||
calendar_move_preserves_other_sessions_and_restores_ceded()
|
||||
legacy_integer_weight_zero_prepares_editable_draft()
|
||||
? EXIT_SUCCESS
|
||||
: EXIT_FAILURE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -438,59 +438,9 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool sport_history_intersection_and_pagination(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
char *page = NULL;
|
||||
size_t size = 0U;
|
||||
const char *start = "2026-10-01T00:00:00Z";
|
||||
const char *end = "2026-10-02T00:00:00Z";
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO sessions(session_id,session_type,started_at,ended_at) VALUES"
|
||||
"('se_before','training','2026-09-30T23:00:00Z','2026-10-01T01:00:00Z'),"
|
||||
"('se_inside','training','2026-10-01T04:00:00Z','2026-10-01T05:00:00Z'),"
|
||||
"('se_point','max_test','2026-10-01T08:00:00Z',NULL),"
|
||||
"('se_after','training','2026-10-02T00:00:00Z','2026-10-02T01:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_sessions_sport_json(database, start, end, 0U, 1U, &page, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(page, "se_before") != NULL);
|
||||
CHECK(strstr(page, "se_inside") == NULL);
|
||||
CHECK(strstr(page, "\"more\":true") != NULL);
|
||||
free(page);
|
||||
page = NULL;
|
||||
CHECK(trainlog_web_sessions_sport_json(database, start, end, 1U, 2U, &page, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(page, "se_inside") != NULL);
|
||||
CHECK(strstr(page, "se_point") != NULL);
|
||||
CHECK(strstr(page, "se_after") == NULL);
|
||||
CHECK(strstr(page, "\"more\":false") != NULL);
|
||||
free(page);
|
||||
page = NULL;
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"DELETE FROM sessions WHERE session_id='se_inside'",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_sessions_sport_json(database, start, end, 0U, 64U, &page, &size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(page, "se_inside") == NULL);
|
||||
CHECK(strstr(page, "se_before") != NULL);
|
||||
free(page);
|
||||
page = NULL;
|
||||
CHECK(trainlog_web_sessions_sport_json(database, "invalid", end, 0U, 64U, &page, &size) ==
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return creation_replay_revision_and_pagination() &&
|
||||
withdrawal_is_durable_and_preserves_evidence() &&
|
||||
sport_history_intersection_and_pagination()
|
||||
withdrawal_is_durable_and_preserves_evidence()
|
||||
? 0
|
||||
: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ export interface ProgramSession {
|
|||
title: string
|
||||
session_type: 'training' | 'max_test'
|
||||
planned_for: string | null
|
||||
current_for: string | null
|
||||
planning_state: 'active' | 'ceded'
|
||||
note: string | null
|
||||
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted'
|
||||
execution_session_id: string | null
|
||||
|
|
@ -64,30 +62,6 @@ export interface ProgramDetail {
|
|||
sessions: ProgramSession[]
|
||||
}
|
||||
|
||||
export interface RescheduleChoice {
|
||||
start_session_id: string
|
||||
through_session_id: string
|
||||
start_date: string
|
||||
ceded_session_ids: string[]
|
||||
expected_revision: string
|
||||
}
|
||||
|
||||
export interface RescheduleMove {
|
||||
program_session_id: string
|
||||
original_for: string | null
|
||||
old_for: string | null
|
||||
new_for: string | null
|
||||
change: 'rescheduled' | 'ceded' | 'unchanged'
|
||||
}
|
||||
|
||||
export interface ReschedulePreview {
|
||||
api_version: 1
|
||||
program_id: string
|
||||
revision_id: string
|
||||
preview_sha256: string
|
||||
moves: RescheduleMove[]
|
||||
}
|
||||
|
||||
export interface ProgramImportPreview {
|
||||
api_version: 1
|
||||
program_id: string
|
||||
|
|
@ -132,47 +106,6 @@ function requestId(prefix = 'web'): string {
|
|||
return `${prefix}_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export function newRescheduleOperationId(): string {
|
||||
return requestId('pr')
|
||||
}
|
||||
|
||||
async function rescheduleRequest(
|
||||
programId: string,
|
||||
mode: 'preview' | 'apply',
|
||||
choice: RescheduleChoice & { preview_sha256?: string },
|
||||
operationId?: string,
|
||||
): Promise<ReschedulePreview> {
|
||||
const csrf = await mutationCsrfToken()
|
||||
const response = await fetch(
|
||||
`/api/v1/sessions/program/${encodeURIComponent(programId)}/reschedule/${mode}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'X-Trainlog-CSRF-Token': csrf,
|
||||
'X-Trainlog-Request-ID': operationId ?? requestId('pr'),
|
||||
},
|
||||
body: JSON.stringify(choice),
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error(await errorReason(response))
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || value.api_version !== 1 || value.program_id !== programId ||
|
||||
typeof value.revision_id !== 'string' || typeof value.preview_sha256 !== 'string' ||
|
||||
!Array.isArray(value.moves)) throw new TypeError('aperçu de recalage invalide')
|
||||
return value as unknown as ReschedulePreview
|
||||
}
|
||||
|
||||
export function previewProgramReschedule(programId: string, choice: RescheduleChoice) {
|
||||
return rescheduleRequest(programId, 'preview', choice)
|
||||
}
|
||||
|
||||
export function applyProgramReschedule(
|
||||
programId: string, choice: RescheduleChoice, previewSha256: string, operationId: string,
|
||||
) {
|
||||
return rescheduleRequest(programId, 'apply', { ...choice, preview_sha256: previewSha256 }, operationId)
|
||||
}
|
||||
|
||||
export async function fetchAllPrograms(
|
||||
search = '',
|
||||
state = '',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchSleepDiary, newSleepId, sleepTimestamp } from "./sleepDiary";
|
||||
import { newSleepId, sleepTimestamp } from "./sleepDiary";
|
||||
|
||||
describe("sleepTimestamp", () => {
|
||||
it("serializes browser timestamps at the C17 contract precision", () => {
|
||||
|
|
@ -22,37 +22,3 @@ describe("newSleepId", () => {
|
|||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchSleepDiary", () => {
|
||||
it("refuses to call a capped unbounded history complete", async () => {
|
||||
const entry = {
|
||||
entry_id: "sd_test",
|
||||
revision_id: "sdr_test",
|
||||
night_start_date: "2026-09-20",
|
||||
night_end_date: "2026-09-21",
|
||||
created_at: "2026-09-20T18:00:00+02:00",
|
||||
updated_at: "2026-09-21T08:00:00+02:00",
|
||||
sleep_quality: null,
|
||||
wake_quality: null,
|
||||
day_form: null,
|
||||
treatment_and_notes: "",
|
||||
publication_status: "draft",
|
||||
events: [],
|
||||
intakes: [],
|
||||
};
|
||||
const entries = Array.from({ length: 3660 }, (_, index) => ({
|
||||
...entry,
|
||||
entry_id: `sd_${index}`,
|
||||
}));
|
||||
const response = { api_version: 1, entries, summary: { nights: 3660 } };
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () => new Response(JSON.stringify(response)),
|
||||
);
|
||||
try {
|
||||
await expect(fetchSleepDiary()).rejects.toThrow("sleep_diary_history_limit_reached");
|
||||
expect(fetch.mock.calls[0][0]).toContain("limit=3660");
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -189,11 +189,6 @@ export async function fetchSleepDiary(
|
|||
!object(value.summary)
|
||||
)
|
||||
throw new TypeError("sleep_diary_invalid");
|
||||
// CONTRACT: the server caps one history read at 3660 nights and does not
|
||||
// expose a continuation cursor. At the cap, completeness is unknowable;
|
||||
// fail explicitly instead of labeling a possibly truncated subset "All".
|
||||
if (!startDate && !endDate && value.entries.length === 3660)
|
||||
throw new RangeError("sleep_diary_history_limit_reached");
|
||||
return value as unknown as SleepSnapshot;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchSportSessions } from "./sportSessions";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("completed sport history read", () => {
|
||||
const item = (index: number) => ({
|
||||
identity: `se_${index}`,
|
||||
label: `Session ${index}`,
|
||||
session_type: "training",
|
||||
started_at: `2026-10-01T0${index}:00:00+02:00`,
|
||||
ended_at: null,
|
||||
});
|
||||
|
||||
it("follows all pages and retains each factual stable identity", async () => {
|
||||
const fetch = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({
|
||||
api_version: 1, offset: 0, more: true, next_offset: 64, items: [item(1)],
|
||||
}) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({
|
||||
api_version: 1, offset: 64, more: false, next_offset: 128, items: [item(2)],
|
||||
}) });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const sessions = await fetchSportSessions("2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z");
|
||||
expect(sessions.map((session) => session.identity)).toEqual(["se_1", "se_2"]);
|
||||
expect(String(fetch.mock.calls[1][0])).toContain("offset=64");
|
||||
});
|
||||
|
||||
it("fails closed on stale cursors and duplicate identities", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({
|
||||
api_version: 1, offset: 0, more: true, next_offset: 64, items: [item(1)],
|
||||
}) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({
|
||||
api_version: 1, offset: 64, more: false, next_offset: 128, items: [item(1)],
|
||||
}) }));
|
||||
await expect(fetchSportSessions("a", "b")).rejects.toThrow(/Duplicate/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
export interface SportSession {
|
||||
identity: string;
|
||||
session_type: string;
|
||||
label: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
}
|
||||
|
||||
interface SportPage {
|
||||
api_version: 1;
|
||||
offset: number;
|
||||
more: boolean;
|
||||
next_offset: number;
|
||||
items: SportSession[];
|
||||
}
|
||||
|
||||
function validSession(value: unknown): value is SportSession {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
return typeof item.identity === "string" &&
|
||||
typeof item.session_type === "string" &&
|
||||
typeof item.label === "string" &&
|
||||
typeof item.started_at === "string" &&
|
||||
(item.ended_at === null || typeof item.ended_at === "string");
|
||||
}
|
||||
|
||||
/** CONTRACT: each read is a fresh factual history projection. Pagination must
|
||||
* complete before callers may present absence or permit a complete PDF. */
|
||||
export async function fetchSportSessions(
|
||||
start: string,
|
||||
end: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SportSession[]> {
|
||||
const sessions: SportSession[] = [];
|
||||
const ids = new Set<string>();
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const query = new URLSearchParams({ start, end, offset: String(offset), limit: "64" });
|
||||
const response = await fetch(`/api/v1/sessions/sport?${query}`, { signal });
|
||||
if (!response.ok) throw new Error(`Sport history HTTP ${response.status}`);
|
||||
const page: SportPage = await response.json();
|
||||
if (page.api_version !== 1 || page.offset !== offset ||
|
||||
!Array.isArray(page.items) || !page.items.every(validSession) ||
|
||||
typeof page.more !== "boolean" ||
|
||||
!Number.isSafeInteger(page.next_offset) || page.next_offset <= offset) {
|
||||
throw new TypeError("Invalid sport history page");
|
||||
}
|
||||
for (const item of page.items) {
|
||||
if (ids.has(item.identity)) throw new TypeError("Duplicate sport session identity");
|
||||
ids.add(item.identity);
|
||||
sessions.push(item);
|
||||
}
|
||||
if (!page.more) return sessions;
|
||||
if (page.items.length === 0) throw new TypeError("Empty sport history page");
|
||||
offset = page.next_offset;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { dashboardFixture } from '../test/dashboardFixtures'
|
||||
import { BODY_ZONE_SVG_MAP, BodyZoneFigure, muscleIntensityLevel, UNMAPPED_BODY_ZONE_IDS } from './BodyZoneFigure'
|
||||
import { ANDROID_BODY_HEAD, ANDROID_BODY_NECK, ANDROID_BODY_REGIONS } from './androidBodyFigureGeometry'
|
||||
|
||||
describe('silhouette BODY ZONES canonique', () => {
|
||||
it('mappe uniquement les huit zones anatomiquement localisables et documente les agrégats', () => {
|
||||
|
|
@ -11,55 +10,6 @@ describe('silhouette BODY ZONES canonique', () => {
|
|||
expect(new Set(Object.values(BODY_ZONE_SVG_MAP).flat()).size).toBe(Object.values(BODY_ZONE_SVG_MAP).flat().length)
|
||||
})
|
||||
|
||||
it('porte la tête, le cou et les onze chemins Android de chaque face', () => {
|
||||
expect(ANDROID_BODY_HEAD).toEqual({ cx: 50, cy: 8.25, rx: 9.5, ry: 6.75 })
|
||||
expect(ANDROID_BODY_NECK).toBe('M44.5 13 L43 19 C46 20.5 54 20.5 57 19 L55.5 13 Z')
|
||||
expect(ANDROID_BODY_REGIONS.front).toHaveLength(11)
|
||||
expect(ANDROID_BODY_REGIONS.back).toHaveLength(11)
|
||||
const all = [...ANDROID_BODY_REGIONS.front, ...ANDROID_BODY_REGIONS.back]
|
||||
const mappedIds = Object.values(BODY_ZONE_SVG_MAP).flat()
|
||||
expect(new Set(all.map((region) => region.id)).size).toBe(22)
|
||||
expect(new Set(mappedIds)).toEqual(new Set(all.map((region) => region.id)))
|
||||
for (const region of ANDROID_BODY_REGIONS.front) {
|
||||
expect(region.id).toMatch(/^front-/)
|
||||
expect(BODY_ZONE_SVG_MAP[region.zone]).toContain(region.id)
|
||||
}
|
||||
for (const region of ANDROID_BODY_REGIONS.back) {
|
||||
expect(region.id).toMatch(/^back-/)
|
||||
expect(BODY_ZONE_SVG_MAP[region.zone]).toContain(region.id)
|
||||
}
|
||||
expect(ANDROID_BODY_REGIONS.front.find((region) => region.zone === 'chest')?.d)
|
||||
.toBe('M27 28.5 C32 24.5 40 24 49 26.5 L49 38 C42 39.5 34 38 29 34.5 Z')
|
||||
expect(ANDROID_BODY_REGIONS.back.find((region) => region.zone === 'back')?.d)
|
||||
.toBe('M28 28.5 C35 25 43 24.5 50 26.5 C57 24.5 65 25 72 28.5 C68 38 64 47 62 55 C56 57 44 57 38 55 C36 47 32 38 28 28.5 Z')
|
||||
})
|
||||
|
||||
it('rend les deux silhouettes neutres sans données en conservant la légende', () => {
|
||||
const { container } = render(<BodyZoneFigure zones={[]} />)
|
||||
expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('path.body-region')).toHaveLength(22)
|
||||
expect(container.querySelectorAll('path.body-region.intensity-0')).toHaveLength(22)
|
||||
expect(screen.getByLabelText('Échelle visuelle des séances sur 30 jours'))
|
||||
.toHaveTextContent('0FaibleIntermédiaireMaximum observé')
|
||||
})
|
||||
|
||||
it('colore les régions correctes sur chaque face sans inverser avant et arrière', () => {
|
||||
const zones = [
|
||||
{ zone_id: 'chest', label: 'Pectoraux', session_count: 1, occurrence_count: 1, set_count: 1 },
|
||||
{ zone_id: 'back', label: 'Dos', session_count: 2, occurrence_count: 2, set_count: 2 },
|
||||
{ zone_id: 'glutes', label: 'Fessiers', session_count: 3, occurrence_count: 3, set_count: 3 },
|
||||
]
|
||||
const { container } = render(<BodyZoneFigure zones={zones} />)
|
||||
expect(container.querySelector('#front-chest-left')).toHaveClass('intensity-1')
|
||||
expect(container.querySelector('#front-chest-right')).toHaveClass('intensity-1')
|
||||
expect(container.querySelector('#back-trunk')).toHaveClass('intensity-2')
|
||||
expect(container.querySelector('#back-glute-left')).toHaveClass('intensity-3')
|
||||
expect(container.querySelector('#front-core')).toHaveClass('intensity-0')
|
||||
expect(container.querySelector('#front-chest-left')?.closest('svg')).toHaveAccessibleName('Silhouette, vue avant')
|
||||
expect(container.querySelector('#back-trunk')?.closest('svg')).toHaveAccessibleName('Silhouette, vue arrière')
|
||||
})
|
||||
|
||||
it('détermine la couleur uniquement depuis session_count et le maximum observé', () => {
|
||||
expect(muscleIntensityLevel(0, 4)).toBe(0)
|
||||
expect(muscleIntensityLevel(1, 4)).toBe(1)
|
||||
|
|
@ -78,7 +28,7 @@ describe('silhouette BODY ZONES canonique', () => {
|
|||
expect(chest).toHaveAccessibleName('Pectoraux : 4 séances, 6 occurrences, 18 séries.')
|
||||
fireEvent.focus(chest)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Pectoraux4 séances · 6 occurrences · 18 séries')
|
||||
expect(container.querySelector('#back-trunk')).toHaveClass('intensity-3')
|
||||
expect(container.querySelector('#back-upper')).toHaveClass('intensity-3')
|
||||
})
|
||||
|
||||
it('rend une région absente neutre et non interactive', () => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue