Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8708c63b | ||
|
|
bb20aa44ef | ||
|
|
45bb44e45d | ||
|
|
bd795495df | ||
|
|
a652644e24 | ||
|
|
8646422bef | ||
|
|
77b5bbca85 | ||
|
|
ffcdc3efcf | ||
|
|
23717ba6da | ||
|
|
de4b77d59b | ||
|
|
4cb911d5c4 | ||
|
|
a60e1aa056 | ||
|
|
696dac82a3 | ||
|
|
fe79c44e51 | ||
|
|
2ce458d286 | ||
|
|
3a51211f01 | ||
|
|
2755d29f2d | ||
|
|
713238fd0e | ||
|
|
d7ca6a1924 | ||
|
|
e7fbd6ff86 | ||
|
|
7adeb59b06 | ||
|
|
f20e5c4d39 |
132
CHANGELOG.md
|
|
@ -1,5 +1,137 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.6 — 2026-09-25
|
||||
|
||||
- Corrected delayed session finalization so a heart-rate capture cannot extend
|
||||
beyond the explicit factual session end. Android now transactionally removes
|
||||
strict post-end BPM/RR tails while retaining an exact-cutoff sample. For an
|
||||
already acknowledged capture, the separately versioned
|
||||
`trainlog-heart-rate-corrections` V1 companion carries an immutable
|
||||
`capture_id|cutoff` correction through the existing causal ledger without
|
||||
widening frozen causal-deletions V1. Old Heart Rate V1 generation replay
|
||||
applies that cutoff before identity comparison and cannot resurrect removed
|
||||
measurements. A guarded dry-run recovery tool reports before/after counts and
|
||||
statistics without rewriting published manifests or ACKs.
|
||||
- Completed the paired-device recovery for session
|
||||
`se_fa845379-794d-4bb6-8cd3-48187ebbd134`: Android and desktop now retain
|
||||
3,429 BPM samples and 3,429 RR values through the inclusive 11:16 cutoff,
|
||||
and two successive full MTP generation exchanges completed with durable
|
||||
consumed ACKs. Replaying the original 4,919-sample artifact cannot restore
|
||||
the removed tail. Generation parent selection now excludes archived or
|
||||
legacy acknowledgement rows lacking durable ACK evidence, while consumers
|
||||
accept the producer's declared parent when it is one of multiple retained
|
||||
current tips. Android Sleep V2 persistence also names medication-intake
|
||||
columns explicitly, preserving `quantity`, note, and creation time across
|
||||
the additive v33 layout.
|
||||
|
||||
- Recovered a real divergent Sleep revision identity without choosing an
|
||||
arbitrary winner. The unpublished desktop branch was re-identified with its
|
||||
exact content, the immutable Android revision retained its published ID and
|
||||
parent, and a fresh successor preserved the non-conflicting qualities,
|
||||
events and medication occurrences. The original immutable generation now
|
||||
has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September
|
||||
session is available through Desktop and Web with its timeline, feedback,
|
||||
cardio and Program provenance.
|
||||
- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep
|
||||
revision payloads. Both importers now reject a known historical revision ID
|
||||
whose parent or canonical content differs, even when that revision is below
|
||||
the current tip. A dedicated transactional recovery utility and regressions
|
||||
cover exact replay, parent/content collisions, medication quantities,
|
||||
rollback, immutable generation retry, and long UUID/parent chains.
|
||||
|
||||
- Fixed Android finalization of Program-derived sessions containing a completed
|
||||
target-only continuous occurrence. The repository now materializes the
|
||||
performed duration from the persisted exercise timeline, including preserved
|
||||
drafts closed by an older build, while keeping planned set counts independent
|
||||
from valid actual sets, repetitions and loads. A strictly identity-bound
|
||||
private recovery build may supply the factual session end; strict late
|
||||
heart-rate BPM/RR tails are now removed transactionally at that boundary.
|
||||
- Hardened desktop Sleep generation consumption when an identical durable
|
||||
revision has no local publication row. Content comparison now reads the
|
||||
durable revision directly, acknowledges an exact match, and reports a real
|
||||
revision-identity conflict instead of an empty rejection diagnostic when the
|
||||
same ID carries different content.
|
||||
|
||||
Development opened after the stable v0.1.5 release. The Web refinement tranche
|
||||
adds persistent French/English settings, denser shared surfaces, ISO-week
|
||||
Dashboard activity, factual exercise-count overview charts, measured-timeline
|
||||
muscle-group drill-down, normalized measurement summaries, one shared refined
|
||||
BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
|
||||
lists factual references and transactionally absorbs a custom duplicate into a
|
||||
canonical identity while retaining history/MAX context and recording a causal
|
||||
deletion marker; immutable embedded drafts cause an explicit conflict.
|
||||
The post-review correction keeps acknowledged-generation capacity recoverable
|
||||
for legacy Android rows without ACK-ledger evidence, proves two successive
|
||||
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
|
||||
generation. Web can now causally remove only an orphaned zero-occurrence active
|
||||
draft while retaining protection for real active drafts. Mutation UUIDs use
|
||||
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
|
||||
Dashboard sizing, Program progress contrast, chart proportions, duration
|
||||
coverage messaging, and the right-aligned Settings/Sync action cluster are
|
||||
also refined.
|
||||
|
||||
- Restored automatic Sleep-only Bluetooth synchronization after a real-night
|
||||
trace exposed several restart and first-import gaps. The user-service
|
||||
installer now removes only its obsolete `syncd` ExecStart override and
|
||||
installs the RFCOMM agent;
|
||||
Android resumes the same unsuperseded immutable generation across a new
|
||||
conversation run and accepts its exact generation/digest ACK even though
|
||||
the immutable ACK retains the original run identity. Ledger-proven
|
||||
superseded branches remain retained without exhausting admission. Desktop
|
||||
progress validation accepts the existing ACK-recovery phase, and a fresh
|
||||
desktop can seed a non-root Sleep V2 current snapshot when its validated
|
||||
ancestry is present. Regression
|
||||
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
|
||||
through import, ACK, replay and a second Sleep-only generation with no
|
||||
Android workout session.
|
||||
- Enriched the selected Sleep night's heart-rate chart with declared sleep
|
||||
ranges, explicit awakening ranges and point events, medication markers that
|
||||
preserve unit dose and quantity, exact start/end bounds, and every
|
||||
intervening hourly tick. When no declared sleep interval exists, the Web
|
||||
presentation alone shows the conservative bedtime +45 minutes to final
|
||||
get-up -10 minutes estimate, labels it as estimated/non-medical, and never
|
||||
writes it to Sleep data. Nights without synchronized HR retain these factual
|
||||
and estimated timeline overlays without inventing BPM samples. The private
|
||||
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
|
||||
real HR night and the latest real night containing medications from code
|
||||
commit `2ce458d`.
|
||||
- Reused that same read-only fallback projection in the multi-night Sleep
|
||||
agenda, with a visibly estimated hatched interval and no Sleep write. The
|
||||
agenda now derives an exact 18:00-to-18:00 hourly grid, presents at least
|
||||
two-hour labels on ordinary desktop widths, expands to every hour when wide,
|
||||
and reduces to six-hour anchors on narrow screens. A read-only real-device
|
||||
investigation of the 2026-09-23 night found no medication intake in any
|
||||
retained local revision and established that later synchronization only
|
||||
published the already-empty current revision. Android regression coverage
|
||||
now proves two pre-bed intakes, including quantity two, through repeated
|
||||
repository reopen, bedtime, wake, final get-up, generation, desktop ACK,
|
||||
replay, stale-ancestor input and a subsequent revision. The private service
|
||||
serves Web code commit `a60e1aa`; a non-mutating Firefox smoke on the real
|
||||
night confirms the identical 22:50–04:04 agenda/detail projection and the
|
||||
responsive 13/25/5-label axis densities.
|
||||
- Unified the Web Sleep agenda, selected-night HR detail, and vector PDF on one
|
||||
temporal projection. Every structured long-awake interval remains a distinct
|
||||
band, row selection updates the detail dates and content without persistence,
|
||||
and preview/download export exactly the active night with observations
|
||||
recomputed for that subset. Sleep duration now consistently sums factual
|
||||
sleep intervals or, only when none exist, uses the displayed Couché +45 min
|
||||
to Levé -10 min estimate minus structured awakenings; the estimate is never
|
||||
written to SQLite.
|
||||
- Changed future Android one-tap Réveil capture from punctual `night_get_up`
|
||||
facts to structured `long_awake` windows under the user-approved 30-minute
|
||||
default. A repeated tap inside the window extends its end to 30 minutes after
|
||||
the latest tap, while Levé clamps it to the factual final-get-up timestamp.
|
||||
The policy is revisioned, undoable, and independent of BPM/RR inference.
|
||||
- Centered the Sleep agenda's hour labels inside a shared 24-column visual grid
|
||||
and added an inclusive night-start date range for PDF preview and export.
|
||||
The duplicate terminal `18` is removed; detail/HR night selection remains
|
||||
independent from the report range, whose date fields now use a grouped
|
||||
Trainlog form treatment.
|
||||
- Fixed a real Sleep agenda offset bug when revision metadata used UTC `Z` but
|
||||
factual events used the user's local offset: the row now anchors its complete
|
||||
18:00 axis to one factual night offset. Agenda night headings use French
|
||||
`DD/MM/YYYY` rather than persistence-oriented ISO dates in the French UI.
|
||||
|
||||
## 0.1.5 — 2026-09-23
|
||||
|
||||
- Completed the Sleep Tonight readiness delta and its non-destructive private
|
||||
|
|
|
|||
575
README.md
|
|
@ -1,381 +1,124 @@
|
|||
# Trainlog
|
||||
<p align="center">
|
||||
<img src="docs/assets/trainlog-banner.svg" alt="Trainlog — local-first training data system" width="100%">
|
||||
</p>
|
||||
|
||||
Synchronization uses one generation/ACK business engine across versioned
|
||||
local transports. The paired 0.1.4 deployment prefers Bluetooth Classic RFCOMM
|
||||
and falls back to direct USB/MTP; the separately configured private Drive path
|
||||
remains available to the legacy mirror/fallback workflow. No transport copies
|
||||
SQLite databases between devices.
|
||||
<p align="center">
|
||||
<img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
|
||||
</p>
|
||||
|
||||
Trainlog is a local-first workout and body-data system. Trainlog Core owns
|
||||
business truth and canonical desktop persistence. Its native Android app is the
|
||||
field companion, its C17/Notcurses desktop TUI is the administration and
|
||||
technical surface, and its local browser Web sibling provides the implemented
|
||||
Dashboard, Sessions, Programmes, Exercises, and factual Analyse/Sleep Diary
|
||||
surfaces.
|
||||
Trainlog is a **local-first system for recording, synchronizing, and examining
|
||||
training and body data**. Its Android companion handles capture in the field;
|
||||
the C17 desktop core keeps the canonical long-term history; a keyboard-first
|
||||
Notcurses TUI and a loopback-only Web application provide complementary ways
|
||||
to inspect and manage it.
|
||||
|
||||
Android and desktop each own a local SQLite database. The desktop database is
|
||||
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||
over its authenticated local transports and optional configured Drive workflow;
|
||||
it never copies SQLite database files between devices.
|
||||
The system is built around explicit facts, stable identities, versioned JSON
|
||||
exchanges, and user-controlled storage. Plans never become performed work until
|
||||
they are actually recorded, continuous activities are not disguised as sets,
|
||||
and estimates are kept distinct from measurements.
|
||||
|
||||
## Product split
|
||||
> **Release line:** The latest stable release is
|
||||
> [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
|
||||
|
||||
| Surface | Primary responsibility |
|
||||
|---|---|
|
||||
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
||||
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||
| Local Web (0.1.5) | Display Dashboard, Programmes, Sessions, Exercises and factual Analyse workspaces. Analyse includes the Sleep Diary editor, multi-night 18:00-to-18:00 agenda, factual heart-rate timelines and local vector PDF export. |
|
||||
## ✦ Showcase
|
||||
|
||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
|
||||
visualizations, and private layout persistence are implemented. This does not
|
||||
make the remaining placeholder routes functional.
|
||||
The top-level `/programmes` route is an operational daily calendar for active
|
||||
Programs. Sessions → Programmes remains the technical administration/import,
|
||||
list, detail, archive, and delete surface.
|
||||
Web Exercises administration, the factual Dashboard/Analyse read model, and the
|
||||
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Stable
|
||||
0.1.5 adds persistent CYCPLUS acquisition, the global `♥ BPM` indicator,
|
||||
training exercise timestamps, structured Android Sleep capture, dedicated
|
||||
synchronized `cardio` sessions, versioned measured Cardio calibration,
|
||||
fresh-signal BPM-guided phases and factual Web heart-rate timelines in
|
||||
History/Sleep.
|
||||
<p align="center">
|
||||
<img src="docs/reviews/evidence/sleep-diary-alignment-firefox.png" alt="Trainlog Web Analyse workspace showing the Sleep Diary agenda and factual heart-rate timeline" width="100%">
|
||||
</p>
|
||||
|
||||
## Releases
|
||||
<p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
|
||||
|
||||
When a Trainlog release is published, its prebuilt assets are available from
|
||||
both official mirrors:
|
||||
The repository currently tracks real-browser evidence for the Web interface.
|
||||
Its Android overview asset is a clearly labelled static design study with
|
||||
fictional data, not a runtime screenshot; no current TUI screenshot is tracked.
|
||||
Those assets are therefore not presented here as product captures.
|
||||
|
||||
- [GitHub Releases](https://github.com/labfytools/trainlog/releases)
|
||||
- [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
|
||||
## ✦ Features
|
||||
|
||||
Each published stable release provides:
|
||||
| Area | Available in the current development tree |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Training capture** | Metadata-driven sets, repetitions, durations and continuous activities; heterogeneous actual sets; durable active draft; exercise timelines; explicit measured MAX results. |
|
||||
| **Planning** | Manual preparations, imported Programs, Android delivery, execution provenance and strict separation between targets and facts. |
|
||||
| **Context** | Exercise and equipment catalogues, BODY ZONES, body measurements, immediate feedback, wording revisions and J+1 follow-ups. |
|
||||
| **Cardio** | Android BLE heart-rate acquisition, factual BPM/RR history, cardio sessions, measured calibration and fresh-signal phase guidance. |
|
||||
| **Sleep** | One-tap Android capture, causal Sleep Diary revisions, medication occurrences, synchronized factual timelines and local vector PDF export. |
|
||||
| **Desktop** | C17 Core, canonical SQLite history, keyboard-first Notcurses administration, correction, statistics, graphs and synchronization diagnostics. |
|
||||
| **Local Web** | Loopback-only Dashboard, Analyse, Programmes, Sessions, Exercises, Equipment, Settings and synchronization surfaces backed by typed Core services. |
|
||||
| **Synchronization** | One generation/ACK business engine over authenticated Bluetooth Classic RFCOMM, direct USB/MTP recovery and a separately configured private Drive workflow; SQLite files are never exchanged. |
|
||||
|
||||
- a signed Android APK;
|
||||
- a Linux x86-64 desktop runtime bundle;
|
||||
- SHA-256 checksums.
|
||||
### Boundaries and work in progress
|
||||
|
||||
Both mirrors publish the same Trainlog product version and release assets. The
|
||||
latest stable version is **0.1.5** on Android and desktop; this is one shared
|
||||
Trainlog version, not separate interface versions. Version 0.1.4 is the
|
||||
previous stable release.
|
||||
- `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
|
||||
and V2 remain readable, while V4 codecs are staged but not selected by the
|
||||
active transport.
|
||||
- Full-generation synchronization is deployed only behind a trusted local
|
||||
opt-in; the default automatic exchange remains V3.
|
||||
- AI history export is active. AI session proposals still have a pending real
|
||||
Drive plus Android-triggered bidirectional smoke test.
|
||||
- Session Generator V1 is implemented but hidden while the V2 planning model
|
||||
is developed.
|
||||
- Web Analyse is implemented; its recorded visual-review gate remains open.
|
||||
|
||||
Trainlog presents French by default, with English selectable
|
||||
from **Settings → Language** on Android and the desktop TUI. The selection is
|
||||
local to that installation and updates the visible interface immediately; it
|
||||
does not translate user exercise/catalogue names or change training data,
|
||||
stable IDs, databases, schemas, exchange artifacts, synchronization protocols,
|
||||
AI, MAX, or feedback. Synchronization status is rendered locally from typed
|
||||
status and counters; raw protocol and operational summaries are not translated
|
||||
or injected into the other surface.
|
||||
The canonical status and limitations live in
|
||||
[`docs/current_state.md`](docs/current_state.md); future work belongs in the
|
||||
[`roadmap`](docs/roadmap.md).
|
||||
|
||||
See the [documentation](docs/README.md) and [changelog](CHANGELOG.md) for more
|
||||
details.
|
||||
|
||||
## Installation
|
||||
|
||||
### Download a prebuilt release
|
||||
|
||||
For a published release, download Trainlog from either official mirror:
|
||||
|
||||
- [GitHub](https://github.com/labfytools/trainlog/releases)
|
||||
- [Forgejo](https://git.labfytools.com/fy59/trainlog/releases)
|
||||
|
||||
Both mirrors contain the same Android APK, Linux x86-64 runtime bundle, and SHA-256
|
||||
checksums for each stable Trainlog release. Verify the downloaded binaries
|
||||
against `SHA256SUMS` before installing them.
|
||||
|
||||
The Linux artifact is an architecture-specific tar archive whose published
|
||||
filename intentionally remains `trainlog-tui-linux-x86_64-v<version>`. It
|
||||
contains the TUI/Web executable, synchronization daemon and helpers, catalogs,
|
||||
and a hashed inventory from the same release commit. Its native executables are
|
||||
dynamically linked, so the host must provide compatible runtime libraries
|
||||
listed under [Dependencies](#dependencies).
|
||||
|
||||
```bash
|
||||
mkdir trainlog-v<version>
|
||||
tar -xf trainlog-tui-linux-x86_64-v<version> -C trainlog-v<version>
|
||||
./trainlog-v<version>/bin/trainlog
|
||||
```
|
||||
|
||||
The extracted bundle can remain in a user-managed installation directory.
|
||||
Linking its launchers from `bin/` into `~/.local/bin` is optional.
|
||||
|
||||
For Android, download the release APK, allow installation from the browser or
|
||||
file manager when Android requests it, and install the package. Android 8.0
|
||||
(API 26) or later is required. Grant Trainlog all-files access only when direct
|
||||
`Documents/Trainlog` synchronization is needed. Desktop synchronization also
|
||||
requires the Linux side and its MTP dependencies to be installed and configured.
|
||||
|
||||
Android and the TUI in each stable release share the same Trainlog product
|
||||
version on both mirrors. Database schemas and JSON protocol versions are
|
||||
independent.
|
||||
|
||||
### Daily synchronization on an opted-in installation
|
||||
|
||||
After explicitly enabling the paired local synchronization path, keep Bluetooth
|
||||
enabled on Android and the desktop and select **Synchronize** in Trainlog when
|
||||
an explicit run is wanted. The current paired deployment prefers authenticated
|
||||
Bluetooth Classic RFCOMM and uses direct USB/MTP as the wired recovery path.
|
||||
The existing private Drive workflow remains separately configured. Wait until
|
||||
the correlated generation and acknowledgements complete; a failure remains
|
||||
durable and should be diagnosed before retrying rather than deleting protocol,
|
||||
database, or backup files.
|
||||
|
||||
### Build from source
|
||||
|
||||
Desktop:
|
||||
|
||||
```bash
|
||||
meson setup build
|
||||
meson compile -C build
|
||||
meson test -C build --print-errorlogs
|
||||
```
|
||||
|
||||
Android:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
|
||||
```
|
||||
|
||||
`assembleRelease` produces a distributable stable APK only when release signing
|
||||
is configured by the release operator. The repository does not contain signing
|
||||
credentials and does not silently generate them.
|
||||
|
||||
Release hosts provide `TRAINLOG_RELEASE_STORE_FILE`,
|
||||
`TRAINLOG_RELEASE_STORE_PASSWORD`, `TRAINLOG_RELEASE_KEY_ALIAS`, and
|
||||
`TRAINLOG_RELEASE_KEY_PASSWORD`, or a private
|
||||
`~/.config/trainlog/release-signing.properties` file containing the equivalent
|
||||
`storeFile`, `storePassword`, `keyAlias`, and `keyPassword` keys. Environment
|
||||
variables take precedence. Keystores and local credential files are ignored by
|
||||
Git; neither belongs in source control, build logs, or release notes.
|
||||
|
||||
The Android release key is the permanent update identity for the application.
|
||||
The release operator must preserve an encrypted external backup of both the
|
||||
keystore and its credentials. Losing either prevents future compatible Android
|
||||
updates; generating a replacement key is not a normal release procedure.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Linux runtime dependencies
|
||||
|
||||
The published v0.1.5 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||
source directly requires compatible versions of:
|
||||
|
||||
- glibc and the GCC support runtime;
|
||||
- SQLite 3;
|
||||
- libuuid;
|
||||
- utf8proc;
|
||||
- libudev;
|
||||
- libmtp;
|
||||
- Notcurses Core;
|
||||
- the standard math library.
|
||||
|
||||
Version 0.1.4 additionally links GNU libmicrohttpd and yyjson for the local Web
|
||||
adapter and Dashboard layout configuration. Those are not retroactive
|
||||
requirements of the pre-Web v0.1.1 tagged source.
|
||||
|
||||
Distribution packages may pull additional transitive libraries, including
|
||||
libusb, ncursesw, unistring, gpm, libgcrypt, libgpg-error, and libdeflate.
|
||||
Package names and ABI versions vary by distribution; inspect the release's
|
||||
`ldd` evidence and install packages from the target distribution rather than
|
||||
assuming one universal package command.
|
||||
|
||||
### Optional runtime and integration dependencies
|
||||
|
||||
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
|
||||
requires the Android companion, libudev, libmtp, and an unlocked connected
|
||||
device.
|
||||
- Bluetooth synchronization requires BlueZ on Linux plus Python 3.11 or newer
|
||||
with D-Bus and GObject bindings. It is a Trainlog synchronization transport,
|
||||
not a heart-rate sensor implementation.
|
||||
- `trainlog-syncd` installation expects a systemd user session. Manual TUI
|
||||
synchronization does not require the user service.
|
||||
- `rclone` is required for desktop Google Drive synchronization and automatic
|
||||
AI Drive workflows. Core capture, local history, analytics, and direct MTP
|
||||
synchronization continue to work without `rclone`.
|
||||
|
||||
### Build dependencies
|
||||
|
||||
Desktop builds require:
|
||||
|
||||
- Meson and Ninja;
|
||||
- a C17 compiler toolchain;
|
||||
- `pkg-config`;
|
||||
- Notcurses Core development headers;
|
||||
- SQLite, libuuid, utf8proc, libudev, libmtp, GNU libmicrohttpd, and yyjson
|
||||
development headers for the current development source;
|
||||
- Python 3 for generated sources, validators, import/export helpers, and tests.
|
||||
|
||||
The embedded Web frontend additionally uses Node and npm at build time only.
|
||||
Prepare its exact locked dependencies explicitly, then enable the release
|
||||
frontend build:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
cd ..
|
||||
meson setup build -Dweb=enabled
|
||||
meson compile -C build
|
||||
```
|
||||
|
||||
Meson never runs `npm install` or downloads packages. Run `npm ci` explicitly
|
||||
for the exact lockfile before configuring a Web-enabled build. The default `web=auto`
|
||||
embeds the frontend when Node, npm, and the prepared `web/node_modules` are
|
||||
available; otherwise it preserves a desktop-only build and `trainlog -w`
|
||||
reports that frontend support is absent. Release builds must use
|
||||
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
|
||||
npm, `node_modules`, and `web/dist` are not runtime dependencies.
|
||||
|
||||
For an existing build directory, change the option with `meson configure
|
||||
build -Dweb=enabled` (or the required value) instead of running `meson setup`
|
||||
as though the directory were new.
|
||||
|
||||
### Optional systemd user service for local Web development
|
||||
|
||||
The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w`
|
||||
available on `127.0.0.1:8080` during an interactive user session. Without
|
||||
systemd user lingering, enabling it does not guarantee startup before login.
|
||||
This is a development convenience, not a requirement of the final Trainlog
|
||||
distribution.
|
||||
It intentionally runs the canonical user entry point
|
||||
`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink
|
||||
to the current `build/tui/trainlog` binary, but the symlink must resolve to an
|
||||
executable before the service is started.
|
||||
|
||||
The unit prevents privilege acquisition, gives the process a private temporary
|
||||
directory, makes system paths read-only, and applies kernel/control-group and
|
||||
setuid restrictions that are compatible with the local HTTP server. It does
|
||||
not protect or hide the home directory because SQLite and Trainlog
|
||||
configuration must remain writable under `~/.local/share/trainlog` and
|
||||
`~/.config/trainlog`. Failed starts use a ten-second restart delay and are
|
||||
limited to three attempts per minute, preventing a persistent port-8080
|
||||
conflict from producing an uncontrolled loop.
|
||||
|
||||
Install the development unit as a symlink so repository updates are picked up
|
||||
after `daemon-reload`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now trainlog-web.service
|
||||
```
|
||||
|
||||
Useful lifecycle and diagnostic commands are:
|
||||
|
||||
```bash
|
||||
systemctl --user status trainlog-web
|
||||
systemctl --user restart trainlog-web
|
||||
systemctl --user stop trainlog-web
|
||||
systemctl --user start trainlog-web
|
||||
journalctl --user -u trainlog-web
|
||||
```
|
||||
|
||||
An operator may separately configure a local nginx proxy such as
|
||||
`http://trainlog.perf`. That hostname and proxy are machine-local conveniences,
|
||||
not a public Trainlog domain or a runtime dependency, and Trainlog does not
|
||||
configure them automatically.
|
||||
|
||||
Uninstalling the integration disables the unit before removing only its user
|
||||
configuration symlink:
|
||||
|
||||
```bash
|
||||
systemctl --user disable --now trainlog-web.service
|
||||
rm ~/.config/systemd/user/trainlog-web.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
Android builds require JDK 17, an Android SDK supporting the configured API
|
||||
levels, and the Gradle wrapper committed in this repository. People installing
|
||||
the APK do not need Java, Gradle, or the Android SDK.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Document | Purpose |
|
||||
|---|---|---|
|
||||
| Documentation map | [docs/README.md](docs/README.md) | Canonical index and ownership map. |
|
||||
| Current state | [docs/current_state.md](docs/current_state.md) | Current implemented schemas, capabilities, validation, and limitations. |
|
||||
| Architecture | [docs/architecture.md](docs/architecture.md) | Component, storage, process, and ownership boundaries. |
|
||||
| Android | [docs/android.md](docs/android.md) | Field-companion behavior and local persistence. |
|
||||
| Desktop TUI | [docs/tui.md](docs/tui.md) | Notcurses workflows, correction, and analytics. |
|
||||
| Database | [docs/database.md](docs/database.md) | Desktop persistence schema and migration contracts. |
|
||||
| Exercise model | [docs/exercise_data_model.md](docs/exercise_data_model.md) | Exercise, occurrence, load, and MAX semantics. |
|
||||
| Synchronization | [docs/sync_exchange.md](docs/sync_exchange.md) | Active MTP flow and companion artifacts. |
|
||||
| Exchange formats | [docs/exchange_format.md](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
|
||||
| Training feedback | [docs/training_feedback.md](docs/training_feedback.md) | Immediate feedback, revisions, and J+1 follow-ups. |
|
||||
| Tests | [docs/tests.md](docs/tests.md) | Durable validation commands and coverage strategy. |
|
||||
| Roadmap | [docs/roadmap.md](docs/roadmap.md) | Current cursor and future work only. |
|
||||
| Development contract | [AGENTS.md](AGENTS.md) | Repository invariants and contribution rules. |
|
||||
| Change history | [CHANGELOG.md](CHANGELOG.md) | Chronological implementation history. |
|
||||
|
||||
## Architecture overview
|
||||
## ✦ Architecture
|
||||
|
||||
```text
|
||||
Android local SQLite
|
||||
|
|
||||
| versioned JSON snapshots and requests
|
||||
v
|
||||
Documents/Trainlog on Android storage
|
||||
|
|
||||
| direct MTP / libmtp
|
||||
v
|
||||
trainlog_sync_run() <---- trainlog-syncd or desktop TUI
|
||||
|
|
||||
+---- import Android snapshot into desktop SQLite
|
||||
+---- publish desktop catalog companions to Android
|
||||
+---- write a synchronization receipt
|
||||
|
||||
desktop SQLite = canonical long-term history
|
||||
┌──────────────────────────────┐
|
||||
│ Android field companion │
|
||||
│ private SQLite capture store │
|
||||
└──────────────┬───────────────┘
|
||||
│ versioned JSON generations + acknowledgements
|
||||
│ Bluetooth RFCOMM primary · direct USB/MTP recovery
|
||||
▼
|
||||
┌──────────────────────────────┐ optional, separately configured
|
||||
│ Synchronization orchestration│◄───── private Drive workflow
|
||||
└──────────────┬───────────────┘
|
||||
│ typed imports, commands and read models
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ Trainlog Core · C17 │
|
||||
│ desktop SQLite = canonical │
|
||||
│ long-term history │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌──────┴──────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────────────┐
|
||||
│ Notcurses TUI│ │ 127.0.0.1 Web adapter│──► browser
|
||||
└──────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
The optional desktop AI flow exports read-only history and exchanges session
|
||||
proposals through Google Drive via the external `rclone` process. Android does
|
||||
not contain cloud credentials and does not run `rclone`.
|
||||
The TUI, Web application, and Android client are sibling adapters with
|
||||
different responsibilities. Business rules, calculations, persistence, and
|
||||
typed read models belong to Core; presentation code does not reconstruct them
|
||||
from raw database tables. See the full
|
||||
[`architecture contract`](docs/architecture.md).
|
||||
|
||||
## Current status
|
||||
## ✦ Your data, your system
|
||||
|
||||
As of 2026-09-18:
|
||||
- Trainlog runs on the user's own Android and Linux systems; no hosted Trainlog
|
||||
account or proprietary cloud service is required for core capture, history,
|
||||
analytics, or direct synchronization.
|
||||
- Android and desktop each keep a local SQLite database; the desktop database
|
||||
is the canonical long-term history.
|
||||
- Devices exchange bounded, versioned JSON artifacts—not database files.
|
||||
- The local Web server binds to `127.0.0.1` by default and never silently falls
|
||||
back to a LAN address or another port.
|
||||
- Direct Android storage uses `Documents/Trainlog`; desktop USB access uses
|
||||
`libudev` and `libmtp` without requiring a GVFS/FUSE mount.
|
||||
- The optional Drive path is operator-configured through external `rclone`.
|
||||
Android holds no cloud credentials.
|
||||
|
||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||
- desktop SQLite schema v29 and Android SQLite schema v26;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
- mobile export V3 is active; V1/V2 remain readable legacy inputs; explicit V4
|
||||
history and execution-draft codecs are staged but not selected by transport;
|
||||
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
|
||||
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
|
||||
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
|
||||
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
|
||||
Android-triggered bidirectional smoke test.
|
||||
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
|
||||
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||
`/programmes` is an operational active-Program calendar, while
|
||||
Sessions → Programmes remains the administration surface;
|
||||
`TRAINLOG_WEB_EXERCISES_V1=PASS`; `/analyse` now provides the 0.1.4 factual
|
||||
Dashboard/Analyse read-model foundation pending visual review;
|
||||
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
||||
private desktop/Android deployment, real Firefox presentation, correlated
|
||||
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
||||
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
|
||||
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
|
||||
services; automatic synchronization still selects V3. The trusted local
|
||||
opt-in path is deployed on the paired private installation and has completed
|
||||
real direct-MTP restart/replay validation; it is not a public rollout.
|
||||
## ✦ Build and run
|
||||
|
||||
The latest executable result belongs in
|
||||
[current state](docs/current_state.md), not in multiple README narratives.
|
||||
### Desktop and TUI
|
||||
|
||||
## Desktop development quick start
|
||||
|
||||
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev,
|
||||
libmtp, and Notcurses.
|
||||
The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
|
||||
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
|
||||
Notcurses Core, GNU libmicrohttpd, and yyjson.
|
||||
|
||||
```bash
|
||||
meson setup build
|
||||
|
|
@ -384,13 +127,36 @@ meson test -C build --print-errorlogs
|
|||
./build/tui/trainlog
|
||||
```
|
||||
|
||||
The database path is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||
The desktop database is stored at
|
||||
`$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
||||
|
||||
## Android development quick start
|
||||
### Embedded local Web application
|
||||
|
||||
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked
|
||||
`android/local.properties` file.
|
||||
Node and npm are build-time dependencies only. Meson does not download npm
|
||||
packages.
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
cd ..
|
||||
|
||||
meson setup build-web -Dweb=enabled
|
||||
meson compile -C build-web
|
||||
./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.
|
||||
|
||||
### Android
|
||||
|
||||
Android builds use the committed Gradle wrapper, JDK 17, and an Android SDK for
|
||||
the configured API levels (`minSdk 26`, `targetSdk 36`, `compileSdk 37`).
|
||||
|
||||
```bash
|
||||
cd android
|
||||
|
|
@ -398,38 +164,81 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
|||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Instrumented tests belong on an emulator. Do not run
|
||||
`connectedDebugAndroidTest` on the primary personal phone.
|
||||
Instrumented tests belong on an emulator; the primary personal phone is
|
||||
reserved for manual, non-destructive installation and synchronization checks.
|
||||
Release APK signing is intentionally operator-local and is not configured with
|
||||
credentials from this repository.
|
||||
|
||||
## Synchronization quick start
|
||||
<details>
|
||||
<summary>Optional user-session services</summary>
|
||||
|
||||
After a desktop build, the repository provides an installer for the private
|
||||
automatic synchronization services:
|
||||
|
||||
```bash
|
||||
meson compile -C build
|
||||
bash tools/install_syncd_user.sh
|
||||
systemctl --user is-active trainlog-syncd.service
|
||||
```
|
||||
|
||||
On Android, grant all-files access for the fixed `Documents/Trainlog`
|
||||
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP
|
||||
validation requires an unlocked connected device and remains an explicit manual
|
||||
check.
|
||||
This modifies the current user's `~/.local/bin` and systemd user configuration.
|
||||
Bluetooth generation transport starts only when its separate Trainlog
|
||||
configuration explicitly enables it. The loopback Web development unit in
|
||||
`systemd/trainlog-web.service` is another optional integration; neither service
|
||||
is required for manual TUI use.
|
||||
|
||||
## Development principles
|
||||
</details>
|
||||
|
||||
- Preserve frozen, versioned compatibility boundaries.
|
||||
- Use stable IDs; display names are not identities.
|
||||
- Keep performed work separate from plans and continuous activity separate
|
||||
from fake sets.
|
||||
- Keep Android capture, desktop analytics, persistence, transport, and
|
||||
rendering responsibilities distinct.
|
||||
- Prefer explicit failure to silent data loss or corruption.
|
||||
- Treat migrations, idempotence, tests, and documentation as part of feature
|
||||
completion.
|
||||
- Keep scientific catalogs separate from runtime user data and preserve stated
|
||||
uncertainty.
|
||||
Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
|
||||
official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
|
||||
and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
|
||||
downloaded artifacts against the accompanying `SHA256SUMS`.
|
||||
|
||||
## License and project notes
|
||||
## ✦ Repository layout
|
||||
|
||||
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror.
|
||||
Repository layout, detailed contracts, and historical review records are linked
|
||||
from [docs/README.md](docs/README.md).
|
||||
```text
|
||||
trainlog/
|
||||
├── android/ Kotlin + Jetpack Compose field companion
|
||||
├── tui/ C17 Core, Notcurses TUI, Web adapter and native tests
|
||||
├── web/ React + TypeScript + Vite frontend
|
||||
├── catalog/ versioned scientific and product knowledge
|
||||
├── format/ JSON schemas and frozen format material
|
||||
├── contracts/ machine-readable presentation contracts
|
||||
├── tools/ validators, import/export and sync utilities
|
||||
├── tests/ cross-surface and protocol regression tests
|
||||
├── systemd/ optional user-session integration
|
||||
└── docs/ canonical documentation and review evidence
|
||||
```
|
||||
|
||||
## ✦ Documentation
|
||||
|
||||
| Start here | Scope |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| [`docs/current_state.md`](docs/current_state.md) | Implemented capabilities, versions, validation evidence and active limitations. |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | Component, storage, process, transport and ownership boundaries. |
|
||||
| [`docs/roadmap.md`](docs/roadmap.md) | Current cursor and future work. |
|
||||
| [`docs/android.md`](docs/android.md) | Android capture, persistence and synchronization behavior. |
|
||||
| [`docs/tui.md`](docs/tui.md) | Notcurses workflows, correction, analytics and technical tools. |
|
||||
| [`docs/sync_exchange.md`](docs/sync_exchange.md) | Active synchronization flow, replay and acknowledgement semantics. |
|
||||
| [`docs/exchange_format.md`](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
|
||||
| [`docs/exercise_data_model.md`](docs/exercise_data_model.md) | Exercise, occurrence, planning, load and MAX semantics. |
|
||||
| [`docs/database.md`](docs/database.md) | Desktop SQLite schema and migration contracts. |
|
||||
| [`docs/tests.md`](docs/tests.md) | Canonical validation commands and coverage strategy. |
|
||||
|
||||
The complete ownership map is in [`docs/README.md`](docs/README.md), with
|
||||
chronological detail in the [`CHANGELOG`](CHANGELOG.md).
|
||||
|
||||
## ✦ Project status
|
||||
|
||||
| Boundary | Current state |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Product | `0.1.6` stable |
|
||||
| Desktop persistence | SQLite schema v36 |
|
||||
| Android persistence | SQLite schema v33 |
|
||||
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
|
||||
| Desktop terminal | C17 + Notcurses, active backend |
|
||||
| Local Web | Dashboard and operational Analyse, Programmes, Sessions, Exercises, Equipment, Settings and sync surfaces |
|
||||
| Interface language | French by default; English selectable on Android and desktop |
|
||||
| Compatibility | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
|
||||
Forgejo is the primary repository; GitHub is its release mirror. Trainlog is
|
||||
licensed under the [GNU General Public License v3](LICENSE).
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
|
|||
LOCAL_PERSISTENCE=IMPLEMENTED
|
||||
DIRECT_STORAGE=IMPLEMENTED
|
||||
MTP_SYNC=IMPLEMENTED
|
||||
ANDROID_SCHEMA=17
|
||||
ANDROID_SCHEMA=33
|
||||
```
|
||||
|
||||
## Visual contract
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import java.time.OffsetDateTime
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
|
|
@ -43,7 +44,38 @@ val trainlogVersionCode =
|
|||
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||
} ?: 6
|
||||
} ?: 7
|
||||
|
||||
/*
|
||||
* WHY: a user may need to finish one preserved draft after a completion bug
|
||||
* has been repaired, while its factual end time predates the repair build.
|
||||
* CONTRACT: both values are private-build inputs, must be supplied together,
|
||||
* and are embedded only after strict identity/timestamp validation.
|
||||
* INVARIANT: ordinary builds use empty values and every non-matching future
|
||||
* session continues to finalize at the live clock time.
|
||||
*/
|
||||
val recoverySessionId =
|
||||
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
|
||||
val recoveryEndedAt =
|
||||
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
|
||||
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
|
||||
throw GradleException(
|
||||
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
|
||||
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
|
||||
)
|
||||
}
|
||||
if (recoverySessionId.isNotEmpty()) {
|
||||
val sessionPattern =
|
||||
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
if (!sessionPattern.matches(recoverySessionId)) {
|
||||
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
|
||||
}
|
||||
try {
|
||||
OffsetDateTime.parse(recoveryEndedAt)
|
||||
} catch (_: Exception) {
|
||||
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.labfytools.trainlog"
|
||||
|
|
@ -55,7 +87,9 @@ android {
|
|||
targetSdk = 36
|
||||
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.5"
|
||||
versionName = "0.1.6"
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
|
@ -86,6 +120,7 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..32
|
||||
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..32) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -101,6 +101,19 @@ internal fun hasBluetoothConnectPermission(context: Context): Boolean =
|
|||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Decide whether a newly connected RFCOMM peer may publish an automatic request.
|
||||
*
|
||||
* WHY: Sleep-only changes have no workout event to wake the desktop path, so
|
||||
* Bluetooth arrival itself must remain the trigger. CONTRACT: user opt-in and
|
||||
* the existing cooldown are both mandatory. INVARIANT: reconnection never
|
||||
* bypasses the bounded request cadence or requires an explicit UI action.
|
||||
*/
|
||||
internal fun shouldPublishBluetoothArrivalRequest(
|
||||
autoSyncEnabled: Boolean,
|
||||
cooldownReady: Boolean,
|
||||
): Boolean = autoSyncEnabled && cooldownReady
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
|
||||
if (!hasBluetoothConnectPermission(context)) return emptyList()
|
||||
|
|
@ -526,9 +539,15 @@ internal class BluetoothSyncClient(
|
|||
}
|
||||
|
||||
private fun publishArrivalRequest() {
|
||||
if (!BackgroundSyncSettings(context).enabled) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!settings.shouldPublishAutomaticRequest(now)) return
|
||||
if (
|
||||
!shouldPublishBluetoothArrivalRequest(
|
||||
BackgroundSyncSettings(context).enabled,
|
||||
settings.shouldPublishAutomaticRequest(now),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
val directory = canonicalExchangeDirectory()
|
||||
if (!directory.isDirectory) return
|
||||
val coordinator =
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@ internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun acknowledgementMatchesCapturedGeneration(
|
||||
acknowledgement: JSONObject,
|
||||
captured: CapturedSyncGeneration,
|
||||
): Boolean =
|
||||
acknowledgement.optString("generation_id") == captured.generationId &&
|
||||
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
|
||||
|
||||
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
||||
internal class SyncGenerationCoordinator(
|
||||
private val repository: TrainlogRepository,
|
||||
|
|
@ -69,15 +76,9 @@ internal class SyncGenerationCoordinator(
|
|||
|
||||
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
||||
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
|
||||
"OR (status='acknowledged' AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
|
||||
"AND c.result IN('consumed','rejected')))) " +
|
||||
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
|
||||
"LIMIT 2",
|
||||
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
|
||||
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
|
||||
sql,
|
||||
arguments,
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null
|
||||
else {
|
||||
|
|
@ -93,6 +94,29 @@ internal class SyncGenerationCoordinator(
|
|||
captured
|
||||
}
|
||||
}
|
||||
query(
|
||||
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
||||
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
|
||||
"OR (status='acknowledged' AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
|
||||
"AND c.result IN('consumed','rejected')))) " +
|
||||
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
|
||||
"LIMIT 2",
|
||||
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
|
||||
) ?: if (consumerPeerId == null) null else query(
|
||||
/* WHY: an RFCOMM reset creates a new conversation run while
|
||||
* the immutable generation still awaits its correlated ACK.
|
||||
* CONTRACT: resume only the unsuperseded generation for the
|
||||
* same peer; the reference adopts the new run while manifest
|
||||
* and generation identity remain unchanged. */
|
||||
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
|
||||
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
|
||||
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
|
||||
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
|
||||
"ORDER BY g.generated_at DESC LIMIT 2",
|
||||
arrayOf(consumerPeerId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(directory: File): JSONObject? {
|
||||
|
|
@ -570,12 +594,15 @@ internal class SyncGenerationCoordinator(
|
|||
afterPublication?.invoke()
|
||||
lease.throwIfYieldRequested()
|
||||
// CONTRACT: durable objects from earlier conversations remain in
|
||||
// the exchange directory. Only this run's exact generation ACK
|
||||
// can advance its lineage; stale bytes are retained and ignored.
|
||||
// the exchange directory. A cross-run retry preserves the
|
||||
// immutable manifest's original run_id, so correlate its ACK by
|
||||
// generation identity and manifest digest. acceptAcknowledgement
|
||||
// then revalidates the complete durable ACK against SQLite.
|
||||
// INVARIANT: unrelated or same-ID/different-bytes ACKs remain
|
||||
// ignored; an exact replay is deliberately accepted.
|
||||
val desktopAck =
|
||||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId &&
|
||||
it.optString("generation_id") == captured.generationId
|
||||
acknowledgementMatchesCapturedGeneration(it, captured)
|
||||
}, pollTransport, lease)
|
||||
service.acceptAcknowledgement(desktopAck)
|
||||
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||
"sleep-diary" to Kind("trainlog-sleep-diary", 2, "sleep-diary-v2.json", false),
|
||||
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
|
||||
"heart-rate-corrections" to
|
||||
Kind("trainlog-heart-rate-corrections", 1,
|
||||
"heart-rate-corrections-v1.json", false),
|
||||
"cardio-calibrations" to
|
||||
Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false),
|
||||
"cardio-guidance" to
|
||||
|
|
@ -294,7 +297,26 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
repository.inSyncGenerationTransaction { db ->
|
||||
val retained =
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
|
||||
/* WHY: early deployed generation builds could advance a
|
||||
* producer row to acknowledged without retaining the ACK
|
||||
* ledger row. Such terminal rows cannot be archived
|
||||
* safely because Trainlog must not fabricate evidence,
|
||||
* but they must not exhaust the active admission window
|
||||
* forever. A failed conversation can also leave an
|
||||
* unacknowledged branch whose sibling later receives a
|
||||
* durable ACK; that superseded branch remains retained
|
||||
* for audit but can never be the causal tip. INVARIANT:
|
||||
* only a ledger-backed acknowledged sibling proves that
|
||||
* an unacknowledged branch no longer consumes admission. */
|
||||
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " +
|
||||
"((status IN('captured','published','waiting_acknowledgement') AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements sibling_ack " +
|
||||
"ON sibling_ack.generation_id=sibling.generation_id WHERE " +
|
||||
"sibling.parent_generation_id=g.parent_generation_id AND " +
|
||||
"sibling.generation_id<>g.generation_id AND sibling.status='acknowledged')) OR " +
|
||||
"(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " +
|
||||
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
|
||||
arrayOf(consumerPeerId),
|
||||
)
|
||||
.use {
|
||||
|
|
@ -340,10 +362,35 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
)
|
||||
}
|
||||
val generatedAt = OffsetDateTime.now().toString()
|
||||
/* WHY: a fully acknowledged fork can remain in the immutable
|
||||
* ledger after its payload has been durably archived.
|
||||
* CONTRACT: archived evidence stays queryable but no longer
|
||||
* participates in the active producer-lineage tip selection.
|
||||
* INVARIANT: only unarchived rows backed by a full durable ACK can
|
||||
* become the parent; two such active tips still reject capture. */
|
||||
val parent =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2",
|
||||
"SELECT g.generation_id FROM sync_generations g WHERE " +
|
||||
"g.producer_peer_id=? AND g.consumer_peer_id=? AND " +
|
||||
"g.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
|
||||
"sync_acknowledgements k WHERE " +
|
||||
"k.generation_id=g.generation_id AND " +
|
||||
"k.result='consumed' AND " +
|
||||
"k.durability='sqlite-commit-full') AND " +
|
||||
"NOT EXISTS(SELECT 1 FROM " +
|
||||
"sync_generation_archives a WHERE " +
|
||||
"a.generation_id=g.generation_id) AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_generations c WHERE " +
|
||||
"c.parent_generation_id=g.generation_id AND " +
|
||||
"c.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
|
||||
"sync_acknowledgements ck WHERE " +
|
||||
"ck.generation_id=c.generation_id AND " +
|
||||
"ck.result='consumed' AND " +
|
||||
"ck.durability='sqlite-commit-full') AND " +
|
||||
"NOT EXISTS(SELECT 1 FROM " +
|
||||
"sync_generation_archives ca WHERE " +
|
||||
"ca.generation_id=c.generation_id)) LIMIT 2",
|
||||
arrayOf(producer, consumerPeerId),
|
||||
)
|
||||
.use {
|
||||
|
|
@ -421,6 +468,24 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
arrayOf(operationId, generationId, if (existed) 0 else 1),
|
||||
)
|
||||
}
|
||||
val heartRateCorrections =
|
||||
JSONObject(checkNotNull(artifacts["heart-rate-corrections"]))
|
||||
.getJSONArray("corrections")
|
||||
for (index in 0 until heartRateCorrections.length()) {
|
||||
val operationId =
|
||||
heartRateCorrections.getJSONObject(index).getString("operation_id")
|
||||
val existed =
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_causal_publications " +
|
||||
"WHERE operation_id=? LIMIT 1",
|
||||
arrayOf(operationId),
|
||||
)
|
||||
.use { it.moveToFirst() }
|
||||
db.execSQL(
|
||||
"INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
||||
arrayOf(operationId, generationId, if (existed) 0 else 1),
|
||||
)
|
||||
}
|
||||
val sleep = JSONObject(checkNotNull(artifacts["sleep-diary"]))
|
||||
val sleepEntries = sleep.getJSONArray("entries")
|
||||
for (index in 0 until sleepEntries.length()) {
|
||||
|
|
@ -834,26 +899,38 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
}
|
||||
}
|
||||
val producerPeer = manifest.getJSONObject("producer").getString("peer_id")
|
||||
val expectedParent =
|
||||
/* WHY: the producer can archive one transient fork while this
|
||||
* consumer retains both immutable consumed audit rows.
|
||||
* CONTRACT: a new generation extends any current consumed tip;
|
||||
* a null, old, or unrelated parent remains invalid.
|
||||
* INVARIANT: fork history is never rewritten to select a tip. */
|
||||
val tipPredicate =
|
||||
"g.producer_peer_id=? AND g.result='consumed' AND " +
|
||||
"NOT EXISTS(SELECT 1 FROM sync_consumed_generations c " +
|
||||
"WHERE c.parent_generation_id=g.generation_id AND " +
|
||||
"c.result='consumed')"
|
||||
val hasCurrentTip =
|
||||
db.rawQuery(
|
||||
"SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2",
|
||||
"SELECT 1 FROM sync_consumed_generations g WHERE " +
|
||||
tipPredicate + " LIMIT 1",
|
||||
arrayOf(producerPeer),
|
||||
)
|
||||
.use {
|
||||
if (!it.moveToFirst()) null
|
||||
else {
|
||||
val value = it.getString(0)
|
||||
if (it.moveToNext())
|
||||
throw SyncGenerationException(
|
||||
"consumed generation lineage has multiple tips"
|
||||
)
|
||||
value
|
||||
}
|
||||
}
|
||||
.use { it.moveToFirst() }
|
||||
val suppliedParent =
|
||||
if (manifest.isNull("parent_generation_id")) null
|
||||
else manifest.getString("parent_generation_id")
|
||||
if (suppliedParent != expectedParent)
|
||||
val suppliedParentIsTip =
|
||||
suppliedParent != null &&
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_consumed_generations g WHERE " +
|
||||
"g.generation_id=? AND " + tipPredicate + " LIMIT 1",
|
||||
arrayOf(suppliedParent, producerPeer),
|
||||
)
|
||||
.use { it.moveToFirst() }
|
||||
if (
|
||||
(!hasCurrentTip && suppliedParent != null) ||
|
||||
(hasCurrentTip && !suppliedParentIsTip)
|
||||
)
|
||||
throw SyncGenerationException("generation lineage is stale or unrelated")
|
||||
val a = frozen.artifacts
|
||||
fun apply(name: String, result: () -> Any) {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ import java.security.MessageDigest
|
|||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
import java.time.temporal.ChronoField
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
|
@ -88,6 +90,11 @@ private val PROFILE_REVISION_NAMESPACE = UUID.fromString("4f4c8ea6-18f6-5e48-9d6
|
|||
private val PROFILE_REVISION_PATTERN = Regex("^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
private val PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$")
|
||||
private const val PROFILE_HISTORY_MAX = 32
|
||||
private val SLEEP_QUICK_TIMESTAMP = DateTimeFormatterBuilder()
|
||||
.appendPattern("uuuu-MM-dd'T'HH:mm:ss")
|
||||
.appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
|
||||
.appendOffsetId()
|
||||
.toFormatter()
|
||||
|
||||
/** Fixed-width UUIDv5 identity over the direct causal parent and resulting profile. */
|
||||
internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String {
|
||||
|
|
@ -708,7 +715,10 @@ class TrainlogRepository(
|
|||
arrayOf(revisionId,event.eventId.ifEmpty { sleepId("sle") },event.type.wireValue,event.startAt,event.endAt),
|
||||
) }
|
||||
draft.intakes.forEach { intake -> db.execSQL(
|
||||
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO sleep_medication_intakes(" +
|
||||
"revision_id,intake_id,medication_id,medication_name,taken_at," +
|
||||
"dose_value,dose_unit,quantity,note,created_at" +
|
||||
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf<Any?>(revisionId, intake.intakeId.ifEmpty { sleepId("mdi") }, intake.medicationId,
|
||||
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
|
||||
intake.quantity,
|
||||
|
|
@ -858,15 +868,37 @@ class TrainlogRepository(
|
|||
|
||||
fun quickSleepWake(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult =
|
||||
mutateActiveSleep(at) { current ->
|
||||
current.quickDraft(
|
||||
at,
|
||||
events = current.events + SleepDiaryEvent(
|
||||
val wake = OffsetDateTime.parse(at)
|
||||
val inferredEnd = wake.plusMinutes(30).format(SLEEP_QUICK_TIMESTAMP)
|
||||
val openIndex = current.events.indexOfLast { event ->
|
||||
event.type == SleepEventType.LONG_AWAKE &&
|
||||
event.endAt != null &&
|
||||
!wake.isBefore(OffsetDateTime.parse(event.startAt)) &&
|
||||
!wake.isAfter(OffsetDateTime.parse(event.endAt))
|
||||
}
|
||||
/*
|
||||
* WHY: one-tap capture cannot require a second interaction from a
|
||||
* partly awake user, but a point event cannot drive an awakening
|
||||
* interval in agenda/PDF projections.
|
||||
* CONTRACT: Réveil records a 30-minute LONG_AWAKE interval. A new
|
||||
* tap inside that window resets its inferred end to 30 minutes
|
||||
* after the latest tap; Levé may subsequently clamp it.
|
||||
* INVARIANT: this is explicit product policy, never an inference
|
||||
* from BPM/RR, and every persisted LONG_AWAKE has start < end.
|
||||
*/
|
||||
val events = if (openIndex >= 0) {
|
||||
current.events.mapIndexed { index, event ->
|
||||
if (index == openIndex) event.copy(endAt = inferredEnd) else event
|
||||
}
|
||||
} else {
|
||||
current.events + SleepDiaryEvent(
|
||||
sleepId("sle"),
|
||||
SleepEventType.NIGHT_GET_UP,
|
||||
SleepEventType.LONG_AWAKE,
|
||||
at,
|
||||
null,
|
||||
),
|
||||
)
|
||||
inferredEnd,
|
||||
)
|
||||
}
|
||||
current.quickDraft(at, events = events)
|
||||
}
|
||||
|
||||
fun quickSleepMedication(
|
||||
|
|
@ -933,9 +965,24 @@ class TrainlogRepository(
|
|||
val activeBefore = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
|
||||
val result =
|
||||
mutateActiveSleep(at) { current ->
|
||||
val finalGetUp = OffsetDateTime.parse(at)
|
||||
val boundedEvents = current.events.map { event ->
|
||||
if (
|
||||
event.type == SleepEventType.LONG_AWAKE &&
|
||||
event.endAt != null &&
|
||||
OffsetDateTime.parse(event.startAt).isBefore(finalGetUp) &&
|
||||
OffsetDateTime.parse(event.endAt).isAfter(finalGetUp)
|
||||
) {
|
||||
// CONTRACT: final get-up is a factual upper bound; an
|
||||
// inferred 30-minute awakening never extends past it.
|
||||
event.copy(endAt = at)
|
||||
} else {
|
||||
event
|
||||
}
|
||||
}
|
||||
current.quickDraft(
|
||||
at,
|
||||
events = current.events + SleepDiaryEvent(
|
||||
events = boundedEvents + SleepDiaryEvent(
|
||||
sleepId("sle"),
|
||||
SleepEventType.FINAL_GET_UP,
|
||||
at,
|
||||
|
|
@ -1413,6 +1460,18 @@ class TrainlogRepository(
|
|||
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||
arrayOf(entryId),
|
||||
).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null }
|
||||
val knownOwner = db.rawQuery(
|
||||
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?",
|
||||
arrayOf(revisionId),
|
||||
).use { if (it.moveToFirst()) it.getString(0) else null }
|
||||
if (knownOwner != null &&
|
||||
(knownOwner != entryId ||
|
||||
!sleepDiaryRevisionMatches(db, item, wireVersion, entryId, revisionId, parent))
|
||||
) {
|
||||
return SleepDiaryImportResult.Rejected(
|
||||
"sleep diary revision identity reused with different content",
|
||||
)
|
||||
}
|
||||
if (local?.first == revisionId) {
|
||||
val same = db.rawQuery(
|
||||
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
|
||||
|
|
@ -1552,7 +1611,10 @@ class TrainlogRepository(
|
|||
draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
|
||||
arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) }
|
||||
draft.intakes.forEach { intake -> db.execSQL(
|
||||
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO sleep_medication_intakes(" +
|
||||
"revision_id,intake_id,medication_id,medication_name,taken_at," +
|
||||
"dose_value,dose_unit,quantity,note,created_at" +
|
||||
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf<Any?>(revisionId, intake.intakeId, intake.medicationId,
|
||||
intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit,
|
||||
intake.quantity,
|
||||
|
|
@ -1567,6 +1629,78 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
private fun sleepDiaryRevisionMatches(
|
||||
db: SQLiteDatabase,
|
||||
item: JSONObject,
|
||||
wireVersion: Int,
|
||||
entryId: String,
|
||||
revisionId: String,
|
||||
parentRevisionId: String?,
|
||||
): Boolean {
|
||||
/* WHY: an incoming snapshot can name an ancestor rather than the
|
||||
* current tip. CONTRACT: stale replay is idempotent only after its
|
||||
* immutable revision-owned payload has been compared byte-semantically.
|
||||
* INVARIANT: descendants never hide identity reuse in history. */
|
||||
var same = db.rawQuery(
|
||||
"SELECT entry_id,parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
|
||||
"COALESCE(treatment_and_notes,'')," +
|
||||
"(SELECT COUNT(*) FROM sleep_diary_events e WHERE e.revision_id=r.revision_id)," +
|
||||
"(SELECT COUNT(*) FROM sleep_medication_intakes i WHERE i.revision_id=r.revision_id) " +
|
||||
"FROM sleep_diary_revisions r WHERE revision_id=?",
|
||||
arrayOf(revisionId),
|
||||
).use { current ->
|
||||
current.moveToFirst() && current.getString(0) == entryId &&
|
||||
(if (current.isNull(1)) null else current.getString(1)) == parentRevisionId &&
|
||||
current.getString(2) == item.getString("updated_at") &&
|
||||
(if (current.isNull(3)) null else current.getString(3)) ==
|
||||
(if (item.isNull("sleep_quality")) null else item.getString("sleep_quality")) &&
|
||||
(if (current.isNull(4)) null else current.getString(4)) ==
|
||||
(if (item.isNull("wake_quality")) null else item.getString("wake_quality")) &&
|
||||
(if (current.isNull(5)) null else current.getString(5)) ==
|
||||
(if (item.isNull("day_form")) null else item.getString("day_form")) &&
|
||||
current.getString(6) == item.getString("treatment_and_notes") &&
|
||||
current.getInt(7) == item.getJSONArray("events").length() &&
|
||||
current.getInt(8) == item.getJSONArray("intakes").length()
|
||||
}
|
||||
val events = item.getJSONArray("events")
|
||||
for (index in 0 until events.length()) {
|
||||
val event = events.getJSONObject(index)
|
||||
same = same && db.rawQuery(
|
||||
"SELECT event_type,start_at,end_at FROM sleep_diary_events " +
|
||||
"WHERE revision_id=? AND event_id=?",
|
||||
arrayOf(revisionId, event.getString("event_id")),
|
||||
).use { current ->
|
||||
current.moveToFirst() && current.getString(0) == event.getString("type") &&
|
||||
current.getString(1) == event.getString("start_at") &&
|
||||
(if (current.isNull(2)) null else current.getString(2)) ==
|
||||
(if (event.isNull("end_at")) null else event.getString("end_at"))
|
||||
}
|
||||
}
|
||||
val intakes = item.getJSONArray("intakes")
|
||||
for (index in 0 until intakes.length()) {
|
||||
val intake = intakes.getJSONObject(index)
|
||||
same = same && db.rawQuery(
|
||||
"SELECT medication_id,medication_name,taken_at,dose_value,dose_unit,quantity," +
|
||||
"COALESCE(note,''),created_at FROM sleep_medication_intakes " +
|
||||
"WHERE revision_id=? AND intake_id=?",
|
||||
arrayOf(revisionId, intake.getString("intake_id")),
|
||||
).use { current ->
|
||||
current.moveToFirst() &&
|
||||
current.getString(0) == intake.getString("medication_id") &&
|
||||
current.getString(1) == intake.getString("medication_name") &&
|
||||
current.getString(2) == intake.getString("taken_at") &&
|
||||
(if (current.isNull(3)) null else current.getDouble(3)) ==
|
||||
(if (intake.isNull("dose_value")) null else intake.getDouble("dose_value")) &&
|
||||
(if (current.isNull(4)) null else current.getString(4)) ==
|
||||
(if (intake.isNull("dose_unit")) null else intake.getString("dose_unit")) &&
|
||||
current.getInt(5) == (if (wireVersion == 1) 1 else intake.getInt("quantity")) &&
|
||||
current.getString(6) == intake.getString("note") &&
|
||||
current.getString(7) == intake.getString("created_at")
|
||||
}
|
||||
}
|
||||
return same
|
||||
}
|
||||
|
||||
private fun markSleepDiaryImported(
|
||||
db: SQLiteDatabase,
|
||||
entryId: String,
|
||||
|
|
@ -1814,6 +1948,55 @@ class TrainlogRepository(
|
|||
"SELECT run_id FROM cardio_guidance_runs WHERE session_id=?)",
|
||||
arrayOf(endedAt, entryId, row.first),
|
||||
)
|
||||
|
||||
val continuous =
|
||||
db.rawQuery(
|
||||
"SELECT de.id,de.data_fields,ca.draft_exercise_row_id " +
|
||||
"FROM draft_session_exercises de " +
|
||||
"LEFT JOIN draft_continuous_activity ca " +
|
||||
"ON ca.draft_exercise_row_id=de.id " +
|
||||
"WHERE de.draft_id=1 AND de.entry_id=? " +
|
||||
"AND de.recording_mode='continuous'",
|
||||
arrayOf(entryId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null
|
||||
else Triple(
|
||||
cursor.getLong(0),
|
||||
cursor.getInt(1),
|
||||
!cursor.isNull(2),
|
||||
)
|
||||
}
|
||||
if (continuous != null && !continuous.third) {
|
||||
check(continuous.second == 0) {
|
||||
"Continuous speed or distance must be recorded before the exercise is finished."
|
||||
}
|
||||
val measuredSeconds =
|
||||
Duration.between(
|
||||
OffsetDateTime.parse(row.third),
|
||||
OffsetDateTime.parse(endedAt),
|
||||
).seconds
|
||||
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
|
||||
"Measured continuous exercise duration is invalid."
|
||||
}
|
||||
/*
|
||||
* WHY: a Program occurrence starts as prescription-only, but
|
||||
* pressing Finish makes its already-recorded timeline interval
|
||||
* an actual performance fact.
|
||||
* CONTRACT: only a target-only continuous occurrence without
|
||||
* supplemental fields is materialized from its own measured
|
||||
* start/end markers; an entered fact is never overwritten.
|
||||
* INVARIANT: timeline completion and performed-duration
|
||||
* creation commit or roll back together.
|
||||
*/
|
||||
db.insertOrThrow(
|
||||
"draft_continuous_activity",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("draft_exercise_row_id", continuous.first)
|
||||
put("duration_seconds", measuredSeconds)
|
||||
},
|
||||
)
|
||||
}
|
||||
val timing =
|
||||
SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt)
|
||||
db.setTransactionSuccessful()
|
||||
|
|
@ -1827,6 +2010,61 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: an older build could close a target-only continuous timeline while
|
||||
* leaving its performed row absent, making the preserved draft impossible
|
||||
* to finalize after upgrade.
|
||||
* CONTRACT: only completed timeline facts for continuous occurrences with
|
||||
* no supplemental fields may supply the missing performed duration.
|
||||
* INVARIANT: existing performed rows and recorded timestamps are never
|
||||
* changed; every inserted duration is positive, bounded, and transaction
|
||||
* owned by the caller.
|
||||
*/
|
||||
private fun materializeCompletedContinuousTimings(
|
||||
db: SQLiteDatabase,
|
||||
sessionId: String,
|
||||
) {
|
||||
val missing = mutableListOf<Triple<Long, String, String>>()
|
||||
db.rawQuery(
|
||||
"SELECT de.id,t.started_at,t.ended_at,de.data_fields " +
|
||||
"FROM draft_session_exercises de " +
|
||||
"JOIN active_session_draft d ON d.id=de.draft_id " +
|
||||
"JOIN session_timeline_exercises t ON t.session_id=d.session_id " +
|
||||
"AND t.entry_id=de.entry_id " +
|
||||
"LEFT JOIN draft_continuous_activity ca " +
|
||||
"ON ca.draft_exercise_row_id=de.id " +
|
||||
"WHERE d.id=1 AND d.session_id=? " +
|
||||
"AND de.recording_mode='continuous' AND t.ended_at IS NOT NULL " +
|
||||
"AND ca.draft_exercise_row_id IS NULL",
|
||||
arrayOf(sessionId),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
check(cursor.getInt(3) == 0) {
|
||||
"Continuous speed or distance is missing from a completed exercise."
|
||||
}
|
||||
missing += Triple(cursor.getLong(0), cursor.getString(1), cursor.getString(2))
|
||||
}
|
||||
}
|
||||
missing.forEach { (draftExerciseRowId, startedAt, endedAt) ->
|
||||
val measuredSeconds =
|
||||
Duration.between(
|
||||
OffsetDateTime.parse(startedAt),
|
||||
OffsetDateTime.parse(endedAt),
|
||||
).seconds
|
||||
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
|
||||
"Measured continuous exercise duration is invalid."
|
||||
}
|
||||
db.insertOrThrow(
|
||||
"draft_continuous_activity",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("draft_exercise_row_id", draftExerciseRowId)
|
||||
put("duration_seconds", measuredSeconds)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCardioCalibrationProfile(
|
||||
db: SQLiteDatabase,
|
||||
calibrationId: String,
|
||||
|
|
@ -3396,6 +3634,7 @@ class TrainlogRepository(
|
|||
artifacts["body-zones"] = buildExerciseBodyZonesJson(true)
|
||||
artifacts["feedback"] = buildTrainingFeedbackJson(true)
|
||||
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
|
||||
artifacts["heart-rate-corrections"] = buildHeartRateCorrectionsV1Json()
|
||||
artifacts["program-executions"] = buildProgramExecutionsV1Json()
|
||||
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
|
||||
artifacts["heart-rate"] = buildHeartRateV1Json()
|
||||
|
|
@ -5465,15 +5704,32 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
fun finalizeActiveSessionDraft(): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(null)
|
||||
/**
|
||||
* Finalize the active singleton atomically. An explicit offset-aware end
|
||||
* is reserved for factual recovery; null retains the live-clock behavior.
|
||||
*/
|
||||
fun finalizeActiveSessionDraft(endedAt: String? = null): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(null, endedAt)
|
||||
|
||||
/** Finalize exactly one lifecycle identity; retry after commit is idempotent. */
|
||||
fun finalizeExecutionDraft(sessionId: String): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(sessionId)
|
||||
/**
|
||||
* Finalize exactly one lifecycle identity; retry after commit is
|
||||
* idempotent. The optional factual end follows the same bounds as active
|
||||
* singleton finalization.
|
||||
*/
|
||||
fun finalizeExecutionDraft(
|
||||
sessionId: String,
|
||||
endedAt: String? = null,
|
||||
): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(sessionId, endedAt)
|
||||
|
||||
private fun finalizeActiveSessionDraftInternal(expectedSessionId: String?):
|
||||
private fun finalizeActiveSessionDraftInternal(
|
||||
expectedSessionId: String?,
|
||||
requestedEndedAt: String?,
|
||||
):
|
||||
FinalizeActiveDraftResult {
|
||||
val finalizedAt = requestedEndedAt ?: OffsetDateTime.now().toString()
|
||||
val parsedFinalizedAt = TrainlogTimestamp.parse(finalizedAt)
|
||||
?: return FinalizeActiveDraftResult.Invalid("Invalid session end timestamp.")
|
||||
var db: SQLiteDatabase? = null
|
||||
var transactionOpen = false
|
||||
return try {
|
||||
|
|
@ -5492,7 +5748,7 @@ class TrainlogRepository(
|
|||
return FinalizeActiveDraftResult.Saved(expectedSessionId)
|
||||
}
|
||||
}
|
||||
val active =
|
||||
var active =
|
||||
loadActiveSessionDraft(db)
|
||||
if (active == null) {
|
||||
db.endTransaction()
|
||||
|
|
@ -5521,7 +5777,15 @@ class TrainlogRepository(
|
|||
"ACTIVE_EXERCISE:$activeEntryId",
|
||||
)
|
||||
}
|
||||
val finalizedAt = OffsetDateTime.now().toString()
|
||||
val sessionStartedAt = TrainlogTimestamp.parse(active.draft.startedAt!!)
|
||||
?: error("Active execution draft has an invalid started_at.")
|
||||
if (parsedFinalizedAt < sessionStartedAt) {
|
||||
db.endTransaction()
|
||||
transactionOpen = false
|
||||
return FinalizeActiveDraftResult.Invalid(
|
||||
"Session cannot end before it starts.",
|
||||
)
|
||||
}
|
||||
val latestExerciseEnd =
|
||||
db.rawQuery(
|
||||
"SELECT MAX(ended_at) FROM session_timeline_exercises WHERE session_id=?",
|
||||
|
|
@ -5532,7 +5796,7 @@ class TrainlogRepository(
|
|||
if (
|
||||
latestExerciseEnd != null &&
|
||||
TrainlogTimestamp.parse(latestExerciseEnd)!! >
|
||||
TrainlogTimestamp.parse(finalizedAt)!!
|
||||
parsedFinalizedAt
|
||||
) {
|
||||
db.endTransaction()
|
||||
transactionOpen = false
|
||||
|
|
@ -5541,6 +5805,11 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
materializeCompletedContinuousTimings(db, lifecycleSessionId)
|
||||
active = checkNotNull(loadActiveSessionDraft(db)) {
|
||||
"Active execution draft disappeared during finalization."
|
||||
}
|
||||
|
||||
val completed =
|
||||
SessionDraft(
|
||||
exercises =
|
||||
|
|
@ -5577,12 +5846,54 @@ class TrainlogRepository(
|
|||
"WHERE session_id=? AND ended_at IS NULL",
|
||||
arrayOf(finalizedAt, sessionId),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
|
||||
"active_exercise_entry_id=NULL WHERE active_slot=1 AND context_id=? " +
|
||||
"AND context_kind IN('session','cardio')",
|
||||
arrayOf(finalizedAt, sessionId),
|
||||
)
|
||||
val activeCapture =
|
||||
db.rawQuery(
|
||||
"SELECT capture_id FROM heart_rate_captures " +
|
||||
"WHERE active_slot=1 AND context_id=? " +
|
||||
"AND context_kind IN('session','cardio')",
|
||||
arrayOf(sessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (activeCapture != null) {
|
||||
val removedSequences = mutableListOf<Long>()
|
||||
db.rawQuery(
|
||||
"SELECT sequence,observed_at FROM heart_rate_samples " +
|
||||
"WHERE capture_id=? ORDER BY sequence",
|
||||
arrayOf(activeCapture),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val observedAt = cursor.getString(1)
|
||||
val observedKey = TrainlogTimestamp.parse(observedAt)
|
||||
?: error("Heart-rate sample has an invalid timestamp.")
|
||||
if (observedKey > parsedFinalizedAt) {
|
||||
removedSequences += cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
* WHY: delayed recovery can establish a factual session end
|
||||
* earlier than samples recorded while the failed draft stayed
|
||||
* open; those later measurements are not part of the session.
|
||||
* CONTRACT: the session/timeline use the requested factual
|
||||
* end inclusively. Samples strictly after it are deleted, and
|
||||
* their RR children follow through the declared cascade.
|
||||
* INVARIANT: every retained sample is at or before the stopped
|
||||
* capture end; retained sample identities are never rewritten.
|
||||
*/
|
||||
removedSequences.forEach { sequence ->
|
||||
check(
|
||||
db.delete(
|
||||
"heart_rate_samples",
|
||||
"capture_id=? AND sequence=?",
|
||||
arrayOf(activeCapture, sequence.toString()),
|
||||
) == 1,
|
||||
)
|
||||
}
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
|
||||
"active_exercise_entry_id=NULL WHERE capture_id=? AND active_slot=1",
|
||||
arrayOf(finalizedAt, activeCapture),
|
||||
)
|
||||
}
|
||||
closeCardioGuidanceRunForSession(db, sessionId, finalizedAt)
|
||||
|
||||
/* INVARIANT: completion moves the exact Program provenance from
|
||||
|
|
@ -7065,7 +7376,10 @@ class TrainlogRepository(
|
|||
|
||||
private fun buildCausalDeletionExportV1Json(db: SQLiteDatabase): String {
|
||||
val operations = JSONArray()
|
||||
db.rawQuery("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations ORDER BY operation_id", null).use { cursor ->
|
||||
db.rawQuery("SELECT operation_id,target_kind,target_id,creator_id," +
|
||||
"predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations " +
|
||||
"WHERE target_kind IN('session','execution_draft','exercise','body_observation'," +
|
||||
"'custom_equipment','feedback','body_zone_relation') ORDER BY operation_id", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
check(operations.length() < MAX_CAUSAL_OPERATIONS) { "Causal protection set exceeds artifact bound." }
|
||||
operations.put(JSONObject().put("operation_id", cursor.getString(0)).put("target_kind", cursor.getString(1))
|
||||
|
|
@ -7078,6 +7392,50 @@ class TrainlogRepository(
|
|||
.put("generated_at", OffsetDateTime.now().toString()).put("operations", operations).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: Heart Rate V1 bytes and causal-deletions V1 semantics are frozen.
|
||||
* CONTRACT: acknowledged capture cutoffs therefore travel in their own
|
||||
* strict V1 companion while reusing the durable causal operation ledger.
|
||||
* INVARIANT: this exporter never emits a heart-rate operation through the
|
||||
* frozen general-deletion artifact.
|
||||
*/
|
||||
internal fun buildHeartRateCorrectionsV1Json(): String {
|
||||
val corrections = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT operation_id,target_id,creator_id,predecessor_revision_id," +
|
||||
"created_at,payload_sha256 FROM sync_causal_operations " +
|
||||
"WHERE target_kind='heart_rate_tail' ORDER BY operation_id",
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
check(corrections.length() < MAX_HEART_RATE_CORRECTIONS)
|
||||
val target = cursor.getString(1).split('|', limit = 2)
|
||||
check(target.size == 2 && sleepIdentity(target[0], "hrc") &&
|
||||
TrainlogTimestamp.parse(target[1]) != null)
|
||||
corrections.put(
|
||||
JSONObject()
|
||||
.put("operation_id", cursor.getString(0))
|
||||
.put("capture_id", target[0])
|
||||
.put("cutoff", target[1])
|
||||
.put("creator_id", cursor.getString(2))
|
||||
.put("predecessor_revision_id", cursor.getString(3))
|
||||
.put("created_at", cursor.getString(4))
|
||||
.put("payload_sha256", cursor.getString(5)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-heart-rate-corrections")
|
||||
.put("version", 1)
|
||||
.put("generated_at", OffsetDateTime.now().toString())
|
||||
.put("corrections", corrections)
|
||||
.toString()
|
||||
.also {
|
||||
check(it.toByteArray(StandardCharsets.UTF_8).size <=
|
||||
MAX_HEART_RATE_CORRECTION_ARTIFACT_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyCausalDeletionExportV1Json(json: String): CausalDeleteResult {
|
||||
if (json.toByteArray().size > MAX_CAUSAL_ARTIFACT_BYTES || !hasStrictJsonShape(json)) return CausalDeleteResult.Invalid
|
||||
val root = try { JSONObject(json) } catch (_: Exception) { return CausalDeleteResult.Invalid }
|
||||
|
|
@ -11932,6 +12290,8 @@ private const val MAX_SYNC_NOTE_UTF8_BYTES = 4096
|
|||
private const val MAX_PENDING_EXECUTION_DRAFTS = 15
|
||||
private const val MAX_CAUSAL_OPERATIONS = 4096
|
||||
private const val MAX_CAUSAL_ARTIFACT_BYTES = 4 * 1024 * 1024
|
||||
private const val MAX_HEART_RATE_CORRECTIONS = 256
|
||||
private const val MAX_HEART_RATE_CORRECTION_ARTIFACT_BYTES = 4 * 1024 * 1024
|
||||
private const val MAX_PLAN_SETS = 64
|
||||
private const val MAX_PLAN_REPS = 10000
|
||||
private const val MAX_PLAN_DURATION_SECONDS = 86400
|
||||
|
|
@ -11990,7 +12350,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
32,
|
||||
33,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -12336,6 +12696,14 @@ private class TrainlogDatabaseHelper(
|
|||
createSleepQuickPublicationTable(db)
|
||||
version = 32
|
||||
}
|
||||
if (version < 33 && newVersion >= 33) {
|
||||
/* WHY: a revision UUID is shared causal identity, so changing any
|
||||
* owned payload row in place can make peers disagree permanently.
|
||||
* CONTRACT: v33 makes Sleep revision payloads append-only; edits
|
||||
* continue to append through repository APIs with a fresh UUID. */
|
||||
createSleepImmutabilityTriggers(db)
|
||||
version = 33
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
|
|
@ -12475,6 +12843,28 @@ private class TrainlogDatabaseHelper(
|
|||
entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
|
||||
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY(generation_id,entry_id))""".trimIndent())
|
||||
createSleepImmutabilityTriggers(db)
|
||||
}
|
||||
|
||||
private fun createSleepImmutabilityTriggers(db: SQLiteDatabase) {
|
||||
/* INVARIANT: only owner tips/publication state may advance in place.
|
||||
* Revision rows and their event/intake children are append-only. */
|
||||
listOf(
|
||||
Triple("sleep_diary_revisions", "update", "sleep diary revision is immutable"),
|
||||
Triple("sleep_diary_revisions", "delete", "sleep diary revision is immutable"),
|
||||
Triple("sleep_diary_events", "update", "sleep diary event is immutable"),
|
||||
Triple("sleep_diary_events", "delete", "sleep diary event is immutable"),
|
||||
Triple("sleep_medication_intakes", "update", "sleep medication intake is immutable"),
|
||||
Triple("sleep_medication_intakes", "delete", "sleep medication intake is immutable"),
|
||||
Triple("sleep_medication_revisions", "update", "sleep medication revision is immutable"),
|
||||
Triple("sleep_medication_revisions", "delete", "sleep medication revision is immutable"),
|
||||
).forEach { (table, operation, message) ->
|
||||
db.execSQL(
|
||||
"CREATE TRIGGER IF NOT EXISTS ${table}_immutable_$operation " +
|
||||
"BEFORE ${operation.uppercase()} ON $table BEGIN " +
|
||||
"SELECT RAISE(ABORT,'$message'); END",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSleepQuickPublicationTable(db: SQLiteDatabase) {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import androidx.compose.ui.semantics.semantics
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.BuildConfig
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
|
|
@ -75,7 +77,18 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
/**
|
||||
* CONTRACT: a private recovery build may override finalization time for one
|
||||
* exact stable lifecycle identity only. Empty build inputs and every other
|
||||
* draft retain the normal repository clock default.
|
||||
*/
|
||||
private fun recoveryEndedAt(draft: ActiveSessionDraft): String? =
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ENDED_AT.takeIf {
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ID.isNotEmpty() &&
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ID == draft.sessionId &&
|
||||
it.isNotEmpty()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionScreen(
|
||||
|
|
@ -638,7 +651,9 @@ fun SessionScreen(
|
|||
} else {
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft()
|
||||
repository.finalizeActiveSessionDraft(
|
||||
recoveryEndedAt(currentDraft),
|
||||
)
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
|
|
@ -870,7 +885,11 @@ fun SessionScreen(
|
|||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
timelineRevision++
|
||||
when (val result = repository.finalizeActiveSessionDraft()) {
|
||||
when (
|
||||
val result = repository.finalizeActiveSessionDraft(
|
||||
recoveryEndedAt(currentDraft),
|
||||
)
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
pendingFinalizeActiveEntryId = null
|
||||
onSessionSaved()
|
||||
|
|
|
|||
|
|
@ -544,7 +544,7 @@
|
|||
<string name="sleep_quick_wake">👁 WAKE</string>
|
||||
<string name="sleep_quick_get_up">☀ GET UP</string>
|
||||
<string name="sleep_bed_recorded">Bed time recorded.</string>
|
||||
<string name="sleep_wake_recorded">Wake recorded.</string>
|
||||
<string name="sleep_wake_recorded">30-minute awakening recorded.</string>
|
||||
<string name="sleep_get_up_recorded">Get-up recorded.</string>
|
||||
<string name="sleep_medication_recorded">%1$s recorded.</string>
|
||||
<string name="sleep_undo">UNDO</string>
|
||||
|
|
|
|||
|
|
@ -554,7 +554,7 @@
|
|||
<string name="sleep_quick_wake">👁 RÉVEIL</string>
|
||||
<string name="sleep_quick_get_up">☀ LEVÉ</string>
|
||||
<string name="sleep_bed_recorded">Couché enregistré.</string>
|
||||
<string name="sleep_wake_recorded">Réveil enregistré.</string>
|
||||
<string name="sleep_wake_recorded">Réveil de 30 minutes enregistré.</string>
|
||||
<string name="sleep_get_up_recorded">Levé enregistré.</string>
|
||||
<string name="sleep_medication_recorded">%1$s enregistré.</string>
|
||||
<string name="sleep_undo">ANNULER</string>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(32, 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(32, 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(32, 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(32, 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(32, 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(32, 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(32, 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(32, 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(32, 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(32, 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(32, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
1,
|
||||
scalar(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,32 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BluetoothSyncTransportContractTest {
|
||||
@Test
|
||||
fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() {
|
||||
assertTrue(
|
||||
shouldPublishBluetoothArrivalRequest(
|
||||
autoSyncEnabled = true,
|
||||
cooldownReady = true,
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldPublishBluetoothArrivalRequest(
|
||||
autoSyncEnabled = false,
|
||||
cooldownReady = true,
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldPublishBluetoothArrivalRequest(
|
||||
autoSyncEnabled = true,
|
||||
cooldownReady = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
|
||||
assertTrue(
|
||||
|
|
|
|||
|
|
@ -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(32, 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))
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("32", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.labfytools.trainlog.model.SessionSetDraft
|
|||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
|
|
@ -178,4 +179,280 @@ class SessionTimelineRepositoryTest {
|
|||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun programSessionFinalizesMeasuredContinuousTargetAndExtraPerformedSet() {
|
||||
val name = "program-session-finish-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val programId = "pg_11111111-1111-4111-8111-111111111111"
|
||||
val programSessionId = "pgs_22222222-2222-4222-8222-222222222222"
|
||||
val warmupId = "ex_33333333-3333-4333-8333-333333333333"
|
||||
val rowId = "ex_44444444-4444-4444-8444-444444444444"
|
||||
val pressId = "ex_55555555-5555-4555-8555-555555555555"
|
||||
val curlId = "ex_66666666-6666-4666-8666-666666666666"
|
||||
|
||||
fun catalogExercise(
|
||||
exerciseId: String,
|
||||
exerciseName: String,
|
||||
recordingMode: String,
|
||||
trackingMode: String,
|
||||
) =
|
||||
JSONObject()
|
||||
.put("exercise_id", exerciseId)
|
||||
.put("name", exerciseName)
|
||||
.put("recording_mode", recordingMode)
|
||||
.put("tracking_mode", trackingMode)
|
||||
.put("data_fields", 0)
|
||||
|
||||
val catalog =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-pc-catalog")
|
||||
.put("version", 1)
|
||||
.put(
|
||||
"exercises",
|
||||
JSONArray()
|
||||
.put(catalogExercise(warmupId, "Program warmup", "continuous", "duration"))
|
||||
.put(catalogExercise(rowId, "Program row", "sets", "reps"))
|
||||
.put(catalogExercise(pressId, "Program press", "sets", "reps"))
|
||||
.put(catalogExercise(curlId, "Program arm curl", "sets", "reps")),
|
||||
)
|
||||
.toString()
|
||||
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||
|
||||
fun occurrence(
|
||||
entryId: String,
|
||||
exerciseId: String,
|
||||
targetSets: Int?,
|
||||
targetReps: Int?,
|
||||
targetDuration: Int?,
|
||||
targetWeight: Double?,
|
||||
restSeconds: Int,
|
||||
) =
|
||||
JSONObject()
|
||||
.put("entry_id", entryId)
|
||||
.put("exercise_id", exerciseId)
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("load_mode", if (targetWeight == null) "none" else "external")
|
||||
.put("rest_seconds", restSeconds)
|
||||
.put("target_sets", targetSets ?: JSONObject.NULL)
|
||||
.put("target_reps", targetReps ?: JSONObject.NULL)
|
||||
.put("target_duration_seconds", targetDuration ?: JSONObject.NULL)
|
||||
.put("target_weight_kg", targetWeight ?: JSONObject.NULL)
|
||||
.put("notes", JSONObject.NULL)
|
||||
|
||||
val program =
|
||||
JSONObject()
|
||||
.put("program_id", programId)
|
||||
.put("revision_id", "pgr_77777777-7777-4777-8777-777777777777")
|
||||
.put("title", "Session finalization regression")
|
||||
.put("note", JSONObject.NULL)
|
||||
.put("state", "active")
|
||||
.put("start_date", "2026-09-24")
|
||||
.put("end_date", JSONObject.NULL)
|
||||
.put("created_at", "2026-09-24T08:00:00Z")
|
||||
.put("updated_at", "2026-09-24T08:00:00Z")
|
||||
.put("source_format", "trainlog-program")
|
||||
.put("source_version", 1)
|
||||
.put("source_payload_sha256", "a".repeat(64))
|
||||
.put(
|
||||
"sessions",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("program_session_id", programSessionId)
|
||||
.put("title", "Workout")
|
||||
.put("session_type", "training")
|
||||
.put("planned_for", "2026-09-24")
|
||||
.put("note", JSONObject.NULL)
|
||||
.put(
|
||||
"occurrences",
|
||||
JSONArray()
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_88888888-8888-4888-8888-888888888888",
|
||||
warmupId,
|
||||
null,
|
||||
null,
|
||||
480,
|
||||
null,
|
||||
0,
|
||||
),
|
||||
)
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_99999999-9999-4999-8999-999999999999",
|
||||
rowId,
|
||||
3,
|
||||
10,
|
||||
null,
|
||||
39.0,
|
||||
90,
|
||||
),
|
||||
)
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
pressId,
|
||||
3,
|
||||
10,
|
||||
null,
|
||||
32.0,
|
||||
90,
|
||||
),
|
||||
)
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
curlId,
|
||||
2,
|
||||
10,
|
||||
null,
|
||||
23.0,
|
||||
60,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val programs =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-programs")
|
||||
.put("version", 1)
|
||||
.put("generated_at", "2026-09-24T08:01:00Z")
|
||||
.put("programs", JSONArray().put(program))
|
||||
.put("deletions", JSONArray())
|
||||
.toString()
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(programs),
|
||||
)
|
||||
assertEquals(
|
||||
StartProgramSessionResult.Started,
|
||||
repository.startSyncedProgramSession(programId, programSessionId),
|
||||
)
|
||||
|
||||
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val sessionId = checkNotNull(opened.draft.sessionId)
|
||||
val sessionStart = OffsetDateTime.parse(checkNotNull(opened.draft.startedAt))
|
||||
val warmup = opened.draft.exercises[0]
|
||||
val row = opened.draft.exercises[1]
|
||||
val press = opened.draft.exercises[2]
|
||||
val curl = opened.draft.exercises[3]
|
||||
val performed =
|
||||
opened.draft.copy(
|
||||
exercises =
|
||||
listOf(
|
||||
warmup,
|
||||
row.copy(
|
||||
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 39.0) },
|
||||
),
|
||||
press.copy(
|
||||
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 32.0) },
|
||||
),
|
||||
curl.copy(
|
||||
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 23.0) },
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||
|
||||
val warmupStart = sessionStart.plusSeconds(10)
|
||||
val warmupEnd = warmupStart.plusSeconds(433)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(warmup.entryId, warmupStart.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
assertTrue(
|
||||
repository.finishActiveSessionExercise(warmup.entryId, warmupEnd.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
val materialized = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(433, materialized.draft.exercises[0].continuousDurationSeconds)
|
||||
/* Recreate the exact v49 persisted state: its timeline was closed,
|
||||
* but the target-only continuous occurrence had no performed row. */
|
||||
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { db ->
|
||||
db.execSQL(
|
||||
"DELETE FROM draft_continuous_activity WHERE draft_exercise_row_id=(" +
|
||||
"SELECT id FROM draft_session_exercises WHERE draft_id=1 AND entry_id=?)",
|
||||
arrayOf(warmup.entryId),
|
||||
)
|
||||
}
|
||||
val legacy = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(0, legacy.draft.exercises[0].continuousDurationSeconds)
|
||||
|
||||
listOf(row, press, curl).forEachIndexed { index, exercise ->
|
||||
val startedAt = warmupEnd.plusSeconds(30L + index * 90L)
|
||||
val endedAt = startedAt.plusSeconds(60)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(exercise.entryId, startedAt.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
assertTrue(
|
||||
repository.finishActiveSessionExercise(exercise.entryId, endedAt.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
}
|
||||
assertTrue(
|
||||
repository.saveDraftExerciseFeedback(
|
||||
curl.entryId,
|
||||
"Valid effort feedback",
|
||||
warmupEnd.plusSeconds(400).toString(),
|
||||
) is SaveFeedbackResult.Saved,
|
||||
)
|
||||
|
||||
val measurement =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 107,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(620),
|
||||
)
|
||||
val finalizationAt = warmupEnd.plusSeconds(500)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
finalizationAt.minusSeconds(1).toString(),
|
||||
measurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val lateSampleAt = finalizationAt.plusSeconds(10)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
lateSampleAt.toString(),
|
||||
measurement.copy(bpm = 99),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
|
||||
val finalized = repository.finalizeActiveSessionDraft(finalizationAt.toString())
|
||||
assertEquals(
|
||||
sessionId,
|
||||
(finalized as FinalizeActiveDraftResult.Saved).sessionId,
|
||||
)
|
||||
val detail = checkNotNull(repository.getSessionDetail(sessionId))
|
||||
assertEquals(finalizationAt.toString(), detail.summary.endedAt)
|
||||
assertEquals(4, detail.exercises.size)
|
||||
assertEquals(433, detail.exercises[0].continuousDurationSeconds)
|
||||
assertEquals(2, detail.exercises[3].plan?.sets)
|
||||
assertEquals(3, detail.exercises[3].sets.size)
|
||||
assertTrue(detail.exercises[3].sets.all { it.reps == 10 && it.weightKg == 23.0 })
|
||||
assertEquals(1, detail.exercises[3].feedback.size)
|
||||
|
||||
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
|
||||
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
|
||||
assertEquals(finalizationAt.toString(), timelineSession.getString("ended_at"))
|
||||
assertEquals(4, timelineSession.getJSONArray("exercises").length())
|
||||
|
||||
val heart = JSONObject(repository.buildHeartRateV1Json())
|
||||
val capture = heart.getJSONArray("captures").getJSONObject(0)
|
||||
assertEquals(finalizationAt.toString(), capture.getString("ended_at"))
|
||||
assertEquals(1, capture.getJSONArray("samples").length())
|
||||
assertEquals(1, capture.getJSONArray("samples").getJSONObject(0)
|
||||
.getJSONArray("rr_intervals_1024").length())
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||
import com.labfytools.trainlog.model.SleepDiaryEvent
|
||||
|
|
@ -11,6 +13,7 @@ import com.labfytools.trainlog.model.MedicationIntake
|
|||
import com.labfytools.trainlog.model.SleepMedication
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -73,6 +76,141 @@ class SleepDiaryRepositoryTest {
|
|||
} finally { repository.close(); context.deleteDatabase(name) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedSleepRevisionPayloadIsAppendOnly() {
|
||||
val name = "sleep-immutable-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
val saved = try {
|
||||
repository.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-10-24",
|
||||
nightEndDate = "2026-10-25",
|
||||
createdAt = "2026-10-24T22:00:00+02:00",
|
||||
updatedAt = "2026-10-25T07:00:00+01:00",
|
||||
sleepQuality = SleepQuality.B,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = emptyList(),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
val raw = SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READWRITE,
|
||||
)
|
||||
try {
|
||||
assertEquals(33, raw.version)
|
||||
assertThrows(SQLiteException::class.java) {
|
||||
raw.execSQL(
|
||||
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
||||
arrayOf(saved.revisionId),
|
||||
)
|
||||
}
|
||||
assertThrows(SQLiteException::class.java) {
|
||||
raw.execSQL(
|
||||
"DELETE FROM sleep_diary_revisions WHERE revision_id=?",
|
||||
arrayOf(saved.revisionId),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
"B",
|
||||
raw.rawQuery(
|
||||
"SELECT sleep_quality FROM sleep_diary_revisions WHERE revision_id=?",
|
||||
arrayOf(saved.revisionId),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getString(0)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
raw.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successiveSleepEditsKeepUniqueIdentitiesAndExactParentage() {
|
||||
val name = "sleep-revision-chain-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
val identities = mutableListOf<String>()
|
||||
val expectedParents = mutableMapOf<String, String?>()
|
||||
val expectedNotes = mutableMapOf<String, String>()
|
||||
try {
|
||||
val created = repository.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-10-24",
|
||||
nightEndDate = "2026-10-25",
|
||||
createdAt = "2026-10-24T22:00:00+02:00",
|
||||
updatedAt = "2026-10-25T07:00:00+01:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "revision-root",
|
||||
events = emptyList(),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
identities += created.revisionId
|
||||
expectedParents[created.revisionId] = null
|
||||
expectedNotes[created.revisionId] = "revision-root"
|
||||
|
||||
repeat(64) { index ->
|
||||
val current = repository.listSleepDiary().single()
|
||||
val note = "revision-$index"
|
||||
val hour = 8 + index / 60
|
||||
val minute = index % 60
|
||||
val saved = repository.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
entryId = current.entryId,
|
||||
expectedRevision = current.revisionId,
|
||||
nightStartDate = current.nightStartDate,
|
||||
nightEndDate = current.nightEndDate,
|
||||
createdAt = current.createdAt,
|
||||
updatedAt = "2026-10-25T%02d:%02d:00+01:00".format(hour, minute),
|
||||
sleepQuality = current.sleepQuality,
|
||||
wakeQuality = current.wakeQuality,
|
||||
dayForm = current.dayForm,
|
||||
treatmentAndNotes = note,
|
||||
events = current.events,
|
||||
intakes = current.intakes,
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
expectedParents[saved.revisionId] = current.revisionId
|
||||
expectedNotes[saved.revisionId] = note
|
||||
identities += saved.revisionId
|
||||
}
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
|
||||
assertEquals(65, identities.toSet().size)
|
||||
val raw = SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
try {
|
||||
identities.forEach { revisionId ->
|
||||
raw.rawQuery(
|
||||
"SELECT parent_revision_id,treatment_and_notes " +
|
||||
"FROM sleep_diary_revisions WHERE revision_id=?",
|
||||
arrayOf(revisionId),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
val parent = if (cursor.isNull(0)) null else cursor.getString(0)
|
||||
assertEquals(expectedParents[revisionId], parent)
|
||||
assertEquals(expectedNotes[revisionId], cursor.getString(1))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
raw.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ancestryAllowsMultiHopRemoteAdvance() {
|
||||
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
|
||||
|
|
@ -188,6 +326,15 @@ class SleepDiaryRepositoryTest {
|
|||
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
|
||||
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
|
||||
assertEquals("Medication revised", destination.listSleepMedications().single().name)
|
||||
|
||||
val reusedAncestor = org.json.JSONObject(firstSnapshot)
|
||||
reusedAncestor.getJSONArray("entries").getJSONObject(0).put("sleep_quality", "M")
|
||||
assertEquals(
|
||||
TrainlogRepository.SleepDiaryImportResult.Rejected(
|
||||
"sleep diary revision identity reused with different content",
|
||||
),
|
||||
destination.applySleepDiaryV1Json(reusedAncestor.toString()),
|
||||
)
|
||||
} finally {
|
||||
source.close()
|
||||
destination.close()
|
||||
|
|
|
|||
|
|
@ -142,8 +142,18 @@ class SleepQuickCaptureTest {
|
|||
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
|
||||
assertEquals(
|
||||
2,
|
||||
beforeGetUp.events.count { it.type == SleepEventType.NIGHT_GET_UP },
|
||||
beforeGetUp.events.count { it.type == SleepEventType.LONG_AWAKE },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"2026-09-24T02:17:00+02:00" to "2026-09-24T02:47:00+02:00",
|
||||
"2026-09-24T04:42:00+02:00" to "2026-09-24T05:12:00+02:00",
|
||||
),
|
||||
beforeGetUp.events
|
||||
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||
.map { it.startAt to it.endAt },
|
||||
)
|
||||
assertTrue(beforeGetUp.events.none { it.type == SleepEventType.NIGHT_GET_UP })
|
||||
assertEquals(2, beforeGetUp.intakes.size)
|
||||
assertEquals(2, beforeGetUp.intakes.first().quantity)
|
||||
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
|
||||
|
|
@ -217,7 +227,7 @@ class SleepQuickCaptureTest {
|
|||
assertEquals(
|
||||
listOf("2026-09-24T02:00:00+02:00"),
|
||||
night.events
|
||||
.filter { it.type == SleepEventType.NIGHT_GET_UP }
|
||||
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||
.map { it.startAt },
|
||||
)
|
||||
} finally {
|
||||
|
|
@ -226,6 +236,37 @@ class SleepQuickCaptureTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedWakeExtendsOneWindowAndFinalGetUpClampsIt() {
|
||||
val name = "sleep-wake-window-" + UUID.randomUUID()
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
repository.quickSleepBedTime("2026-09-23T22:05:00+02:00")
|
||||
repository.quickSleepWake("2026-09-24T04:11:00+02:00")
|
||||
repository.quickSleepWake("2026-09-24T04:14:00+02:00")
|
||||
|
||||
val extended = repository.listSleepDiary().single()
|
||||
assertEquals(
|
||||
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:44:00+02:00"),
|
||||
extended.events
|
||||
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||
.map { it.startAt to it.endAt },
|
||||
)
|
||||
|
||||
repository.quickSleepFinalGetUp("2026-09-24T04:14:30+02:00")
|
||||
val completed = repository.listSleepDiary().single()
|
||||
assertEquals(
|
||||
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:14:30+02:00"),
|
||||
completed.events
|
||||
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||
.map { it.startAt to it.endAt },
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
|
||||
val name = "sleep-no-sensor-" + UUID.randomUUID()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.labfytools.trainlog.model.SessionDraft
|
|||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||
import com.labfytools.trainlog.model.SleepMedication
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
|
@ -30,6 +31,299 @@ import org.robolectric.annotation.Config
|
|||
class SyncGenerationServiceTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun sleepOnlyNightCrossesDesktopAckReplayAndSecondRevisionWithoutSession() {
|
||||
val sourceName = "sleep-only-generation-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-sleep-only-generation-").toFile()
|
||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||
val repositoryRoot =
|
||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||
it.parentFile
|
||||
}
|
||||
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||
val generationTool =
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
|
||||
var source = TrainlogRepository(context, sourceName)
|
||||
|
||||
fun run(vararg command: String): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"command failed: ${command.joinToString(" ")}\n$output",
|
||||
0,
|
||||
process.waitFor(),
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
try {
|
||||
run(
|
||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
val desktopPeer =
|
||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||
.find(
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"peer-id",
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
)!!
|
||||
.groupValues[1]
|
||||
val medicationCreated =
|
||||
source.saveSleepMedication(
|
||||
SleepMedication(
|
||||
medicationId = "",
|
||||
revisionId = "",
|
||||
createdAt = "2026-09-23T20:00:00+02:00",
|
||||
updatedAt = "2026-09-23T20:00:00+02:00",
|
||||
name = "Synthetic sleep-only medication",
|
||||
defaultDoseValue = 5.0,
|
||||
defaultDoseUnit = "mg",
|
||||
form = "tablet",
|
||||
note = "",
|
||||
active = true,
|
||||
)
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
val pendingRoot =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-09-23",
|
||||
nightEndDate = "2026-09-24",
|
||||
createdAt = "2026-09-23T22:00:00+02:00",
|
||||
updatedAt = "2026-09-23T22:00:00+02:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = emptyList(),
|
||||
intakes = emptyList(),
|
||||
)
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
val preBed =
|
||||
source.quickSleepMedication(
|
||||
medicationCreated.entryId,
|
||||
"2026-09-23T22:20:00+02:00",
|
||||
quantity = 2,
|
||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
assertEquals(pendingRoot.entryId, preBed.receipt.entryId)
|
||||
source.quickSleepMedication(
|
||||
medicationCreated.entryId,
|
||||
"2026-09-23T22:21:00+02:00",
|
||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
source.inSyncGenerationTransaction { db ->
|
||||
assertEquals(
|
||||
2,
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM sleep_medication_intakes WHERE revision_id=" +
|
||||
"(SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?)",
|
||||
arrayOf(preBed.receipt.entryId),
|
||||
).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
|
||||
val bed =
|
||||
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
||||
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
val firstMeasurement =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 61,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = 7,
|
||||
rrIntervals1024 = listOf(1000),
|
||||
)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSleep(
|
||||
"Synthetic HR",
|
||||
"2026-09-23T22:30:01+02:00",
|
||||
firstMeasurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied
|
||||
)
|
||||
assertTrue(
|
||||
source.quickSleepWake("2026-09-24T02:17:00+02:00")
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||
)
|
||||
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSleep(
|
||||
"Synthetic HR",
|
||||
"2026-09-24T02:17:01+02:00",
|
||||
firstMeasurement.copy(bpm = 65, rrIntervals1024 = listOf(980)),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied
|
||||
)
|
||||
assertTrue(
|
||||
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||
)
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
assertTrue(source.listSessions().isEmpty())
|
||||
|
||||
val night = source.listSleepDiary().single()
|
||||
val capture =
|
||||
JSONObject(source.buildHeartRateV1Json())
|
||||
.getJSONArray("captures")
|
||||
.getJSONObject(0)
|
||||
assertEquals(night.entryId, capture.getString("context_id"))
|
||||
assertEquals(2, capture.getJSONArray("samples").length())
|
||||
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
|
||||
|
||||
val service = SyncGenerationService(source)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
val diaryArtifact =
|
||||
JSONObject(java.io.File(captured.stagingDirectory, "sleep-diary-v2.json").readText())
|
||||
val heartArtifact =
|
||||
JSONObject(java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText())
|
||||
val historyArtifact =
|
||||
JSONObject(java.io.File(captured.stagingDirectory, "history-v4.json").readText())
|
||||
assertEquals(0, historyArtifact.getJSONArray("sessions").length())
|
||||
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
|
||||
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
|
||||
assertEquals(night.entryId, publishedNight.getString("entry_id"))
|
||||
assertEquals(2, publishedNight.getJSONArray("intakes").length())
|
||||
assertEquals(
|
||||
listOf(1, 2),
|
||||
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
|
||||
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
|
||||
}.sorted(),
|
||||
)
|
||||
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
|
||||
|
||||
val published = service.publish(captured, java.io.File(root, "android-objects"))
|
||||
val ack = java.io.File(root, "sleep-only-ack.json")
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
ack.absolutePath,
|
||||
)
|
||||
val ackDocument = JSONObject(ack.readText())
|
||||
assertEquals(
|
||||
"desktop ACK diagnostic: ${ackDocument.getString("diagnostic")}",
|
||||
"consumed",
|
||||
ackDocument.getString("result"),
|
||||
)
|
||||
assertEquals("acknowledged", service.acceptAcknowledgement(ack.readBytes()))
|
||||
assertEquals("unchanged", service.acceptAcknowledgement(ack.readBytes()))
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
java.io.File(root, "sleep-only-replay-ack.json").absolutePath,
|
||||
)
|
||||
assertEquals(
|
||||
// The desktop fixture owns one reserved calibration session;
|
||||
// the empty Android history must not add a workout.
|
||||
"1|2|2,1|2|2|1",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
|
||||
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
|
||||
"(SELECT group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
||||
"(SELECT count(*) FROM sessions);",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
|
||||
val staleWithoutIntakes = JSONObject(source.buildSleepDiaryV1Json())
|
||||
val staleEntry = staleWithoutIntakes.getJSONArray("entries").getJSONObject(0)
|
||||
staleEntry.put("revision_id", pendingRoot.revisionId)
|
||||
staleEntry.put("parent_revision_id", JSONObject.NULL)
|
||||
staleEntry.put("ancestry", org.json.JSONArray().put(pendingRoot.revisionId))
|
||||
staleEntry.put("updated_at", "2026-09-23T22:00:00+02:00")
|
||||
staleEntry.put("events", org.json.JSONArray())
|
||||
staleEntry.put("intakes", org.json.JSONArray())
|
||||
val staleResult = source.applySleepDiaryV1Json(staleWithoutIntakes.toString())
|
||||
assertTrue(staleResult is TrainlogRepository.SleepDiaryImportResult.Applied)
|
||||
assertEquals(1, (staleResult as TrainlogRepository.SleepDiaryImportResult.Applied).unchanged)
|
||||
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
|
||||
|
||||
val revised =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
entryId = night.entryId,
|
||||
expectedRevision = night.revisionId,
|
||||
nightStartDate = night.nightStartDate,
|
||||
nightEndDate = night.nightEndDate,
|
||||
createdAt = night.createdAt,
|
||||
updatedAt = "2026-09-24T07:00:00+02:00",
|
||||
sleepQuality = night.sleepQuality,
|
||||
wakeQuality = night.wakeQuality,
|
||||
dayForm = night.dayForm,
|
||||
treatmentAndNotes = "Sleep-only revision two",
|
||||
events = night.events,
|
||||
intakes = night.intakes,
|
||||
)
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(
|
||||
source.validateSleepDiary(
|
||||
revised.entryId,
|
||||
revised.revisionId,
|
||||
"2026-09-24T07:01:00+02:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
)
|
||||
val second = service.capture(root, desktopPeer)
|
||||
val secondPublished =
|
||||
service.publish(second, java.io.File(root, "android-objects-second"))
|
||||
val secondAck = java.io.File(root, "sleep-only-second-ack.json")
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
secondPublished.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
secondAck.absolutePath,
|
||||
)
|
||||
assertEquals("acknowledged", service.acceptAcknowledgement(secondAck.readBytes()))
|
||||
assertEquals(
|
||||
revised.revisionId,
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id='${night.entryId}';",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||
assertTrue(source.listSessions().isEmpty())
|
||||
} finally {
|
||||
source.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
|
||||
val repositoryRoot =
|
||||
|
|
@ -322,6 +616,13 @@ class SyncGenerationServiceTest {
|
|||
destinationService.peerId(),
|
||||
runId,
|
||||
)
|
||||
val retryRun = "sy_${UUID.randomUUID()}"
|
||||
val crossRunResume =
|
||||
SyncGenerationCoordinator(source)
|
||||
.resumableGeneration(retryRun, destinationService.peerId())
|
||||
assertEquals(outbound.generationId, crossRunResume?.generationId)
|
||||
assertEquals(retryRun, crossRunResume?.runId)
|
||||
assertEquals(outbound.manifestSha256, crossRunResume?.manifestSha256)
|
||||
val outboundDirectory =
|
||||
sourceService.publish(outbound, java.io.File(root, "source-objects"))
|
||||
sourceService.acceptAcknowledgement(
|
||||
|
|
@ -353,6 +654,74 @@ class SyncGenerationServiceTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossRunResumeAcceptsExactImmutableAcknowledgementFromOriginalRun() {
|
||||
val generationId = "gen_${UUID.randomUUID()}"
|
||||
val originalRunId = "sy_${UUID.randomUUID()}"
|
||||
val retryRunId = "sy_${UUID.randomUUID()}"
|
||||
val digest = "a".repeat(64)
|
||||
val captured =
|
||||
CapturedSyncGeneration(generationId, retryRunId, digest, java.io.File("unused"))
|
||||
val acknowledgement =
|
||||
JSONObject()
|
||||
.put("run_id", originalRunId)
|
||||
.put("generation_id", generationId)
|
||||
.put("manifest_sha256", digest)
|
||||
|
||||
assertTrue(acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
|
||||
acknowledgement.put("manifest_sha256", "b".repeat(64))
|
||||
assertTrue(!acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ledgerBackedSiblingMakesRetainedWaitingBranchesCapacityTerminal() {
|
||||
val name = "generation-superseded-capacity-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-superseded-").toFile()
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val service = SyncGenerationService(repository)
|
||||
val peer = "peer_11111111-1111-4111-8111-111111111111"
|
||||
val branches = (0 until 4).map { service.capture(root, peer) }
|
||||
val acknowledged = branches.last()
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.execSQL(
|
||||
"UPDATE sync_generations SET parent_generation_id=? WHERE consumer_peer_id=?",
|
||||
arrayOf("gen_22222222-2222-4222-8222-222222222222", peer),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE sync_generations SET status='acknowledged',acknowledged_at=? " +
|
||||
"WHERE generation_id=?",
|
||||
arrayOf("2026-09-24T07:00:00+02:00", acknowledged.generationId),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf(
|
||||
"ack_${UUID.randomUUID()}",
|
||||
acknowledged.generationId,
|
||||
acknowledged.runId,
|
||||
service.peerId(),
|
||||
peer,
|
||||
acknowledged.manifestSha256,
|
||||
"consumed",
|
||||
"sqlite-commit-full",
|
||||
"2026-09-24T07:00:00+02:00",
|
||||
"",
|
||||
"a".repeat(64),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// The three sibling branches remain durable, but the ledger-backed
|
||||
// acknowledged sibling proves that none can be the causal tip.
|
||||
assertEquals(3, branches.dropLast(1).size)
|
||||
assertTrue(service.capture(root, peer).generationId.startsWith("gen_"))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
|
||||
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
|
||||
|
|
@ -509,7 +878,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(32, it.getInt(0))
|
||||
assertEquals(33, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
@ -872,6 +1241,13 @@ class SyncGenerationServiceTest {
|
|||
events = emptyList(),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(
|
||||
source.validateSleepDiary(
|
||||
sleepEntry.entryId,
|
||||
sleepEntry.revisionId,
|
||||
"2026-09-23T07:00:00+02:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||
)
|
||||
val heartCapture =
|
||||
source.startHeartRateCapture(
|
||||
HeartRateContextKind.SLEEP,
|
||||
|
|
@ -906,6 +1282,18 @@ class SyncGenerationServiceTest {
|
|||
.getJSONArray("captures")
|
||||
.length(),
|
||||
)
|
||||
val heartRateCorrections =
|
||||
JSONObject(
|
||||
java.io.File(
|
||||
androidToDesktop.stagingDirectory,
|
||||
"heart-rate-corrections-v1.json",
|
||||
).readText(),
|
||||
)
|
||||
assertEquals(
|
||||
"trainlog-heart-rate-corrections",
|
||||
heartRateCorrections.getString("format"),
|
||||
)
|
||||
assertEquals(0, heartRateCorrections.getJSONArray("corrections").length())
|
||||
val androidPublished =
|
||||
sourceService.publish(
|
||||
androidToDesktop,
|
||||
|
|
@ -932,6 +1320,10 @@ class SyncGenerationServiceTest {
|
|||
"acknowledged",
|
||||
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
"unchanged",
|
||||
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||
|
|
@ -958,6 +1350,69 @@ class SyncGenerationServiceTest {
|
|||
.trim(),
|
||||
)
|
||||
|
||||
/* A second Sleep-only mutation must advance the same diary entry
|
||||
* after the first correlated ACK. No new workout is created, and
|
||||
* replaying either generation remains strictly idempotent. */
|
||||
val revisedSleep =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
entryId = sleepEntry.entryId,
|
||||
expectedRevision = sleepEntry.revisionId,
|
||||
nightStartDate = "2026-09-22",
|
||||
nightEndDate = "2026-09-23",
|
||||
createdAt = "2026-09-22T22:00:00+02:00",
|
||||
updatedAt = "2026-09-23T08:00:00+02:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "second sleep-only revision",
|
||||
events = emptyList(),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(
|
||||
source.validateSleepDiary(
|
||||
revisedSleep.entryId,
|
||||
revisedSleep.revisionId,
|
||||
"2026-09-23T08:01:00+02:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||
)
|
||||
val secondSleepGeneration = sourceService.capture(root, desktopPeer)
|
||||
val secondSleepPublished =
|
||||
sourceService.publish(
|
||||
secondSleepGeneration,
|
||||
java.io.File(root, "android-desktop-objects"),
|
||||
)
|
||||
val secondAckFile = java.io.File(root, "android-desktop-second-ack.json")
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||
"consume-desktop",
|
||||
secondSleepPublished.absolutePath,
|
||||
"--database",
|
||||
desktopConsumerDb.absolutePath,
|
||||
"--ack-output",
|
||||
secondAckFile.absolutePath,
|
||||
)
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
"unchanged",
|
||||
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
"1|${revisedSleep.revisionId}|1",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopConsumerDb.absolutePath,
|
||||
"SELECT count(*) || '|' || current_revision_id || '|' || " +
|
||||
"(SELECT count(*) FROM sessions WHERE session_id='$androidSessionId') " +
|
||||
"FROM sleep_diary_entries WHERE entry_id='${sleepEntry.entryId}';",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
|
||||
run(
|
||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import com.labfytools.trainlog.model.BodyObservationDraft
|
|||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseEditInput
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||
import com.labfytools.trainlog.model.HeartRateRrInterval
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
|
|
@ -56,6 +58,126 @@ class TrainlogRepositoryDraftTest {
|
|||
databaseName = "draft-test-${UUID.randomUUID()}.db"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delayedFactualFinalizationTruncatesHeartRateSamplesAndRrInclusively() {
|
||||
val repo = openRepository()
|
||||
val exercise = createExercise(repo, "Récupération cardio", RecordingMode.SETS, TrackingMode.REPS)
|
||||
val sessionId = "se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
val entryId = "sxe_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
val startedAt = "2026-09-24T10:00:00+02:00"
|
||||
val cutoff = "2026-09-24T11:16:00+02:00"
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repo.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
sessionId = sessionId,
|
||||
startedAt = startedAt,
|
||||
exercises = listOf(
|
||||
SessionExerciseDraft(
|
||||
entryId = entryId,
|
||||
exercise = exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 8, weightKg = 20.0)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val capture = repo.startHeartRateCapture(
|
||||
HeartRateContextKind.SESSION,
|
||||
sessionId,
|
||||
startedAt,
|
||||
"Synthetic",
|
||||
) as TrainlogRepository.StartHeartRateCaptureResult.Started
|
||||
assertTrue(repo.startActiveSessionExercise(
|
||||
entryId, "2026-09-24T10:30:00+02:00",
|
||||
) is TrainlogRepository.SessionExerciseTimingResult.Started)
|
||||
assertTrue(repo.finishActiveSessionExercise(
|
||||
entryId, "2026-09-24T11:15:00+02:00",
|
||||
) is TrainlogRepository.SessionExerciseTimingResult.Finished)
|
||||
listOf(
|
||||
"2026-09-24T11:15:59.999999+02:00" to 100,
|
||||
cutoff to 101,
|
||||
"2026-09-24T11:16:00.000001+02:00" to 40,
|
||||
).forEachIndexed { index, (observedAt, bpm) ->
|
||||
assertTrue(
|
||||
repo.appendHeartRateSample(
|
||||
capture.captureId,
|
||||
observedAt,
|
||||
bpm,
|
||||
rrIntervals = listOf(HeartRateRrInterval(0, 900 + index)),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
FinalizeActiveDraftResult.Saved(sessionId),
|
||||
repo.finalizeActiveSessionDraft(cutoff),
|
||||
)
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(databaseName).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(cutoff, db.rawQuery(
|
||||
"SELECT ended_at FROM heart_rate_captures WHERE capture_id=?",
|
||||
arrayOf(capture.captureId),
|
||||
).use { it.moveToFirst(); it.getString(0) })
|
||||
assertEquals(
|
||||
listOf("2026-09-24T11:15:59.999999+02:00", cutoff),
|
||||
db.rawQuery(
|
||||
"SELECT observed_at FROM heart_rate_samples WHERE capture_id=? " +
|
||||
"ORDER BY sequence",
|
||||
arrayOf(capture.captureId),
|
||||
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } },
|
||||
)
|
||||
assertEquals(2, db.rawQuery(
|
||||
"SELECT COUNT(*) FROM heart_rate_rr_intervals WHERE capture_id=?",
|
||||
arrayOf(capture.captureId),
|
||||
).use { it.moveToFirst(); it.getInt(0) })
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heartRateCorrectionsRemainOutsideFrozenCausalDeletionV1() {
|
||||
val repo = openRepository()
|
||||
repo.buildCausalDeletionExportV1Json()
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(databaseName).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READWRITE,
|
||||
).use { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO sync_causal_operations(" +
|
||||
"operation_id,target_kind,target_id,creator_id,predecessor_revision_id," +
|
||||
"created_at,payload_sha256,publication_context) VALUES(?,?,?,?,?,?,?,NULL)",
|
||||
arrayOf(
|
||||
"hrx_11111111-1111-4111-8111-111111111111",
|
||||
"heart_rate_tail",
|
||||
"hrc_22222222-2222-4222-8222-222222222222|" +
|
||||
"2026-09-24T11:16:00+02:00",
|
||||
"peer_test",
|
||||
"lv_test",
|
||||
"2026-09-24T12:00:00+02:00",
|
||||
"0".repeat(64),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(repo.buildCausalDeletionExportV1Json())
|
||||
.getJSONArray("operations")
|
||||
.length(),
|
||||
)
|
||||
val correction = JSONObject(repo.buildHeartRateCorrectionsV1Json())
|
||||
assertEquals("trainlog-heart-rate-corrections", correction.getString("format"))
|
||||
assertEquals(
|
||||
"hrx_11111111-1111-4111-8111-111111111111",
|
||||
correction.getJSONArray("corrections").getJSONObject(0).getString("operation_id"),
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
repository?.close()
|
||||
|
|
@ -2260,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(32, cursor.getInt(0))
|
||||
assertEquals(33, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2387,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(32, 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())
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
"columns": 12,
|
||||
"max_y": 200,
|
||||
"tiles": [
|
||||
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
|
||||
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
|
||||
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
|
||||
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
|
||||
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
|
||||
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
|
||||
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
|
||||
{ "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
|
||||
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
|
||||
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
|
||||
{ "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
|
||||
{ "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
|
||||
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
|
||||
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ revision. Quick publication makes a current fact eligible for generation; it
|
|||
does not validate a day and does not change the existing validation/ACK
|
||||
lifecycle.
|
||||
|
||||
Android schema v33 enforces that same immutable identity boundary inside
|
||||
SQLite. Eight append-only triggers reject update or deletion of Sleep diary
|
||||
revisions, events, medication intakes, and medication revisions. Repository
|
||||
imports compare a known ancestor's complete canonical payload before the stale
|
||||
ancestor shortcut, so a reused ID with a different parent or content cannot be
|
||||
hidden below a later local tip. All user edits continue to create a fresh
|
||||
UUIDv4 revision and preserve the previous payload and causal parent.
|
||||
Medication-intake writes name all columns explicitly, so the additive
|
||||
`quantity` column cannot reorder note or creation-time values in upgraded
|
||||
schema-v33 databases.
|
||||
|
||||
The quick Sleep capture derives one night identity from the existing
|
||||
18:00-to-18:00 date rule. A medication action can create that pending entry
|
||||
before `BED_TIME`, later medication actions reuse it, and Couché appends
|
||||
|
|
@ -34,6 +45,21 @@ successful intake; the per-unit dose and unit remain independent structured
|
|||
facts. Pending entries persist through restart and can synchronize without
|
||||
inventing bedtime.
|
||||
|
||||
The persistence regression begins from an empty pending revision, records two
|
||||
pre-bed intakes (one with quantity two), and reopens the repository after
|
||||
intake, Couché, Réveil and Levé. It then proves the same stable entry and intake
|
||||
snapshots through generation, desktop import/ACK, exact replay, rejection of a
|
||||
stale intake-free ancestor and a later current revision. This is the executable
|
||||
contract for lifecycle and synchronization retention; it does not infer facts
|
||||
that are absent from a device database.
|
||||
|
||||
The one-tap Réveil action records `long_awake` rather than a point
|
||||
`night_get_up`: its factual start is the tap timestamp and its policy end is 30
|
||||
minutes later. Another tap inside that window extends the same interval to 30
|
||||
minutes after the latest tap; Levé clamps an overlapping interval to the exact
|
||||
final-get-up timestamp. This explicit default requires no Rendormi interaction,
|
||||
remains revisioned/undoable, and is not derived from heart-rate measurements.
|
||||
|
||||
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
|
||||
no capture. After Couché, the first fresh real measurement creates or resumes
|
||||
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
|
||||
|
|
@ -663,7 +689,24 @@ resume action after relaunch.
|
|||
Final save validates the durable draft, inserts the completed session and actual
|
||||
values, and removes the draft in one SQLite transaction. Failure rolls back and
|
||||
retains the draft for retry; repeated completion does not create duplicates.
|
||||
The existing completed-session save-time timestamp behavior is unchanged.
|
||||
Planned targets remain prescriptions: finalization accepts fewer or more valid
|
||||
performed sets and actual repetitions or loads that differ from their targets.
|
||||
Finishing a target-only continuous occurrence with no supplemental fields
|
||||
materializes its performed duration from that occurrence's already persisted
|
||||
timeline start/end interval. This also repairs a preserved draft whose interval
|
||||
was closed by an older build without creating the performed row. Existing
|
||||
performed values are never overwritten, and continuous occurrences that require
|
||||
speed or distance still require those measured values explicitly.
|
||||
|
||||
Ordinary completion uses the save-time timestamp. A private recovery build may
|
||||
embed one exact session identity and one explicit offset-aware factual end time;
|
||||
only that matching draft uses the supplied time. The repository rejects an end
|
||||
before the session start or latest exercise marker. When heart-rate samples
|
||||
arrived after a failed factual session end, delayed finalization keeps samples
|
||||
at or before that inclusive end and transactionally deletes the strict tail.
|
||||
RR children follow their BPM sample through the declared foreign-key cascade,
|
||||
and the stopped capture uses the exact factual end. Retained sample identities
|
||||
and values are never retimed or renumbered.
|
||||
|
||||
PC catalog reconciliation preserves draft references through catalog row
|
||||
ownership. If an editing selection no longer resolves, only the selection is
|
||||
|
|
|
|||
37
docs/assets/trainlog-badges.svg
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Trainlog technology badges</title>
|
||||
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
|
||||
<style>
|
||||
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
|
||||
</style>
|
||||
<g>
|
||||
<rect x="0" y="4" width="172" height="44" rx="12" fill="#1e1e2e" stroke="#b4befe" stroke-width="2"/>
|
||||
<circle cx="24" cy="26" r="6" fill="#b4befe"/>
|
||||
<text class="label" x="100" y="27">LOCAL-FIRST</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="184" y="4" width="116" height="44" rx="12" fill="#1e1e2e" stroke="#89b4fa" stroke-width="2"/>
|
||||
<circle cx="208" cy="26" r="6" fill="#89b4fa"/>
|
||||
<text class="label" x="258" y="27">C17</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="312" y="4" width="160" height="44" rx="12" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2"/>
|
||||
<circle cx="336" cy="26" r="6" fill="#a6e3a1"/>
|
||||
<text class="label" x="405" y="27">ANDROID</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="484" y="4" width="176" height="44" rx="12" fill="#1e1e2e" stroke="#74c7ec" stroke-width="2"/>
|
||||
<circle cx="508" cy="26" r="6" fill="#74c7ec"/>
|
||||
<text class="label" x="584" y="27">LOCAL WEB</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="672" y="4" width="132" height="44" rx="12" fill="#1e1e2e" stroke="#cba6f7" stroke-width="2"/>
|
||||
<circle cx="696" cy="26" r="6" fill="#cba6f7"/>
|
||||
<text class="label" x="753" y="27">GPLv3</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/>
|
||||
<circle cx="840" cy="26" r="6" fill="#fab387"/>
|
||||
<text class="label" x="896" y="27">v0.1.6</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
66
docs/assets/trainlog-banner.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="480" viewBox="0 0 1440 480" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Trainlog</title>
|
||||
<desc id="desc">A Catppuccin Mocha banner for a local-first training data system.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#11111b"/>
|
||||
<stop offset="0.58" stop-color="#181825"/>
|
||||
<stop offset="1" stop-color="#1e1e2e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="lavender" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#b4befe"/>
|
||||
<stop offset="1" stop-color="#cba6f7"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M40 0H0V40" fill="none" stroke="#45475a" stroke-width="1" opacity="0.22"/>
|
||||
</pattern>
|
||||
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="7" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1440" height="480" rx="28" fill="url(#bg)"/>
|
||||
<rect width="1440" height="480" rx="28" fill="url(#grid)"/>
|
||||
<path d="M0 25Q0 0 25 0H1415Q1440 0 1440 25" fill="none" stroke="#b4befe" stroke-width="4"/>
|
||||
|
||||
<g transform="translate(72 64)">
|
||||
<circle cx="12" cy="12" r="7" fill="#f38ba8"/>
|
||||
<circle cx="36" cy="12" r="7" fill="#f9e2af"/>
|
||||
<circle cx="60" cy="12" r="7" fill="#a6e3a1"/>
|
||||
<text x="90" y="19" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">~/trainlog</text>
|
||||
|
||||
<text x="0" y="164" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="92" font-weight="700" letter-spacing="10">TRAINLOG</text>
|
||||
<rect x="2" y="190" width="610" height="5" rx="2.5" fill="url(#lavender)"/>
|
||||
<text x="0" y="243" fill="#b4befe" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="27" letter-spacing="1">capture · synchronize · inspect · understand</text>
|
||||
<text x="0" y="293" fill="#a6adc8" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">Android / C17 + Notcurses / Local Web / SQLite</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(890 72)">
|
||||
<rect width="470" height="336" rx="22" fill="#11111b" stroke="#45475a" stroke-width="2"/>
|
||||
<path d="M0 58H470" stroke="#45475a" stroke-width="2"/>
|
||||
<circle cx="28" cy="29" r="6" fill="#f38ba8"/>
|
||||
<circle cx="50" cy="29" r="6" fill="#f9e2af"/>
|
||||
<circle cx="72" cy="29" r="6" fill="#a6e3a1"/>
|
||||
<text x="96" y="36" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">facts.timeline</text>
|
||||
|
||||
<path d="M34 222H436" stroke="#313244" stroke-width="2"/>
|
||||
<path d="M34 118H436M34 170H436M34 274H436" stroke="#313244" stroke-width="1" stroke-dasharray="5 8"/>
|
||||
<path d="M34 221H78L94 219L111 145L128 263L147 212H188L207 194L226 205L245 114L264 248L286 218H327L345 176L364 203L384 202L402 155L420 221H436" fill="none" stroke="#b4befe" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)"/>
|
||||
<circle cx="111" cy="145" r="6" fill="#f38ba8"/>
|
||||
<circle cx="245" cy="114" r="6" fill="#f38ba8"/>
|
||||
<circle cx="402" cy="155" r="6" fill="#a6e3a1"/>
|
||||
|
||||
<rect x="34" y="292" width="112" height="24" rx="12" fill="#313244"/>
|
||||
<circle cx="50" cy="304" r="5" fill="#a6e3a1"/>
|
||||
<text x="63" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">local</text>
|
||||
<rect x="158" y="292" width="136" height="24" rx="12" fill="#313244"/>
|
||||
<circle cx="174" cy="304" r="5" fill="#89b4fa"/>
|
||||
<text x="187" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">versioned</text>
|
||||
<rect x="306" y="292" width="130" height="24" rx="12" fill="#313244"/>
|
||||
<circle cx="322" cy="304" r="5" fill="#b4befe"/>
|
||||
<text x="335" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">factual</text>
|
||||
</g>
|
||||
|
||||
<rect x="1" y="1" width="1438" height="478" rx="27" fill="none" stroke="#313244" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -10,6 +10,16 @@ the wired recovery path. The separately configured private Drive transport
|
|||
remains available to its legacy mirror/fallback workflow. No transport copies
|
||||
SQLite.
|
||||
|
||||
Automatic Bluetooth restart recovery preserves the immutable generation
|
||||
identity across conversation-run changes and recognizes the exact original-run
|
||||
ACK by immutable generation ID and manifest digest before full SQLite
|
||||
validation. An unacknowledged branch is excluded
|
||||
from the bounded admission count only when a different sibling has a durable,
|
||||
ledger-backed consumed acknowledgement; the superseded bytes and row remain
|
||||
retained. The desktop service installer retires the exact obsolete private
|
||||
ExecStart override that omitted required runtime paths and installs both
|
||||
`trainlog-syncd` and the BlueZ RFCOMM agent.
|
||||
|
||||
Android synchronization paths share the same concurrency-guarded generation
|
||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
||||
arbiter: explicit foreground intent has priority over automatic background
|
||||
|
|
@ -55,6 +65,12 @@ occurrences: opening an existing stable entry renders its prefilled form,
|
|||
keeps the independent target intact on save, and leaves sibling occurrences
|
||||
unchanged on save or cancellation. A set-based target-only occurrence can add
|
||||
or change equipment without fabricating a performed set.
|
||||
Session completion now also turns a completed target-only continuous timeline
|
||||
into its measured performed duration when no supplemental field is required.
|
||||
Program targets remain independent from facts, so a valid execution may contain
|
||||
fewer or more sets and different repetitions or loads. A strictly session-bound
|
||||
private recovery build can supply a past factual end without retiming exercise
|
||||
markers or immutable heart-rate samples.
|
||||
|
||||
The full-generation path is operational on the paired daily installation.
|
||||
Bluetooth Classic is the primary local byte transport, MTP remains the wired
|
||||
|
|
@ -65,8 +81,8 @@ is idempotent and no transport carries SQLite. The Bluetooth transport does not
|
|||
yet ingest heart-rate sensor data.
|
||||
|
||||
The public Android release chain advances from v0.1.1 versionCode 2 through
|
||||
v0.1.2 versionCode 3 and v0.1.3 versionCode 4 to v0.1.4 versionCode 5 under
|
||||
certificate SHA-256
|
||||
v0.1.2 versionCode 3, v0.1.3 versionCode 4, v0.1.4 versionCode 5 and v0.1.5
|
||||
versionCode 6 to v0.1.6 versionCode 7 under certificate SHA-256
|
||||
`5ef41117da107c9805ae3215c5ec823cff06216610e6e4f7542a754161c71bd2`.
|
||||
Higher-numbered private validation builds used a separate Android Debug
|
||||
certificate and are not members of the public update-signature chain.
|
||||
|
|
@ -81,7 +97,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
|
|||
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
||||
mixed-checkout user-service execution during the controlled rollout.
|
||||
|
||||
Snapshot date: **2026-09-23**.
|
||||
Snapshot date: **2026-09-25**.
|
||||
|
||||
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
||||
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
||||
|
|
@ -112,11 +128,11 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
|
||||
| Android SQLite | schema v32; v32 additively gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
|
||||
| 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.5` stable, synchronized across Android and desktop; latest stable release: `v0.1.5` |
|
||||
| Trainlog product version | `0.1.6` stable, synchronized across Android and desktop; latest stable release: `v0.1.6` |
|
||||
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
||||
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
||||
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
||||
|
|
@ -148,10 +164,11 @@ parallel implementation of its rules.
|
|||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
||||
| Recovered session HR tail | `TRAINLOG_HR_TRUNCATE_AFTER_SESSION_END_V1=PASS`: delayed Android finalization truncates BPM/RR strictly after the inclusive factual end, and the separate `trainlog-heart-rate-corrections` V1 companion reuses causal state without widening frozen causal-deletions V1. The paired-device recovery for `se_fa845379-794d-4bb6-8cd3-48187ebbd134` retained 3,429 BPM/RR facts through 11:16; two successive full MTP exchanges received consumed ACKs, and replay of the original 4,919-sample Heart Rate V1 artifact did not resurrect the tail. |
|
||||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
||||
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
||||
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
|
||||
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: existing one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
|
||||
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. Réveil now creates a structured 30-minute `long_awake` window, repeated taps extend it, and Levé clamps it. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
|
||||
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
|
||||
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
|
||||
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
|
||||
|
|
@ -159,7 +176,11 @@ parallel implementation of its rules.
|
|||
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
|
||||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
||||
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
||||
| Current operational cursor | Stable v0.1.5 closes the cardio and Sleep Tonight cycle; subsequent development opens as 0.1.6 after the immutable release tag. |
|
||||
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
|
||||
| Current operational cursor | Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization recovery and factual session-finalization cycle; subsequent stabilization opens as 0.1.7 after the immutable release tag. |
|
||||
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
|
||||
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
|
||||
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
@ -173,12 +194,32 @@ Desktop and Android schema numbers are independent. Neither changes the frozen
|
|||
Trainlog JSON V1 contract.
|
||||
|
||||
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 the selected stable Sleep entry drives the full-width
|
||||
heart-rate detail. Its rise/fall overlays are descriptive relative measured
|
||||
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 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.
|
||||
The detail shares one normalized time projection with declared sleep and
|
||||
awakening intervals, exact Couché/Réveil/Levé markers, medication markers, and
|
||||
full-hour ticks between exact visible bounds. Medication presentation retains
|
||||
unit dose and quantity as separate facts. A missing declared sleep interval
|
||||
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
|
||||
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
|
||||
data. Agenda, selected-night graph, and ranged PDF consume the same projection of
|
||||
sleep intervals, every structured long-awake interval, point events, medication
|
||||
intakes, and bounds. The agenda renders an estimated interval with distinct
|
||||
hatching, never substitutes it for a factual interval, and retains the exact
|
||||
shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by
|
||||
every row, with each hour label centered inside the cell it names. Displayed
|
||||
events use one factual night offset even when revision metadata is UTC, and
|
||||
French agenda headings use `DD/MM/YYYY`. Displayed sleep duration is the sum of factual
|
||||
sleep intervals or, in their absence, the displayed estimate minus overlapping
|
||||
structured awakenings. The overlays remain available without an HR capture and
|
||||
do not fabricate BPM.
|
||||
|
||||
Language is device-local presentation state. Android keeps it in dedicated
|
||||
SharedPreferences; desktop keeps it in the XDG configuration file
|
||||
|
|
@ -306,6 +347,53 @@ the result. The Web aggregate now exposes the distinct factual snapshot through
|
|||
|
||||
## Local Web
|
||||
|
||||
The 0.1.6 refinement adds a persistent French/English preference and a dedicated
|
||||
Settings route without moving the global synchronization action. Dashboard
|
||||
activity is grouped by calendar month/ISO week, its progression tile is now a
|
||||
global factual synthesis, and Analyse progresses from primary BODY ZONE to
|
||||
exercise to individual detail. Exercise work time is derived exclusively from
|
||||
`session_exercise_timeline` start/end facts; missing timelines remain unknown.
|
||||
Measurements first expose unit-safe relative change cards before real-unit
|
||||
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
|
||||
|
||||
The Dashboard layout keeps a 12-column canonical grid but permits every card
|
||||
to reach full width and several useful heights; per-card minima, overlap
|
||||
validation, exact save/cancel/reset semantics, and keyboard resizing remain
|
||||
authoritative. Settings and synchronization form the rightmost Header action
|
||||
cluster. Program progress uses the shared success color while retaining its
|
||||
factual completed/total text. Responsive charts preserve their viewBox aspect
|
||||
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
|
||||
partial measured coverage instead of deriving time from sets or repetitions.
|
||||
The shared Sleep agenda owns one label/track/facts grid. Its 25 exact hourly
|
||||
positions and every event, interval and medication marker use the same
|
||||
coordinates: 18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00
|
||||
at 100%. Ordinary desktop widths show the 13 two-hour labels, wide layouts show
|
||||
all hours, and narrow layouts retain the five six-hour anchors without changing
|
||||
the underlying geometry.
|
||||
|
||||
The Equipment route projects supplied and custom definitions with exercise,
|
||||
history, preparation and Program reference counts. Its guarded merge accepts a
|
||||
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
|
||||
transaction, preserves occurrence/MAX facts indirectly through occurrence
|
||||
identity, and writes the existing causal deletion boundary. An immutable
|
||||
execution-draft JSON reference rejects the operation rather than being edited.
|
||||
Browser mutation identities use the shared UUIDv4 helper backed by
|
||||
`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`,
|
||||
so `http://trainlog.perf` remains supported.
|
||||
|
||||
An `execution_drafts` row marked active but containing no `exercises` or
|
||||
legacy `entries` is an orphaned projection, not a resumable workout. Web may
|
||||
delete that exact row through the existing revision-guarded causal transaction;
|
||||
the durable tombstone prevents synchronization replay from resurrecting it.
|
||||
An active draft with any occurrence remains protected from Web deletion.
|
||||
|
||||
Generation admission archives only rows backed by an exact correlated consumed
|
||||
ACK and retains the newest two acknowledged lineage members. Legacy terminal
|
||||
Android rows that predate the durable ACK ledger are neither fabricated nor
|
||||
deleted, but no longer consume the active admission window. Captured,
|
||||
published, waiting-ACK, and current acknowledged rows continue to count until
|
||||
safe archival, so capacity recovery never prunes unacknowledged evidence.
|
||||
|
||||
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
|
||||
browser shell, Dashboard, layout ownership, build/runtime separation, and
|
||||
security invariants are canonical. The CLI/local HTTP infrastructure and
|
||||
|
|
@ -425,8 +513,7 @@ The full Android lint comparison against an isolated clean `main` worktree is
|
|||
unchanged at ten pre-existing errors: one `NewApi`, one
|
||||
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
||||
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
||||
The stable Android v0.1.4 tag uses `versionName=0.1.4` and public versionCode 5.
|
||||
The v0.1.5 development source starts at `versionName=0.1.5` and versionCode 6.
|
||||
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
|
||||
Private validation builds may still supply only a strictly increasing
|
||||
`versionCode` as a build-time override; that does not create a public release
|
||||
or change schemas and protocols.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,39 @@
|
|||
# Desktop database
|
||||
|
||||
## Causal heart-rate tail correction (schema v36, no migration)
|
||||
|
||||
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
||||
`sync_causal_operations` and `sync_causal_state` tables for the additive local
|
||||
`heart_rate_tail` state. The frozen `trainlog-causal-deletions` V1 artifact
|
||||
continues to emit and accept exactly its original seven target kinds. A heart-
|
||||
rate correction's composite target contains the stable capture identity and an
|
||||
inclusive offset-aware cutoff. Applying it deletes only BPM samples strictly
|
||||
after the cutoff; declared RR foreign keys cascade those exact children, and
|
||||
the capture end becomes the cutoff in the same transaction. The predecessor
|
||||
revision hashes the original ordered raw capture, including RR values, so a
|
||||
correction cannot be silently applied to a different measurement history.
|
||||
|
||||
The immutable Heart Rate V1 generation remains audit evidence. Import applies
|
||||
known causal cutoffs before stable-identity comparison or insertion, which
|
||||
makes old-generation replay idempotent without rewriting its manifest or ACK.
|
||||
No schema or frozen `TRAINLOG_FORMAT_V1` change is involved.
|
||||
|
||||
## Schema v36: append-only Sleep revision payloads
|
||||
|
||||
Schema v36 adds database-level `BEFORE UPDATE` and `BEFORE DELETE` guards for
|
||||
Sleep diary revisions, their events and medication intakes, and medication
|
||||
catalog revisions. A stable revision identity therefore cannot be detached
|
||||
from its canonical parent or payload even through a direct SQLite write.
|
||||
Normal correction remains append-only: it inserts a fresh UUIDv4 revision and
|
||||
advances the entry or medication current pointer transactionally. The migration
|
||||
is additive and does not rewrite any existing Sleep identity, payload, causal
|
||||
edge, generation membership, publication state, or acknowledgement.
|
||||
|
||||
Importers additionally compare the immutable payload of every already-known
|
||||
Sleep revision before treating an incoming ancestor as stale/idempotent. Thus
|
||||
an exact historical replay remains accepted, while reuse of the same identity
|
||||
with a different parent or content is an explicit collision.
|
||||
|
||||
## Schema v35: Sleep Diary quantity V2
|
||||
|
||||
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required
|
||||
|
|
|
|||
|
|
@ -320,14 +320,15 @@ name/dose/unit snapshot. Full editing remains available later.
|
|||
|
||||
### Réveil
|
||||
|
||||
There is no `Rendormi` button.
|
||||
There is no `Rendormi` button. One tap records a structured `long_awake`
|
||||
interval beginning at the factual tap time and ending 30 minutes later under
|
||||
the explicit capture policy. Nothing else is required before the user tries to
|
||||
return to sleep. A second tap within that window resets its end to 30 minutes
|
||||
after the latest tap. Levé truncates an overlapping interval to the factual
|
||||
final-get-up timestamp.
|
||||
|
||||
One tap records one punctual night wake event. Nothing else is required before
|
||||
the user tries to return to sleep.
|
||||
|
||||
Trainlog does not infer a certain return-to-sleep time from BPM or RR data.
|
||||
An approximate interval may be completed later only if the user chooses to
|
||||
provide it.
|
||||
The 30-minute end is user-approved product policy, not an inference from BPM or
|
||||
RR data. Later correction remains available when the actual duration is known.
|
||||
|
||||
### Levé
|
||||
|
||||
|
|
@ -347,11 +348,11 @@ Undo is scoped to that exact action and must not roll back unrelated newer
|
|||
state.
|
||||
|
||||
`Corriger la nuit` provides later editing of timestamps, medication
|
||||
intakes, accidental wake points, forgotten events, and existing subjective
|
||||
intakes, awakening intervals, forgotten events, and existing subjective
|
||||
Sleep Diary fields.
|
||||
|
||||
The night-time rule is: one tap, one timestamp, then no further required
|
||||
interaction.
|
||||
The night-time rule is: one tap, one 30-minute awakening window, then no further
|
||||
required interaction.
|
||||
|
||||
## Sleep + cardio factual timeline
|
||||
|
||||
|
|
@ -361,7 +362,7 @@ After synchronization, Analyse may superpose factual events and measurements:
|
|||
|
||||
- Couché;
|
||||
- medication intakes;
|
||||
- wake points;
|
||||
- structured 30-minute awakening windows;
|
||||
- Levé;
|
||||
- BPM;
|
||||
- RR when supplied;
|
||||
|
|
|
|||
|
|
@ -691,6 +691,30 @@ enumerate at most 4096 operations plus one sentinel row, and a local deletion
|
|||
is refused before its effect if the complete protection artifact would exceed
|
||||
either bound.
|
||||
|
||||
The V1 `target_kind` set remains the frozen seven-kind set: `session`,
|
||||
`execution_draft`, `exercise`, `body_observation`, `custom_equipment`,
|
||||
`feedback`, and `body_zone_relation`. Heart-rate correction does not widen this
|
||||
published contract.
|
||||
|
||||
## Heart-rate corrections V1
|
||||
|
||||
`trainlog-heart-rate-corrections` V1 is a separate optional Android→desktop
|
||||
full-generation companion named `heart-rate-corrections`, stored as
|
||||
`heart-rate-corrections-v1.json`. Its root contains `format`, integer `version`,
|
||||
`generated_at`, and at most 256 `corrections`; the complete UTF-8 artifact is
|
||||
limited to 4 MiB. Each correction contains a stable UUIDv4-based `operation_id`,
|
||||
stable `capture_id`, inclusive offset-aware `cutoff`, `creator_id`, exact
|
||||
`predecessor_revision_id`, `created_at`, and canonical `payload_sha256`.
|
||||
|
||||
The predecessor hashes the complete original capture metadata, ordered raw BPM
|
||||
samples, and ordered RR children. The consumer requires the cutoff to equal the
|
||||
canonical session end and not precede the session start or latest exercise end.
|
||||
It deletes only samples strictly after the cutoff in one transaction, relies on
|
||||
the declared RR cascade, records durable causal state, and applies that state
|
||||
before any Heart Rate V1 snapshot in the same generation. Unknown fields,
|
||||
duplicate keys/identities, digest mismatch, ancestry mismatch, and incompatible
|
||||
capture/session ownership reject the containing generation atomically.
|
||||
|
||||
## Staged generation manifest and acknowledgement V1
|
||||
|
||||
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ than later development. GitHub and Forgejo publication must preserve the
|
|||
existing release, tag, status, and assets while keeping their note bodies equal
|
||||
to the corresponding source file.
|
||||
|
||||
- [Trainlog v0.1.6](v0.1.6.md)
|
||||
- [Trainlog v0.1.5](v0.1.5.md)
|
||||
- [Trainlog v0.1.4](v0.1.4.md)
|
||||
- [Trainlog v0.1.3](v0.1.3.md)
|
||||
|
|
|
|||
64
docs/releases/v0.1.6.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
Trainlog v0.1.6 closes the Web refinement and synchronization-recovery cycle.
|
||||
It improves factual Sleep and training presentation, preserves immutable
|
||||
history through difficult replay and recovery cases, and gives the repository
|
||||
a durable Catppuccin-based project entry point without widening frozen product
|
||||
contracts.
|
||||
|
||||
## Local Web refinement
|
||||
|
||||
- Dashboard activity uses calendar months and ISO weeks, progression is a
|
||||
factual global synthesis, and Analyse drills from BODY ZONES to exercises and
|
||||
measured detail without inventing missing exercise time.
|
||||
- A persistent French/English preference, Settings route, denser shared
|
||||
surfaces, responsive charts, and a shared BODY ZONES figure refine the local
|
||||
interface while presentation state remains outside business SQLite.
|
||||
- Equipment administration can transactionally merge a custom duplicate into
|
||||
a canonical identity while preserving history, MAX context, preparations,
|
||||
Programs, and causal deletion evidence.
|
||||
|
||||
## Sleep facts and heart-rate timelines
|
||||
|
||||
- The multi-night agenda, selected-night heart-rate detail, and vector PDF use
|
||||
one temporal projection of factual sleep intervals, awakenings, point events,
|
||||
medication occurrences, and exact bounds.
|
||||
- The shared 18:00-to-18:00 agenda keeps exact hourly geometry at desktop and
|
||||
narrow widths. A clearly labelled fallback estimate is presentation-only,
|
||||
non-medical, and never written to Sleep data.
|
||||
- Android wake capture records revisioned long-awake intervals, medication
|
||||
quantity survives persistence and synchronization, and Sleep revision
|
||||
payloads are append-only on both platforms.
|
||||
|
||||
## Synchronization and recovery
|
||||
|
||||
- Bluetooth restart recovery retains immutable generation identity and accepts
|
||||
the exact original generation/digest acknowledgement across conversation
|
||||
runs; direct MTP remains the wired recovery transport.
|
||||
- Android foreground and automatic synchronization share one ownership arbiter,
|
||||
and direct-MTP publication waits for MediaProvider visibility before exposing
|
||||
a mutable generation reference.
|
||||
- Divergent Sleep revision identities can be recovered without choosing an
|
||||
arbitrary winner. Strict payload checks reject reuse of a historical revision
|
||||
identity with different parentage or content.
|
||||
- A separate heart-rate correction companion removes samples strictly after a
|
||||
factual session end and prevents an older immutable generation from
|
||||
resurrecting the removed tail.
|
||||
|
||||
## Compatibility and validation
|
||||
|
||||
- Desktop schema v36 and Android schema v33 are additive migrations.
|
||||
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, generation/ACK
|
||||
semantics, and Bluetooth/MTP transport ownership remain unchanged.
|
||||
- The default automatic exchange remains V3; full-generation synchronization
|
||||
remains behind its trusted local opt-in.
|
||||
- Native Meson and Clang ASan/UBSan suites each pass 114/114. Android passes
|
||||
317 tests (312 passed and five documented optional skips), debug assembly,
|
||||
signed release assembly, and signature verification. Web passes 32 files / 209
|
||||
tests plus typecheck/build; JSON/import validation, changed-C formatting, and
|
||||
diff checks also pass at the release tag.
|
||||
|
||||
## Assets
|
||||
|
||||
- `trainlog-android-v0.1.6.apk`: signed public Android APK.
|
||||
- `trainlog-tui-linux-x86_64-v0.1.6`: Linux x86-64 runtime bundle containing
|
||||
the TUI, local Web adapter, synchronization helpers, and required catalogs.
|
||||
- `SHA256SUMS`: SHA-256 checksums for both artifacts.
|
||||
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 138 KiB |
|
|
@ -13,7 +13,7 @@ endobj
|
|||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Length 17659 >>
|
||||
<< /Length 17665 >>
|
||||
stream
|
||||
0 G 0 g
|
||||
0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET
|
||||
|
|
@ -53,8 +53,8 @@ stream
|
|||
0 g BT /F1 4 Tf 728.0 552.0 Td (TRAITEMENT ET) Tj ET
|
||||
0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) Tj ET
|
||||
0.7 G 30 498 782 40 re S
|
||||
0 g BT /F1 5 Tf 33.0 525.0 Td (20/09/2026) Tj ET
|
||||
0 g BT /F1 5 Tf 33.0 515.0 Td (au 21/09/2026) Tj ET
|
||||
0 g BT /F1 5 Tf 33.0 525.0 Td (23/09/2026) Tj ET
|
||||
0 g BT /F1 5 Tf 33.0 515.0 Td (au 24/09/2026) Tj ET
|
||||
0.9 G 112 498 m 112 538 l S
|
||||
0.9 G 132.83333333333334 498 m 132.83333333333334 538 l S
|
||||
0.9 G 153.66666666666666 498 m 153.66666666666666 538 l S
|
||||
|
|
@ -133,8 +133,8 @@ stream
|
|||
0 g BT /F1 7 Tf 620.0 482.0 Td (B) Tj ET
|
||||
0 g BT /F1 7 Tf 658.0 482.0 Td (TB) Tj ET
|
||||
0 g BT /F1 7 Tf 696.0 482.0 Td (Moy) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg | Nuit de co) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 482.0 Td (ntr\364le g\351om\351trique.) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg \3272 | Nuit de) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 482.0 Td ( contr\364le g\351om\351trique.) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 473.0 Td () Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 464.0 Td () Tj ET
|
||||
0.7 G 30 418 782 40 re S
|
||||
|
|
@ -383,5 +383,5 @@ xref
|
|||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
18059
|
||||
18065
|
||||
%%EOF
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 140 KiB |
103
docs/reviews/sleep_awake_selection_pdf_v1_evidence.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Sleep awake selection and PDF V1 evidence
|
||||
|
||||
`TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS` on 2026-09-24.
|
||||
|
||||
The Web now derives the agenda, selected-night heart-rate overlay, and vector
|
||||
PDF rows from one pure temporal projection. It retains every structured
|
||||
`long_awake` interval separately from point-in-time `night_get_up` events,
|
||||
medication intakes, and sleep ranges. Factual `sleep` intervals determine sleep
|
||||
duration when present. Otherwise the displayed Couché +45 minutes to Levé -10
|
||||
minutes range supplies a presentation-only duration after subtracting the union
|
||||
of overlapping structured awakenings; no estimate is persisted.
|
||||
|
||||
One agenda row is the effective selection. A click updates the detail dates,
|
||||
editor snapshot, and HR context in memory without issuing a write. PDF preview
|
||||
and download receive the same explicit inclusive range over each entry's
|
||||
`night_start_date`, and all observations are recomputed from that subset. The
|
||||
range initially spans the loaded agenda period; later detail/HR selection does
|
||||
not alter it. Each of the 24 hour labels is centered in the equal visual cell
|
||||
it names, using the same grid beneath every 18:00-to-18:00 agenda row; there is
|
||||
no ambiguous duplicate `18` at the right edge.
|
||||
|
||||
A real-data follow-up found that using UTC revision `created_at` as the visual
|
||||
offset source moved `+02:00` events two hours left. The agenda now derives one
|
||||
row-wide offset from factual bedtime/event data. Regression coverage mixes UTC
|
||||
metadata with `+02:00` events and keeps the 04:45 final get-up at its local
|
||||
04:45 position. French night headings render `DD/MM/YYYY`.
|
||||
|
||||
## Automated validation
|
||||
|
||||
- Web targeted suites: 34/34 passed.
|
||||
- Complete Web suite after the range/axis follow-up: 209/209 passed.
|
||||
- `npm run typecheck`: passed.
|
||||
- `npm run build`: passed.
|
||||
- Embedded production bundle `meson compile -C build`: passed.
|
||||
- The tests cover multiple awakening intervals in agenda/HR projection, exact
|
||||
factual sleep duration, estimated duration with awakening subtraction,
|
||||
selection dates/HR identity, no write on selection, identical preview/export
|
||||
subsets, PDF interval geometry, medications, point markers, and no-HR
|
||||
fallback without an invented BPM curve.
|
||||
|
||||
## Read-only real-data smoke
|
||||
|
||||
The rebuilt user Web service was exercised through real headless Firefox at
|
||||
`http://trainlog.perf/analyse?section=sleep` without a Sleep mutation.
|
||||
|
||||
- The real 2026-09-23 → 2026-09-24 row contains no factual `sleep` interval;
|
||||
its displayed estimated band now reports `5 h 14 min 16 s`, not `—`.
|
||||
- The real 2026-09-22 → 2026-09-23 row renders its structured awakening in
|
||||
both the agenda and the no-invented-HR detail overlay.
|
||||
- Selecting that row updates the detail inputs to exactly 2026-09-22 and
|
||||
2026-09-23.
|
||||
- Firefox export produced one PDF page whose extracted text contains only
|
||||
`22/09/2026`, `au 23/09/2026`, and `1 nuit`; it contains no 24/09 row.
|
||||
- The selected-subset observation is `Sommeil déclaré : 6 h`,
|
||||
`Longs réveils : 1`, `Siestes : 1`, and `Prises de médicaments : 7`.
|
||||
- Raster inspection confirms the long-awake band remains visibly distinct
|
||||
inside the sleep timeline and point/medication markers retain their positions.
|
||||
|
||||
The original Web change affected no database schema, synchronization artifact,
|
||||
frozen format, or Android behavior; the follow-up below deliberately corrects
|
||||
the Android capture semantics without changing those compatibility boundaries.
|
||||
|
||||
## Android capture correction and real-night repair
|
||||
|
||||
Follow-up visual review established that Android one-tap Réveil had produced
|
||||
three punctual `night_get_up` facts for 2026-09-23 → 2026-09-24, so there was
|
||||
no structured interval for the shared projection to color. The user selected
|
||||
an explicit 30-minute no-further-action policy. Future taps now create a
|
||||
`long_awake` interval from the factual tap through +30 minutes; another tap
|
||||
inside the window extends that same interval, while Levé clamps it to the
|
||||
factual final-get-up timestamp. This is product policy, not BPM/RR inference.
|
||||
|
||||
Before repair, separate desktop and Android backups were retained under
|
||||
`~/.local/share/trainlog/backups/sleep-wake-window-20260924T0733/`:
|
||||
|
||||
- desktop SHA-256: `ac2246dc699a5e11e9d8b52c4cad0f096dfc08191f54b6a86c44ce72f623c39c`;
|
||||
- Android SHA-256: `a1a9017875757f27264556ea62a9d27a98f2efad17ca0234fb10f4095170a98a`.
|
||||
|
||||
The real night was advanced causally, retaining its stable `entry_id`, to:
|
||||
|
||||
- `01:35:43.554104 → 02:05:43.554104`;
|
||||
- `04:11:59.245980 → 04:14:28.358915` (bounded by Levé).
|
||||
|
||||
Android and desktop current revision is
|
||||
`slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa`. Its repaired linear ancestry
|
||||
retains desktop revision `slr_2bbf573d-4262-49b0-896e-1bcabf3b002a`, including
|
||||
the existing `TB` sleep and wake qualities.
|
||||
Android database round-trip hashes matched, integrity/foreign-key checks passed,
|
||||
and the non-destructive app update preserved first-install identity while
|
||||
advancing the private build to versionCode 49.
|
||||
|
||||
The first post-repair full-generation attempts correctly rejected an already
|
||||
published Android archive whose embedded ancestry still named the pre-desktop
|
||||
mobile parent. Both live databases are repaired and aligned, but that stale
|
||||
transport archive remains rejected until the normal archive-capacity recovery
|
||||
retires it; no successful synchronization receipt is claimed by this evidence.
|
||||
|
||||
Post-correction validation passed 312 Android JVM tests (five optional skips),
|
||||
`assembleDebug`, 208 Web tests, typecheck, and build. Real Firefox then observed
|
||||
two orange agenda bands, two orange HR bands, `Réveils 2 · 32 min 29 s`, and two
|
||||
orange PDF intervals. The selected-only PDF reports `Sommeil déclaré : 4 h 44
|
||||
min 16 s` after subtracting the repaired awakening intervals from the visual
|
||||
estimate.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# Sleep revision collision recovery V1 evidence
|
||||
|
||||
Date: 2026-09-24
|
||||
|
||||
## Collision
|
||||
|
||||
```text
|
||||
COLLIDING_REVISION_ID=slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa
|
||||
ANDROID_PARENT=slr_e98ad36c-94e2-452c-8068-a7e3d2ea4dd4
|
||||
DESKTOP_PARENT=slr_2bbf573d-4262-49b0-896e-1bcabf3b002a
|
||||
ANDROID_PUBLISHED=YES
|
||||
DESKTOP_PUBLISHED=NO
|
||||
ANDROID_REFERENCED_BY_IMMUTABLE_GENERATION=YES
|
||||
DESKTOP_REFERENCED_BY_IMMUTABLE_GENERATION=NO
|
||||
```
|
||||
|
||||
The two records had the same entry identity, night envelope, creation time,
|
||||
notes, day form, and four event identities (`IDENTICAL`). Their causal parents
|
||||
were different (`PARENT_CONFLICT`). Desktop contained two non-null quality
|
||||
values where Android retained nulls (`NON_CONFLICTING_ADDITION`). Desktop also
|
||||
contained five distinct medication occurrences absent from the older Android
|
||||
branch (`MISSING_ON_ANDROID` / `NON_CONFLICTING_ADDITION`), with a total
|
||||
quantity of six. No same occurrence ID had divergent content. Medication names,
|
||||
unit doses, and notes are deliberately omitted from this repository record.
|
||||
|
||||
The root cause was the manual real-night causal repair recorded in
|
||||
`docs/reviews/sleep_awake_selection_pdf_v1_evidence.md`: two independently
|
||||
derived tips were assigned the same preselected new revision ID. No production
|
||||
UUID collision or repository clone function produced the reuse. The faulty
|
||||
transition was therefore that operational repair, not an Android/Desktop save
|
||||
function. The production boundaries hardened by this tranche are
|
||||
`sleep_diary_exchange.apply()` and
|
||||
`TrainlogRepository.applySleepDiaryV1Json()`; database schemas v36/v33 also
|
||||
make revision payloads append-only.
|
||||
|
||||
## Backups and isolated simulation
|
||||
|
||||
Immediate pre-recovery evidence is outside Git at
|
||||
`/home/fy59/Downloads/trainlog-sleep-collision-recovery-20260924T140000+0200/`.
|
||||
The Android application archive SHA-256 is
|
||||
`7d0d95c9401df4baa66503658b8e309e4f6cb7359e5404517460a97afbce668a`,
|
||||
the extracted Android database SHA-256 is
|
||||
`6fb99e87a24041202a867e8487dd95ef088f8e189ebb6d6e60956f999bc01ae0`,
|
||||
and the Desktop database SHA-256 is
|
||||
`4a44a29a68683ddfaea4c20c121cd6f32309660d6b4141ca716c3fb20d89b225`.
|
||||
Both databases reported `integrity_check=ok` and an empty foreign-key check.
|
||||
|
||||
Before canonical mutation, isolated copies retained both branches, replayed
|
||||
the immutable generation, produced `consumed` / `sqlite-commit-full`, and
|
||||
replayed the exact ACK byte-identically. The imported fixture had one session
|
||||
ending `2026-09-24T11:16:00+02:00`, 7 exercises, 15 sets, 5 feedbacks,
|
||||
7 exercise intervals, 4,919 BPM samples, 4,919 RR intervals, and one Program
|
||||
execution. Sleep retained four events, five medication occurrences, and total
|
||||
quantity six; both copied databases remained integrity/FK clean.
|
||||
|
||||
## Canonical recovery and replay
|
||||
|
||||
The unpublished Desktop branch was retained exactly under
|
||||
`slr_42250548-5237-4225-a12d-7e68a0b57076`. The immutable Android branch kept
|
||||
the collision identity and its original parent. The reconciliation successor
|
||||
`slr_670c7516-f39e-4196-a861-8f3b657dbee1` descends from that published branch
|
||||
and contains the non-conflicting union. The target manifest remains SHA-256
|
||||
`ea3e7ed5622a04f514db069db29cccff1b4702c5cc0e4e253a578ffe8806fc70`;
|
||||
no generation artifact was rewritten.
|
||||
|
||||
Generation `gen_76222040-50d7-4f5d-bcfb-ad9c1e7786f8` was consumed through the
|
||||
production consumer with `sqlite-commit-full`, and Android accepted its exact
|
||||
consumed ACK. Two subsequent normal Android generations were also consumed and
|
||||
acknowledged during the recovery validation. The canonical database and Web
|
||||
API expose the single original session identity at 11:16, all 7 exercises,
|
||||
15 sets, 5 feedbacks, 4,919 BPM/RR values, one Program execution, and the
|
||||
reconciled Sleep tip with four events, five medication occurrences, and total
|
||||
quantity six.
|
||||
|
|
@ -30,14 +30,14 @@ estimates.
|
|||
|
||||
## Current baseline
|
||||
|
||||
Stable v0.1.5 is the release baseline for the next development cycle.
|
||||
Desktop schema v35, Android schema v32, Notcurses, direct `Documents/Trainlog`
|
||||
Stable v0.1.6 is the release baseline for the next stabilization cycle.
|
||||
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
|
||||
explicit manual validation gate. APP_SHELL_V1 remains useful historical design
|
||||
evidence, but its September 2026 observations must be revalidated against the
|
||||
v0.1.5 baseline rather than treated as a current implementation plan.
|
||||
v0.1.6 baseline rather than treated as a current implementation plan.
|
||||
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
|
||||
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||
contract, frozen interactive grid, versioned layout persistence and factual
|
||||
|
|
@ -48,7 +48,8 @@ Sessions → Programmes remains the technical administration/import, list,
|
|||
detail, archive, and delete surface. Web Exercises V1, the factual
|
||||
Dashboard/Analyse read-model foundation, Sleep Diary V1/V2, synchronized
|
||||
factual heart-rate timelines, and the complete Cardio V1
|
||||
capture/calibration/guidance chain are included in stable v0.1.5.
|
||||
capture/calibration/guidance chain and the 0.1.6 refinement/recovery work are
|
||||
included in stable v0.1.6.
|
||||
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||
Sessions presentation correction, local date preference and durable preparation
|
||||
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||
|
|
@ -57,14 +58,25 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
|
|||
|
||||
## Current cursor
|
||||
|
||||
Stable v0.1.5 closes Cardio V1 and the Sleep Tonight readiness cycle. Android
|
||||
Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization
|
||||
recovery, and factual session-finalization cycle. Android
|
||||
alone discovers and reads the heart-rate sensor; desktop/Web consume only
|
||||
synchronized factual measurements and never connect directly to the sensor.
|
||||
The heart-rate path remains independent from the frozen Bluetooth Trainlog
|
||||
synchronization transport.
|
||||
|
||||
The stable 0.1.6 Web refinement projects factual Sleep intervals, every
|
||||
structured awakening, point events, medication intakes, bounds, and duration
|
||||
through the agenda, selected-night HR detail, and selected-night PDF. Its
|
||||
explicitly labeled fallback estimate is presentation-only, subtracts overlapping
|
||||
structured awakenings for displayed duration, and is shared by all three views;
|
||||
the agenda retains an exact hourly 18:00-to-18:00 geometry. Android lifecycle/ACK regressions now explicitly guard
|
||||
pre-bed medication snapshots against restart, replay and stale ancestry. The
|
||||
release closes this implementation cycle without reopening its frozen
|
||||
contracts.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.5 published; open 0.1.6 development without reopening frozen 0.1.5 contracts.
|
||||
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.6 published; open 0.1.7 stabilization after the immutable release tag.
|
||||
```
|
||||
|
||||
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||
|
|
|
|||
|
|
@ -953,6 +953,20 @@ active. Archive directories are outside bounded MTP polling and are never
|
|||
uploaded recursively. Failure or interruption leaves admission closed rather
|
||||
than fabricating an ACK or discarding evidence.
|
||||
|
||||
An unacknowledged generation whose parent also has a distinct, ledger-backed
|
||||
acknowledged child is a retained superseded branch: it stays available for
|
||||
audit and is never deleted or assigned a fabricated ACK, but it no longer
|
||||
consumes active admission because it cannot become the causal tip. After a
|
||||
transport reset, the next run republishes the newest unsuperseded immutable
|
||||
generation for that peer before attempting a new capture. The mutable
|
||||
generation reference uses the new conversation run; generation ID, manifest,
|
||||
artifact bytes and eventual ACK correlation remain unchanged. Because the
|
||||
immutable manifest and its durable ACK retain the original run identity, a
|
||||
cross-run retry recognizes that ACK by the exact generation ID and manifest
|
||||
digest; the producer's SQLite acknowledgement validator still verifies the
|
||||
complete peer, result, durability and original-run contract before advancing
|
||||
state.
|
||||
|
||||
Before Android captures for a negotiated `generation-archive-v1` conversation,
|
||||
the desktop consumer publishes
|
||||
`desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope
|
||||
|
|
@ -999,11 +1013,14 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission
|
|||
and retransmission because generation membership lives only in
|
||||
`sync_causal_publications`.
|
||||
|
||||
The foreground Android coordinator correlates both durable desktop ACKs and
|
||||
desktop generation references by the active run (and the Android generation
|
||||
for its ACK). Objects retained from an interrupted conversation are ignored
|
||||
until atomically replaced by matching objects. After both peer ACKs, Android
|
||||
reports completion directly; it does not enter the legacy receipt wait state.
|
||||
The foreground Android coordinator correlates desktop generation references
|
||||
by the active run. It correlates a durable desktop ACK for an ordinary attempt
|
||||
by the generation and manifest of that run; for a cross-run resume, those same
|
||||
two immutable identities deliberately recognize the exact ACK whose embedded
|
||||
run remains the original capture run. Objects retained from an interrupted
|
||||
conversation are ignored until atomically replaced by matching objects. After
|
||||
both peer ACKs, Android reports completion directly; it does not enter the
|
||||
legacy receipt wait state.
|
||||
|
||||
The peer publication precedes one fresh
|
||||
`trainlog-sync-full-generation-request-v1` signal on
|
||||
|
|
@ -1092,6 +1109,30 @@ advance cardio acknowledgement. Acknowledged captures remain in Android
|
|||
history but are omitted from normal later publication, preventing a full
|
||||
night's samples from being retransmitted on every sync.
|
||||
|
||||
An acknowledged session capture may later receive one causal
|
||||
`heart_rate_tail` deletion when recovery establishes an earlier factual
|
||||
`session.ended_at`. Its target is the exact `capture_id|cutoff`; the predecessor
|
||||
digest covers the original capture metadata, ordered BPM samples and ordered RR
|
||||
children. The cutoff is inclusive: only measurements strictly later than it are
|
||||
deleted, while `capture.ended_at` becomes the cutoff. Because the general
|
||||
causal-deletions V1 contract is frozen to its seven existing target kinds, the
|
||||
operation travels in the separate optional
|
||||
`trainlog-heart-rate-corrections` V1 / `heart-rate-corrections-v1.json`
|
||||
companion while reusing the durable causal operation/state ledger. No published
|
||||
or acknowledged generation is rewritten and no ACK is fabricated. The
|
||||
correction companion is consumed before Heart Rate V1; that importer also
|
||||
consults durable correction state before comparing or inserting an old
|
||||
snapshot, so replay cannot resurrect a corrected tail.
|
||||
|
||||
Producer lineage selection treats only unarchived acknowledged generations
|
||||
backed by an exact `consumed` / `sqlite-commit-full` ACK as active tips. This
|
||||
keeps legacy terminal rows that predate the durable ACK ledger as immutable
|
||||
audit evidence without letting them block every later publication. A consumer
|
||||
can retain several historical consumed tips because it cannot observe which
|
||||
fork the producer archived; the next manifest is valid only when its declared
|
||||
parent is one of those current tips. A null, old, or unrelated parent remains a
|
||||
hard rejection, and neither side rewrites fork evidence.
|
||||
|
||||
The companion changes neither the frozen Bluetooth Classic RFCOMM transport,
|
||||
MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics.
|
||||
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
|
||||
|
|
@ -1247,3 +1288,22 @@ packaged user-session deployment. Before every invocation the bounded combined
|
|||
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
|
||||
signal, and non-zero-exit diagnostics therefore describe only the current run
|
||||
and are retained in the synchronization error/history when the run fails.
|
||||
|
||||
## Sleep revision identity collision recovery
|
||||
|
||||
A Sleep `revision_id` names one immutable parent plus canonical payload. The
|
||||
generation consumer accepts the same identity only when both are equal; it
|
||||
rejects a different parent or payload even if the incoming revision is an
|
||||
ancestor of the local current tip. Ordinary Sleep edits always use a new
|
||||
revision identity.
|
||||
|
||||
The bounded recovery tool is intentionally narrower than general conflict
|
||||
resolution. It may re-identify a divergent local tip only after proving that
|
||||
the tip has no descendant, validation/ACK state, or immutable generation
|
||||
reference. It retains that branch unchanged, restores the manifest-owned peer
|
||||
revision under its published identity, and creates a fresh single-parent
|
||||
successor containing only non-conflicting scalar additions and exact-ID unions
|
||||
of events and medication occurrences. Conflicting non-null scalars, notes, or
|
||||
same occurrence IDs with different payloads require a human decision. The
|
||||
generation manifest and artifacts are never rewritten; only an exact rejected
|
||||
consumer/producer ledger may be rearmed for normal replay.
|
||||
|
|
|
|||
166
docs/tests.md
|
|
@ -1,5 +1,86 @@
|
|||
# Tests and validation
|
||||
|
||||
## Sleep revision collision recovery
|
||||
|
||||
Collision regressions distinguish exact replay from same-ID parent or payload
|
||||
reuse, validate known historical ancestors before stale-snapshot handling, and
|
||||
exercise safe re-identification of an unpublished branch. They preserve
|
||||
quantity one and two, distinct same-time medication occurrences, event and
|
||||
intake unions, rollback, immutable-generation references, exact ledger rearm,
|
||||
and idempotent replay. SQLite migration tests prove that Desktop v36 and
|
||||
Android v33 reject direct update/delete of all revision-owned Sleep payload
|
||||
tables. Android also creates a 65-revision edit chain and verifies unique UUIDs,
|
||||
exact parentage, and unchanged historical payloads.
|
||||
|
||||
The retained real-data simulation replays the original immutable full
|
||||
generation against isolated copies and verifies one session ending at 11:16,
|
||||
7 exercises, 15 sets, 5 feedbacks, 7 exercise intervals, 4,919 BPM samples,
|
||||
4,919 RR intervals, Program provenance, `consumed` / `sqlite-commit-full`, a
|
||||
byte-identical ACK replay, and clean integrity/foreign-key checks. A later
|
||||
normal generation was consumed and acknowledged, proving continued pipeline
|
||||
use. Sensitive medication names and dose values remain outside repository
|
||||
evidence.
|
||||
|
||||
## Web UI refinement 0.1.6
|
||||
|
||||
Web regressions cover calendar-week activity grouping, global rather than
|
||||
single-exercise Dashboard progression, exercises-per-date overview data,
|
||||
measured-only group drill-down, normalized measurement selection, the shared
|
||||
BODY ZONES figure, persistent language/settings navigation, multi-night Sleep
|
||||
selection invariants, and Equipment response validation. Native equipment
|
||||
coverage exercises a synthetic custom duplicate and proves that a transactional
|
||||
merge repoints completed history to the retained canonical identity without
|
||||
touching real user equipment. Strict C compilation, the full Meson inventory,
|
||||
Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and
|
||||
diff checks remain the closeout gates.
|
||||
|
||||
Post-review regressions cover a validated Sleep Diary revision plus nocturnal
|
||||
BPM/RR capture through the production full-generation transport, desktop
|
||||
import, correlated ACK, exact ACK replay, a second Sleep-only revision and a
|
||||
second generation. The 24-conversation deployed-capacity regression removes
|
||||
legacy ACK-ledger rows deliberately, recovers capacity without deleting
|
||||
generation evidence, and continues through archive/replay. Native Web tests
|
||||
prove that an active zero-occurrence execution draft is deleted causally and
|
||||
that repeating its request is byte-identical, while a populated active draft
|
||||
still conflicts. The equipment client test removes `crypto.randomUUID`, uses
|
||||
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
|
||||
request; native merge tests retain their transaction/rollback coverage.
|
||||
|
||||
The real Sleep Bluetooth recovery regression starts with no Android session
|
||||
and an empty pending Sleep revision, records two pre-bed medication intakes
|
||||
(including `quantity=2`), reopens the repository after each meaningful
|
||||
lifecycle transition, then records Couché, two BPM/RR samples
|
||||
separated by Réveil, and Levé. It captures and publishes the production V2
|
||||
Sleep and V1 heart-rate artifacts, imports them with the desktop consumer,
|
||||
accepts and replays the ACK without duplication, then admits a second
|
||||
Sleep-only revision. It also applies the original intake-free ancestor after
|
||||
ACK and proves that causal reconciliation cannot erase the current intakes.
|
||||
Separate tests prove connection-arrival auto-request
|
||||
policy with its cooldown, cross-run reuse of an unacknowledged immutable
|
||||
generation and recognition of its original-run exact generation/digest ACK,
|
||||
ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry,
|
||||
service-installer upgrade behavior, and ACK-recovery progress frames over the
|
||||
Bluetooth-selected orchestrator path.
|
||||
|
||||
UI regressions assert the Sleep agenda's 25 exact hourly positions, 13 visible
|
||||
two-hour desktop labels and five narrow six-hour anchors, including the exact
|
||||
0/25/50/75/100% coordinates. They also prove that both Sleep views reuse the
|
||||
same presentation-only Couché +45 minutes to Levé -10 minutes estimate, that a
|
||||
factual sleep interval wins, and that a missing bound produces no estimate.
|
||||
Other UI regressions cover persisted
|
||||
Dashboard sizing and reset behavior, English preference persistence across a
|
||||
remount, centralized Dashboard English labels, non-stretched chart aspect
|
||||
ratios, and distinct missing/partial exercise-timing messages.
|
||||
The current closeout run passes Web **32 files / 209 tests**, TypeScript
|
||||
typecheck and the production build; native Meson **113/113**; Android **315
|
||||
tests** with five optional skips plus `assembleDebug`; JSON/import validators;
|
||||
changed-C formatting; and diff checks.
|
||||
The restarted private service serves the rebuilt executable byte-for-byte from
|
||||
the development checkout and reports healthy Trainlog `0.1.6`. A non-mutating
|
||||
Firefox smoke on the real 2026-09-23 night confirms the agenda and detail both
|
||||
show 22:50–04:04 as estimated, with 13 ordinary-desktop labels, 25 wide labels
|
||||
and five narrow anchors.
|
||||
|
||||
Sleep Diary coverage includes schema creation/migration, midnight and DST
|
||||
offsets, revision conflict, logical deletion/reopen, companion replay and
|
||||
rollback, Web periods/deep links, Android persistence, and vector PDF
|
||||
|
|
@ -21,6 +102,17 @@ execution and verifies Web `1 / 24` presentation. Related regressions cover
|
|||
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
|
||||
non-root current revisions.
|
||||
|
||||
Android session-finalization coverage includes a Program-derived mixed session
|
||||
whose continuous target is completed from its measured timeline, several prior
|
||||
occurrences, feedback, an active heart-rate capture, and a planned `2 x 10`
|
||||
occurrence performed as three valid `10 @ 23 kg` sets. It recreates the legacy
|
||||
state with a closed continuous timeline but no performed row, finalizes at an
|
||||
explicit factual timestamp, and proves that plan, actual sets, feedback,
|
||||
timeline, BPM samples, and RR intervals remain distinct and intact. Desktop
|
||||
Sleep exchange coverage also verifies that replay of an identical durable but
|
||||
unpublished revision acknowledges it without `StopIteration`, while different
|
||||
content under the same revision identity remains an explicit conflict.
|
||||
|
||||
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
||||
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
|
||||
|
|
@ -269,6 +361,18 @@ cover exact 18:00-to-18:00 timestamp geometry, active-night isolation, empty
|
|||
night state, return navigation and rejection of an earlier date response that
|
||||
finishes late. Localized dose/unit labels, same-name medications with different
|
||||
usual doses and editable intake-dose prefill remain covered.
|
||||
Selection regressions additionally prove that one agenda click updates both
|
||||
detail dates and HR identity without a write, and that preview and download
|
||||
receive the same one-night subset with recomputed observations. Projection
|
||||
unit tests cover exact factual duration, fallback duration, multiple structured
|
||||
awakenings, and subtraction of only their overlap from an estimated range.
|
||||
The heart-rate panel suite additionally covers a complete 22:05-to-04:14 night,
|
||||
declared and fallback-estimated sleep ranges, multiple close awakenings, final
|
||||
get-up, pre-bed and close medication intakes with exact dose/unit/quantity,
|
||||
every intervening hour tick, and a no-HR rendering that creates no measured
|
||||
curve. Geometry tests prove exact factual bounds and the common normalized
|
||||
projection used by chart samples, overlays, events, intakes, and ticks; narrow
|
||||
viewports preserve that geometry in a horizontally scrollable chart.
|
||||
`test_web_sleep_browser.py` exercises the embedded production bundle in real
|
||||
Firefox against an isolated synthetic database, observes every mutation
|
||||
without reload, verifies HTTP success and browser error collectors, measures
|
||||
|
|
@ -277,7 +381,9 @@ empty night and back, then reloads and compares the persisted Agenda.
|
|||
The PDF suite separately asserts complete French and English vocabularies,
|
||||
accent-preserving WinAnsi text, human-readable duration and plural forms,
|
||||
localized dates, medication dose snapshots, pagination and the exact ordered
|
||||
geometry of sleep / 45-minute long-awakening / sleep intervals. The same real
|
||||
geometry of sleep / 45-minute long-awakening / sleep intervals. PDF rows reuse
|
||||
the same projected sleep/awake/point/medication primitives as agenda and HR;
|
||||
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.
|
||||
|
|
@ -1665,6 +1771,45 @@ generation tool, accepts the correlated ACK, verifies one desktop
|
|||
capture/sample/RR row, and proves the acknowledged Android capture is no longer
|
||||
emitted.
|
||||
|
||||
`test_heart_rate_tail_recovery` covers the delayed-session correction boundary:
|
||||
a sample exactly at the factual end remains, the first representable sample
|
||||
after it and its RR child are removed, statistics use only retained facts, an
|
||||
already bounded capture is an idempotent no-op, wrong session/capture and
|
||||
invalid temporal bounds are refused, and an injected post-effect failure rolls
|
||||
back every row. It then replays the old Heart Rate V1 snapshot and the same
|
||||
causal operation twice to prove non-resurrection and second-sync idempotence.
|
||||
It also proves that `heart_rate_tail` appears only in the separately versioned
|
||||
heart-rate-correction artifact and remains absent from frozen
|
||||
causal-deletions V1.
|
||||
`TrainlogRepositoryDraftTest.delayedFactualFinalizationTruncatesHeartRateSamplesAndRrInclusively`
|
||||
exercises the production Android delayed-finalization transaction.
|
||||
|
||||
The 2026-09-24 paired-device closeout applies operation
|
||||
`hrx_a2222757-9a85-492f-823b-ad590208ce6b` to capture
|
||||
`hrc_fe0627e0-dff6-4629-82d9-03da78c49634`. Android and desktop both report
|
||||
3,429 BPM samples, 3,429 RR values, no sample after 11:16, last sample
|
||||
11:15:59.759323, and 89 / 119.30300379119277 / 138 BPM. Full MTP runs
|
||||
`sy_d1a29dd9-7b51-41db-83e1-5712758b3700` and
|
||||
`sy_4b8b3c28-17b1-4300-bfbf-9332385f69ab` both completed through
|
||||
`peer_consumed`. A production import of the retained pre-correction 4,919-point
|
||||
artifact left the same counts and statistics. The live Web history detail in
|
||||
Firefox rendered the 10:18--11:16 curve, seven exercises, 15 sets, 3,429 RR,
|
||||
and the same min/average/max values.
|
||||
|
||||
Generation regressions additionally cover an archived acknowledged fork, a
|
||||
legacy acknowledged row without durable ACK evidence, and consumer acceptance
|
||||
of a producer-selected current fork tip while rejecting stale/null ancestry.
|
||||
Android generation tests exercise the matching rules. Sleep quick-capture and
|
||||
generation tests cover the schema-v33 medication-intake column order with
|
||||
quantity 2, preventing note/creation-time values from being bound to the
|
||||
`quantity` constraint.
|
||||
|
||||
The final post-device closeout passes the complete native Meson inventory
|
||||
**114/114**, the complete clang ASan/UBSan Meson inventory **114/114**, and
|
||||
Android `testDebugUnitTest` with **317 tests: 312 passed, five skipped, zero
|
||||
failures/errors**. Android `assembleDebug`, both JSON/import validators,
|
||||
changed-file C formatting, and `git diff --check` also pass.
|
||||
|
||||
The 2026-09-23 closeout passes the complete native Meson inventory:
|
||||
**102/102 passed, 0 failed**. The complete Android JVM inventory reports
|
||||
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
|
||||
|
|
@ -1750,21 +1895,34 @@ also succeeds. The five skips remain optional historical/external fixture gates.
|
|||
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
|
||||
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
|
||||
API creates/advances normal immutable Sleep Diary revisions for Couché,
|
||||
medication intake, point Réveil and Levé actions. Undo is accepted only when
|
||||
medication intake, 30-minute structured Réveil and Levé actions. Undo is accepted only when
|
||||
the action's exact applied revision is still current; older receipts conflict,
|
||||
and successful undo appends a compensating revision instead of rewriting the
|
||||
history.
|
||||
|
||||
Targeted coverage executes Couché, a configured medication with dose snapshot,
|
||||
two independent wake points, live sleep-owned BPM/RR capture, Levé and final
|
||||
two independent awakening intervals, live sleep-owned BPM/RR capture, Levé and final
|
||||
heart-rate export against the same stable sleep `entry_id`. A separate test
|
||||
proves stale undo rejection and exact restoration of the latest action.
|
||||
proves stale undo rejection and exact restoration of the latest action. A
|
||||
dedicated regression proves that a second tap inside 30 minutes extends one
|
||||
interval and that final get-up clamps it without leaving overlapping windows.
|
||||
|
||||
The complete Android JVM inventory before the final localization-only cleanup
|
||||
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
|
||||
`assembleDebug` succeeded. After the localization cleanup the targeted
|
||||
SleepQuickCapture test and `assembleDebug` passed again.
|
||||
|
||||
The 30-minute awakening-policy follow-up passes the current complete inventory:
|
||||
**312 tests: 307 passed, five skipped, zero failures/errors**, plus
|
||||
`assembleDebug`. Real-data Firefox coverage additionally asserts two orange
|
||||
agenda/HR bands and two orange vector-PDF intervals for the repaired
|
||||
2026-09-23 night.
|
||||
|
||||
The subsequent agenda/report-range regression covers exact 18:00-to-18:00
|
||||
hour geometry, a visible 24-column row grid, inclusive start/end filtering,
|
||||
identical preview/export snapshots, range summary recomputation, and the
|
||||
independence of the selected HR/editor night from the PDF range.
|
||||
|
||||
The private Android installation was advanced non-destructively to Trainlog
|
||||
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
|
||||
data clear. The device locked before the final visual Sleep-screen inspection,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
project(
|
||||
'trainlog',
|
||||
'c',
|
||||
version: '0.1.5',
|
||||
version: '0.1.6',
|
||||
meson_version: '>=1.3.0',
|
||||
default_options: [
|
||||
'c_std=c17',
|
||||
|
|
|
|||
|
|
@ -126,7 +126,9 @@ class GenerationArchiveTest(unittest.TestCase):
|
|||
|
||||
stage, manifest, _ = generation.capture_desktop(self.database, self.owned, PEER)
|
||||
self.assertTrue(stage.is_dir())
|
||||
self.assertEqual(parent, manifest["parent_generation_id"])
|
||||
# Historical status alone is terminal evidence, not a durable
|
||||
# consumed ACK and therefore not an active lineage parent.
|
||||
self.assertIsNone(manifest["parent_generation_id"])
|
||||
with sqlite3.connect(self.database) as database:
|
||||
self.assertEqual(
|
||||
0,
|
||||
|
|
|
|||
217
tests/test_heart_rate_tail_recovery.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Causal recovery coverage for delayed session-end heart-rate tails."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import heart_rate_exchange
|
||||
import causal_delete_exchange
|
||||
import heart_rate_correction_exchange
|
||||
import recover_heart_rate_tail as recovery
|
||||
|
||||
|
||||
SESSION = "se_22222222-2222-4222-8222-222222222222"
|
||||
OTHER_SESSION = "se_99999999-9999-4999-8999-999999999999"
|
||||
CAPTURE = "hrc_11111111-1111-4111-8111-111111111111"
|
||||
OTHER_CAPTURE = "hrc_88888888-8888-4888-8888-888888888888"
|
||||
CUTOFF = "2026-09-24T11:16:00+02:00"
|
||||
|
||||
|
||||
def create_schema(db: sqlite3.Connection) -> None:
|
||||
db.executescript(
|
||||
"""
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,
|
||||
started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER);
|
||||
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER);
|
||||
CREATE TABLE continuous_activity(session_exercise_row_id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE session_exercise_timeline(session_id TEXT,entry_id TEXT,
|
||||
exercise_id TEXT,started_at TEXT,ended_at TEXT,imported_at TEXT);
|
||||
CREATE TABLE heart_rate_captures(capture_id TEXT PRIMARY KEY,context_kind TEXT,
|
||||
context_id TEXT,started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT);
|
||||
CREATE TABLE heart_rate_samples(capture_id TEXT,sequence INTEGER,observed_at TEXT,
|
||||
bpm INTEGER,exercise_entry_id TEXT,sensor_contact_detected INTEGER,
|
||||
energy_expended INTEGER,PRIMARY KEY(capture_id,sequence),
|
||||
FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE);
|
||||
CREATE TABLE heart_rate_rr_intervals(capture_id TEXT,sample_sequence INTEGER,
|
||||
rr_index INTEGER,value_1024 INTEGER,
|
||||
PRIMARY KEY(capture_id,sample_sequence,rr_index),
|
||||
FOREIGN KEY(capture_id,sample_sequence)
|
||||
REFERENCES heart_rate_samples(capture_id,sequence) ON DELETE CASCADE);
|
||||
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,
|
||||
target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,
|
||||
payload_sha256 TEXT,publication_context TEXT);
|
||||
CREATE TABLE sync_causal_state(target_kind TEXT,target_id TEXT,
|
||||
current_revision_id TEXT,deleted INTEGER,operation_id TEXT,
|
||||
PRIMARY KEY(target_kind,target_id));
|
||||
PRAGMA user_version=36;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def seed(db: sqlite3.Connection, ended_at: str = CUTOFF,
|
||||
include_tail: bool = True) -> dict:
|
||||
db.execute(
|
||||
"INSERT INTO sessions VALUES(1,?,?,?,?,?)",
|
||||
(SESSION, "2026-09-24T10:00:00+02:00", ended_at, "training", "keep"),
|
||||
)
|
||||
db.execute("INSERT INTO session_exercises VALUES(1,1)")
|
||||
db.execute("INSERT INTO performed_sets VALUES(1,1)")
|
||||
db.execute(
|
||||
"INSERT INTO session_exercise_timeline VALUES(?,?,?,?,?,?)",
|
||||
(SESSION, "sxe_33333333-3333-4333-8333-333333333333",
|
||||
"ex_44444444-4444-4444-8444-444444444444",
|
||||
"2026-09-24T10:15:00+02:00", "2026-09-24T11:15:00+02:00", CUTOFF),
|
||||
)
|
||||
capture_end = "2026-09-24T11:16:00.000001+02:00" if include_tail else CUTOFF
|
||||
db.execute(
|
||||
"INSERT INTO heart_rate_captures VALUES(?,?,?,?,?,?,?)",
|
||||
(CAPTURE, "session", SESSION, "2026-09-24T10:00:00+02:00", capture_end,
|
||||
"Synthetic", CUTOFF),
|
||||
)
|
||||
samples = [
|
||||
(CAPTURE, 0, "2026-09-24T11:15:59.999999+02:00", 100, None, 1, 1),
|
||||
(CAPTURE, 1, CUTOFF, 101, None, 1, 2),
|
||||
]
|
||||
if include_tail:
|
||||
samples.append((CAPTURE, 2, "2026-09-24T11:16:00.000001+02:00",
|
||||
40, None, 0, 3))
|
||||
db.executemany("INSERT INTO heart_rate_samples VALUES(?,?,?,?,?,?,?)", samples)
|
||||
db.executemany(
|
||||
"INSERT INTO heart_rate_rr_intervals VALUES(?,?,0,?)",
|
||||
[(CAPTURE, row[1], 900 + row[1]) for row in samples],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO heart_rate_captures VALUES(?,?,?,?,?,?,?)",
|
||||
(OTHER_CAPTURE, "session", OTHER_SESSION, "2026-09-24T09:00:00+02:00",
|
||||
"2026-09-24T09:01:00+02:00", "Other", CUTOFF),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO heart_rate_samples VALUES(?,?,?,?,?,?,?)",
|
||||
(OTHER_CAPTURE, 0, "2026-09-24T09:00:30+02:00", 77, None, 1, None),
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"format": "trainlog-heart-rate",
|
||||
"version": 1,
|
||||
"generated_at": CUTOFF,
|
||||
"captures": [{
|
||||
"capture_id": CAPTURE,
|
||||
"context_kind": "session",
|
||||
"context_id": SESSION,
|
||||
"started_at": "2026-09-24T10:00:00+02:00",
|
||||
"ended_at": capture_end,
|
||||
"sensor_name": "Synthetic",
|
||||
"samples": [{
|
||||
"sequence": row[1], "observed_at": row[2], "bpm": row[3],
|
||||
"exercise_entry_id": None, "sensor_contact_detected": bool(row[5]),
|
||||
"energy_expended": row[6], "rr_intervals_1024": [900 + row[1]],
|
||||
} for row in samples],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
class HeartRateTailRecoveryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.temp.name) / "trainlog.sqlite"
|
||||
self.db = sqlite3.connect(self.path)
|
||||
create_schema(self.db)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def apply(self, operation=None, fail=False):
|
||||
return recovery.recover(
|
||||
self.db, SESSION, CAPTURE, CUTOFF, "peer_test", True,
|
||||
operation=operation, fail_after_effect=fail,
|
||||
)
|
||||
|
||||
def test_exact_cutoff_rr_stats_and_old_generation_replay(self):
|
||||
old_generation = seed(self.db)
|
||||
report, operation = self.apply()
|
||||
self.assertEqual("applied", report["outcome"])
|
||||
self.assertEqual(1, report["before"]["bpm_remove"])
|
||||
self.assertEqual(1, report["before"]["rr_remove"])
|
||||
self.assertEqual(0, report["after"]["bpm_remove"])
|
||||
self.assertEqual(100.5, report["after"]["current_stats"]["average"])
|
||||
self.assertEqual(
|
||||
[(0, "2026-09-24T11:15:59.999999+02:00"), (1, CUTOFF)],
|
||||
self.db.execute(
|
||||
"SELECT sequence,observed_at FROM heart_rate_samples "
|
||||
"WHERE capture_id=? ORDER BY sequence", (CAPTURE,),
|
||||
).fetchall(),
|
||||
)
|
||||
self.assertEqual(2, self.db.execute(
|
||||
"SELECT COUNT(*) FROM heart_rate_rr_intervals WHERE capture_id=?", (CAPTURE,),
|
||||
).fetchone()[0])
|
||||
correction_document = heart_rate_correction_exchange.export_document(self.db)
|
||||
self.assertEqual([operation], correction_document["corrections"])
|
||||
self.assertEqual([], causal_delete_exchange.export_document(self.db)["operations"])
|
||||
self.assertEqual((0, 1), heart_rate_exchange.apply(
|
||||
self.db, heart_rate_exchange.validate(old_generation),
|
||||
))
|
||||
second, _ = self.apply(operation)
|
||||
self.assertEqual("unchanged", second["outcome"])
|
||||
self.assertEqual(2, second["after"]["bpm_total"])
|
||||
|
||||
def test_dry_run_and_injected_failure_roll_back_every_table(self):
|
||||
seed(self.db)
|
||||
before = list(self.db.iterdump())
|
||||
report, _ = recovery.recover(
|
||||
self.db, SESSION, CAPTURE, CUTOFF, "peer_test", False,
|
||||
)
|
||||
self.assertEqual("dry-run", report["mode"])
|
||||
self.assertEqual(before, list(self.db.iterdump()))
|
||||
with self.assertRaisesRegex(recovery.RecoveryError, "injected failure"):
|
||||
self.apply(fail=True)
|
||||
self.assertEqual(before, list(self.db.iterdump()))
|
||||
|
||||
def test_wrong_id_and_invalid_temporal_bounds_are_rejected(self):
|
||||
seed(self.db)
|
||||
with self.assertRaisesRegex(recovery.RecoveryError, "session not found"):
|
||||
recovery.recover(self.db, OTHER_SESSION, CAPTURE, CUTOFF, "peer", False)
|
||||
with self.assertRaisesRegex(recovery.RecoveryError, "capture"):
|
||||
recovery.recover(
|
||||
self.db, SESSION,
|
||||
"hrc_77777777-7777-4777-8777-777777777777", CUTOFF, "peer", False,
|
||||
)
|
||||
self.db.execute(
|
||||
"UPDATE sessions SET ended_at='2026-09-24T09:59:59+02:00' WHERE session_id=?",
|
||||
(SESSION,),
|
||||
)
|
||||
self.db.commit()
|
||||
with self.assertRaisesRegex(Exception, "outside capture"):
|
||||
recovery.recover(
|
||||
self.db, SESSION, CAPTURE, "2026-09-24T09:59:59+02:00", "peer", False,
|
||||
)
|
||||
self.db.execute("UPDATE sessions SET ended_at=? WHERE session_id=?", (CUTOFF, SESSION))
|
||||
self.db.execute(
|
||||
"UPDATE session_exercise_timeline SET ended_at='2026-09-24T11:16:01+02:00'",
|
||||
)
|
||||
self.db.commit()
|
||||
with self.assertRaisesRegex(Exception, "precedes the last exercise"):
|
||||
self.apply()
|
||||
|
||||
def test_already_bounded_capture_is_noop(self):
|
||||
seed(self.db, include_tail=False)
|
||||
report, operation = self.apply()
|
||||
self.assertEqual("unchanged", report["outcome"])
|
||||
self.assertIsNone(operation)
|
||||
self.assertEqual(0, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sync_causal_operations",
|
||||
).fetchone()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -58,6 +58,15 @@ class SleepExchangeTest(unittest.TestCase):
|
|||
exported = exchange.build(self.db)
|
||||
self.assertTrue(exported["entries"][0]["deleted"])
|
||||
|
||||
def test_known_revision_with_different_parent_is_an_identity_collision(self):
|
||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(revision))))
|
||||
conflicting = self.document(
|
||||
entry(revision, "slr_30000000-0000-4000-8000-000000000003")
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "identity reused"):
|
||||
exchange.apply(self.db, conflicting)
|
||||
|
||||
def test_v1_hydrates_quantity_one_and_v2_preserves_quantity(self):
|
||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||
legacy = self.document(entry(first))
|
||||
|
|
@ -186,6 +195,47 @@ class SleepExchangeTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(ValueError, "concurrent"):
|
||||
exchange.apply(self.db, sibling)
|
||||
|
||||
def test_current_snapshot_seeds_fresh_peer_from_non_root_revisions(self):
|
||||
parent = "slr_20000000-0000-4000-8000-000000000002"
|
||||
tip = "slr_40000000-0000-4000-8000-000000000004"
|
||||
document = self.document(entry(tip, parent))
|
||||
document["version"] = 2
|
||||
document["entries"][0]["intakes"][0]["quantity"] = 2
|
||||
document["entries"][0]["ancestry"] = [tip, parent]
|
||||
|
||||
medication = document["medications"][0]
|
||||
medication_parent = medication["revision_id"]
|
||||
medication_tip = "medr_a0000000-0000-4000-8000-00000000000a"
|
||||
medication["revision_id"] = medication_tip
|
||||
medication["parent_revision_id"] = medication_parent
|
||||
medication["ancestry"] = [medication_tip, medication_parent]
|
||||
medication["updated_at"] = "2026-10-25T18:05:00+01:00"
|
||||
|
||||
rejected = self.document(entry(
|
||||
"slr_50000000-0000-4000-8000-000000000005",
|
||||
"slr_60000000-0000-4000-8000-000000000006",
|
||||
))
|
||||
rejected["version"] = 2
|
||||
rejected["entries"][0]["intakes"][0]["quantity"] = 2
|
||||
with self.assertRaisesRegex(ValueError, "unknown parent"):
|
||||
exchange.apply(self.db, rejected)
|
||||
self.db.rollback()
|
||||
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, document))
|
||||
self.assertEqual(
|
||||
(tip, parent),
|
||||
self.db.execute(
|
||||
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions"
|
||||
).fetchone(),
|
||||
)
|
||||
self.assertEqual(
|
||||
(medication_tip, medication_parent),
|
||||
self.db.execute(
|
||||
"SELECT revision_id,parent_revision_id FROM sleep_medication_revisions"
|
||||
).fetchone(),
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_concurrent_sibling_and_negative_absolute_interval(self):
|
||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
|
||||
|
|
@ -210,6 +260,35 @@ class SleepExchangeTest(unittest.TestCase):
|
|||
(item["entry_id"], revision, item["updated_at"]))
|
||||
self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"])
|
||||
|
||||
def test_identical_remote_revision_validates_unpublished_local_draft(self):
|
||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||
document = self.document(entry(revision))
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, document))
|
||||
self.db.execute(
|
||||
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
|
||||
(document["entries"][0]["entry_id"],),
|
||||
)
|
||||
|
||||
self.assertEqual([], exchange.build(self.db)["entries"])
|
||||
self.assertEqual((0, 1), exchange.apply(self.db, document))
|
||||
self.assertEqual(
|
||||
revision,
|
||||
self.db.execute(
|
||||
"SELECT acknowledged_revision_id FROM sleep_diary_publication_state "
|
||||
"WHERE entry_id=?",
|
||||
(document["entries"][0]["entry_id"],),
|
||||
).fetchone()[0],
|
||||
)
|
||||
|
||||
self.db.execute(
|
||||
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
|
||||
(document["entries"][0]["entry_id"],),
|
||||
)
|
||||
conflicting = json.loads(json.dumps(document))
|
||||
conflicting["entries"][0]["treatment_and_notes"] = "Different content"
|
||||
with self.assertRaisesRegex(ValueError, "identity reused"):
|
||||
exchange.apply(self.db, conflicting)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
310
tests/test_sleep_revision_collision_recovery.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Transactional Sleep revision collision recovery regressions."""
|
||||
|
||||
import copy
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import recover_sleep_revision_collision as recovery
|
||||
import sleep_diary_exchange as exchange
|
||||
|
||||
|
||||
ENTRY = "sl_10000000-0000-4000-8000-000000000001"
|
||||
REMOTE_PARENT = "slr_20000000-0000-4000-8000-000000000002"
|
||||
LOCAL_PARENT = "slr_30000000-0000-4000-8000-000000000003"
|
||||
COLLISION = "slr_40000000-0000-4000-8000-000000000004"
|
||||
LOCAL_REIDENTIFIED = "slr_50000000-0000-4000-8000-000000000005"
|
||||
RECONCILIATION = "slr_60000000-0000-4000-8000-000000000006"
|
||||
RECOVERED_AT = "2026-10-25T18:30:00+01:00"
|
||||
GENERATION = "gen_a0000000-0000-4000-8000-00000000000a"
|
||||
RUN = "sy_b0000000-0000-4000-8000-00000000000b"
|
||||
PRODUCER = "peer_c0000000-0000-4000-8000-00000000000c"
|
||||
CONSUMER = "peer_d0000000-0000-4000-8000-00000000000d"
|
||||
MANIFEST_DIGEST = "a" * 64
|
||||
|
||||
|
||||
def incoming_entry() -> dict:
|
||||
return {
|
||||
"entry_id": ENTRY,
|
||||
"night_start_date": "2026-10-24",
|
||||
"night_end_date": "2026-10-25",
|
||||
"created_at": "2026-10-24T22:00:00+02:00",
|
||||
"updated_at": "2026-10-25T07:00:00+01:00",
|
||||
"revision_id": COLLISION,
|
||||
"parent_revision_id": REMOTE_PARENT,
|
||||
"ancestry": [COLLISION, REMOTE_PARENT],
|
||||
"deleted": False,
|
||||
"sleep_quality": None,
|
||||
"wake_quality": None,
|
||||
"day_form": None,
|
||||
"treatment_and_notes": "",
|
||||
"events": [{
|
||||
"event_id": "sle_70000000-0000-4000-8000-000000000007",
|
||||
"type": "sleep",
|
||||
"start_at": "2026-10-24T23:00:00+02:00",
|
||||
"end_at": "2026-10-25T06:00:00+01:00",
|
||||
}],
|
||||
"intakes": [],
|
||||
}
|
||||
|
||||
|
||||
def document() -> dict:
|
||||
return {
|
||||
"format": exchange.FORMAT,
|
||||
"version": 2,
|
||||
"generated_at": "2026-10-25T18:00:00+01:00",
|
||||
"entries": [incoming_entry()],
|
||||
"medications": [],
|
||||
}
|
||||
|
||||
|
||||
def manifest() -> dict:
|
||||
return {
|
||||
"generation_id": GENERATION,
|
||||
"run_id": RUN,
|
||||
"producer": {"peer_id": PRODUCER, "kind": "android"},
|
||||
"consumer_peer_id": CONSUMER,
|
||||
"generated_at": "2026-10-25T18:00:00+01:00",
|
||||
"parent_generation_id": None,
|
||||
}
|
||||
|
||||
|
||||
class SleepRevisionCollisionRecoveryTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.db = sqlite3.connect(":memory:")
|
||||
self.db.execute("PRAGMA foreign_keys=ON")
|
||||
self.db.executescript("""
|
||||
CREATE TABLE sleep_diary_entries(entry_id TEXT PRIMARY KEY,night_start_date TEXT,night_end_date TEXT,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
|
||||
CREATE TABLE sleep_diary_revisions(revision_id TEXT PRIMARY KEY,entry_id TEXT REFERENCES sleep_diary_entries(entry_id),parent_revision_id TEXT,created_at TEXT,sleep_quality TEXT,wake_quality TEXT,day_form TEXT,treatment_and_notes TEXT);
|
||||
CREATE TABLE sleep_diary_events(revision_id TEXT REFERENCES sleep_diary_revisions(revision_id),event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id));
|
||||
CREATE TABLE sleep_medication_intakes(revision_id TEXT REFERENCES sleep_diary_revisions(revision_id),intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,quantity INTEGER,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id));
|
||||
CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
|
||||
CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER);
|
||||
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
|
||||
CREATE TABLE sleep_diary_generation_entries(generation_id TEXT,entry_id TEXT,revision_id TEXT,PRIMARY KEY(generation_id,entry_id));
|
||||
CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT);
|
||||
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
|
||||
CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,producer_kind TEXT,generated_at TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,status TEXT,manifest_json TEXT,staging_path TEXT,acknowledged_at TEXT);
|
||||
""")
|
||||
item = incoming_entry()
|
||||
self.db.execute(
|
||||
"INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,0)",
|
||||
(ENTRY, item["night_start_date"], item["night_end_date"], item["created_at"],
|
||||
item["updated_at"], COLLISION),
|
||||
)
|
||||
# The fixture intentionally reproduces two parents and one reused id.
|
||||
for revision_id, parent in ((REMOTE_PARENT, None), (LOCAL_PARENT, REMOTE_PARENT)):
|
||||
self.db.execute(
|
||||
"INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||
(revision_id, ENTRY, parent, item["updated_at"], None, None, None, ""),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||
(COLLISION, ENTRY, LOCAL_PARENT, item["updated_at"], "TB", "B", None, ""),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
|
||||
(COLLISION, item["events"][0]["event_id"], "sleep",
|
||||
item["events"][0]["start_at"], item["events"][0]["end_at"]),
|
||||
)
|
||||
for suffix, quantity in (("8", 1), ("9", 2)):
|
||||
self.db.execute(
|
||||
"INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
(COLLISION, f"mdi_80000000-0000-4000-8000-00000000000{suffix}",
|
||||
"med_90000000-0000-4000-8000-000000000009", "Fixture medication",
|
||||
"2026-10-24T22:30:00+02:00", 10.0, "mg", quantity, "",
|
||||
"2026-10-24T22:30:00+02:00"),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO sleep_diary_publication_state VALUES(?,?,?,?,?)",
|
||||
(ENTRY, REMOTE_PARENT, item["updated_at"], REMOTE_PARENT, item["updated_at"]),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
def ids(self):
|
||||
values = iter((LOCAL_REIDENTIFIED, RECONCILIATION))
|
||||
return lambda: next(values)
|
||||
|
||||
def state(self):
|
||||
return {
|
||||
"entry": self.db.execute("SELECT * FROM sleep_diary_entries").fetchall(),
|
||||
"revisions": self.db.execute(
|
||||
"SELECT * FROM sleep_diary_revisions ORDER BY revision_id"
|
||||
).fetchall(),
|
||||
"events": self.db.execute(
|
||||
"SELECT * FROM sleep_diary_events ORDER BY revision_id,event_id"
|
||||
).fetchall(),
|
||||
"intakes": self.db.execute(
|
||||
"SELECT * FROM sleep_medication_intakes ORDER BY revision_id,intake_id"
|
||||
).fetchall(),
|
||||
}
|
||||
|
||||
def test_unpublished_branch_is_preserved_and_replay_is_idempotent(self):
|
||||
report = recovery.recover(
|
||||
self.db, document(), COLLISION, RECOVERED_AT, self.ids()
|
||||
)
|
||||
self.assertEqual(LOCAL_REIDENTIFIED, report["local_reidentified_revision_id"])
|
||||
self.assertEqual(RECONCILIATION, report["reconciliation_revision_id"])
|
||||
self.assertEqual((2, 1), (report["intake_count"], report["event_count"]))
|
||||
self.assertEqual(
|
||||
RECONCILIATION,
|
||||
self.db.execute("SELECT current_revision_id FROM sleep_diary_entries").fetchone()[0],
|
||||
)
|
||||
self.assertEqual(
|
||||
(REMOTE_PARENT, None, None),
|
||||
self.db.execute(
|
||||
"SELECT parent_revision_id,sleep_quality,wake_quality FROM sleep_diary_revisions "
|
||||
"WHERE revision_id=?", (COLLISION,),
|
||||
).fetchone(),
|
||||
)
|
||||
self.assertEqual(
|
||||
(COLLISION, "TB", "B"),
|
||||
self.db.execute(
|
||||
"SELECT parent_revision_id,sleep_quality,wake_quality FROM sleep_diary_revisions "
|
||||
"WHERE revision_id=?", (RECONCILIATION,),
|
||||
).fetchone(),
|
||||
)
|
||||
self.assertEqual(
|
||||
[(1,), (2,)],
|
||||
self.db.execute(
|
||||
"SELECT quantity FROM sleep_medication_intakes WHERE revision_id=? "
|
||||
"ORDER BY intake_id", (RECONCILIATION,),
|
||||
).fetchall(),
|
||||
)
|
||||
self.assertEqual((0, 1), exchange.apply(self.db, document()))
|
||||
self.assertEqual([], self.db.execute("PRAGMA foreign_key_check").fetchall())
|
||||
|
||||
def test_historical_identity_mismatch_is_rejected_after_recovery(self):
|
||||
recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids())
|
||||
conflicting = copy.deepcopy(document())
|
||||
conflicting["entries"][0]["sleep_quality"] = "M"
|
||||
with self.assertRaisesRegex(ValueError, "identity reused"):
|
||||
exchange.apply(self.db, conflicting)
|
||||
|
||||
def test_failure_rolls_back_every_reidentification_write(self):
|
||||
before = self.state()
|
||||
with self.assertRaisesRegex(recovery.RecoveryError, "injected"):
|
||||
recovery.recover(
|
||||
self.db, document(), COLLISION, RECOVERED_AT, self.ids(),
|
||||
fail_after_reidentification=True,
|
||||
)
|
||||
self.assertEqual(before, self.state())
|
||||
|
||||
def test_published_local_branch_is_never_reidentified(self):
|
||||
self.db.execute(
|
||||
"UPDATE sleep_diary_publication_state SET validated_revision_id=? WHERE entry_id=?",
|
||||
(COLLISION, ENTRY),
|
||||
)
|
||||
self.db.commit()
|
||||
with self.assertRaisesRegex(recovery.RecoveryError, "publication state"):
|
||||
recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids())
|
||||
|
||||
def test_two_non_null_scalar_values_require_human_decision(self):
|
||||
conflicting = document()
|
||||
conflicting["entries"][0]["sleep_quality"] = "M"
|
||||
with self.assertRaisesRegex(recovery.RecoveryError, "human decision"):
|
||||
recovery.recover(self.db, conflicting, COLLISION, RECOVERED_AT, self.ids())
|
||||
|
||||
def test_same_intake_identity_with_different_quantity_is_rejected(self):
|
||||
conflicting = document()
|
||||
conflicting["entries"][0]["intakes"] = [{
|
||||
"intake_id": "mdi_80000000-0000-4000-8000-000000000008",
|
||||
"medication_id": "med_90000000-0000-4000-8000-000000000009",
|
||||
"medication_name": "Fixture medication",
|
||||
"taken_at": "2026-10-24T22:30:00+02:00",
|
||||
"dose_value": 10.0,
|
||||
"dose_unit": "mg",
|
||||
"quantity": 2,
|
||||
"note": "",
|
||||
"created_at": "2026-10-24T22:30:00+02:00",
|
||||
}]
|
||||
with self.assertRaisesRegex(recovery.RecoveryError, "identity reused"):
|
||||
recovery.recover(self.db, conflicting, COLLISION, RECOVERED_AT, self.ids())
|
||||
|
||||
def insert_rejection_ack(self) -> None:
|
||||
ack_id = "ack_" + GENERATION.removeprefix("gen_")
|
||||
self.db.execute(
|
||||
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(ack_id, GENERATION, RUN, PRODUCER, CONSUMER, MANIFEST_DIGEST, "rejected",
|
||||
"sqlite-commit-rejection", RECOVERED_AT, "", "b" * 64),
|
||||
)
|
||||
|
||||
def test_desktop_rejection_is_rearmed_with_recovery_transaction(self):
|
||||
self.db.execute(
|
||||
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(GENERATION, RUN, PRODUCER, CONSUMER, None, MANIFEST_DIGEST, RECOVERED_AT,
|
||||
"rejected", "sqlite-commit-rejection", "", "{}"),
|
||||
)
|
||||
self.insert_rejection_ack()
|
||||
self.db.commit()
|
||||
report = recovery.recover(
|
||||
self.db, document(), COLLISION, RECOVERED_AT, self.ids(),
|
||||
rejected_generation=(manifest(), MANIFEST_DIGEST),
|
||||
)
|
||||
self.assertTrue(report["desktop_rejection_rearmed"])
|
||||
self.assertEqual(0, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sync_consumed_generations WHERE generation_id=?", (GENERATION,)
|
||||
).fetchone()[0])
|
||||
self.assertEqual(0, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,)
|
||||
).fetchone()[0])
|
||||
|
||||
def test_post_recovery_schema_rejection_can_be_rearmed_exactly(self):
|
||||
recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids())
|
||||
self.db.execute(
|
||||
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(GENERATION, RUN, PRODUCER, CONSUMER, None, MANIFEST_DIGEST, RECOVERED_AT,
|
||||
"rejected", "sqlite-commit-rejection", "schema desktop v8 à v35 requis", "{}"),
|
||||
)
|
||||
self.insert_rejection_ack()
|
||||
self.db.commit()
|
||||
report = recovery.rearm_desktop_consumer(
|
||||
self.db, document(), manifest(), MANIFEST_DIGEST, COLLISION
|
||||
)
|
||||
self.assertEqual("ready_for_retry", report["status"])
|
||||
self.assertFalse(report["immutable_artifacts_rewritten"])
|
||||
self.assertEqual(0, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sync_consumed_generations WHERE generation_id=?", (GENERATION,)
|
||||
).fetchone()[0])
|
||||
self.assertEqual(0, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,)
|
||||
).fetchone()[0])
|
||||
|
||||
def test_android_rearm_preserves_manifest_and_sleep_mapping(self):
|
||||
self.db.execute(
|
||||
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(GENERATION, RUN, PRODUCER, CONSUMER, "android", manifest()["generated_at"],
|
||||
None, MANIFEST_DIGEST, "rejected", "{}", "/immutable", RECOVERED_AT),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO sleep_diary_generation_entries VALUES(?,?,?)",
|
||||
(GENERATION, ENTRY, COLLISION),
|
||||
)
|
||||
self.insert_rejection_ack()
|
||||
self.db.commit()
|
||||
report = recovery.rearm_android_producer(
|
||||
self.db, manifest(), MANIFEST_DIGEST, COLLISION
|
||||
)
|
||||
self.assertFalse(report["immutable_artifacts_rewritten"])
|
||||
self.assertEqual(
|
||||
("waiting_acknowledgement", None, "{}", "/immutable"),
|
||||
self.db.execute(
|
||||
"SELECT status,acknowledged_at,manifest_json,staging_path FROM sync_generations "
|
||||
"WHERE generation_id=?", (GENERATION,),
|
||||
).fetchone(),
|
||||
)
|
||||
self.assertEqual(0, self.db.execute(
|
||||
"SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,)
|
||||
).fetchone()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -13,6 +13,17 @@ import sync_generation_exchange as generation
|
|||
|
||||
|
||||
class DeploymentToolsTest(unittest.TestCase):
|
||||
def test_user_service_installer_retires_only_known_legacy_exec_override(self):
|
||||
installer = (ROOT / "tools/install_syncd_user.sh").read_text()
|
||||
self.assertIn(
|
||||
"grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3'",
|
||||
installer,
|
||||
)
|
||||
self.assertIn('rm -f -- "$LEGACY_ROLLOUT"', installer)
|
||||
self.assertIn('--sync-once "$SYNC_ONCE"', installer)
|
||||
self.assertIn('--bt-adapter "$BT_ADAPTER"', installer)
|
||||
self.assertIn('--mtp-adapter "$MTP_ADAPTER"', installer)
|
||||
|
||||
def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
|
@ -65,8 +76,8 @@ 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.5")
|
||||
self.assertEqual(inventory["desktop_schema"], 35)
|
||||
self.assertEqual(inventory["product_version"], "0.1.6")
|
||||
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"])
|
||||
|
|
@ -76,6 +87,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
self.assertIn("cardio-calibrations-v1", inventory["protocols"])
|
||||
self.assertIn("cardio-guidance-v1", inventory["protocols"])
|
||||
self.assertIn("heart-rate-v1", inventory["protocols"])
|
||||
self.assertIn("heart-rate-corrections-v1", inventory["protocols"])
|
||||
self.assertIn("session-timeline-v1", inventory["protocols"])
|
||||
self.assertIn("bluetooth-files-v1", inventory["protocols"])
|
||||
self.assertIn("generation-ack-recovery-v1", inventory["protocols"])
|
||||
|
|
@ -102,7 +114,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
text=True,
|
||||
)
|
||||
self.assertEqual(linked.returncode, 0, linked.stderr)
|
||||
self.assertIn("trainlog 0.1.5", linked.stdout)
|
||||
self.assertIn("trainlog 0.1.6", linked.stdout)
|
||||
imported = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,105 @@ 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_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
|
||||
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO sync_peer_identity VALUES(1,?,'desktop')",
|
||||
(PEER_A,),
|
||||
)
|
||||
for identity, generated in (
|
||||
(active, "2026-09-17T09:00:00+02:00"),
|
||||
(archived, "2026-09-17T09:01:00+02:00"),
|
||||
):
|
||||
db.execute(
|
||||
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
identity, RUN, PEER_A, PEER_B, "desktop", generated, None,
|
||||
"a" * 64, "acknowledged", "{}", str(self.root / identity),
|
||||
generated,
|
||||
),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"ack_" + identity[4:], identity, RUN, PEER_A, PEER_B,
|
||||
"a" * 64, "consumed", "sqlite-commit-full", generated,
|
||||
"", "b" * 64,
|
||||
),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sync_generation_archives VALUES(?,?,?,?,?,?)",
|
||||
(archived, str(self.root / "archive"), "a" * 64, "b" * 64,
|
||||
"2026-09-17T09:02:00+02:00", "{}"),
|
||||
)
|
||||
db.commit()
|
||||
_, manifest, _ = self.capture()
|
||||
self.assertEqual(active, manifest["parent_generation_id"])
|
||||
|
||||
def test_acknowledged_row_without_durable_ack_is_not_an_active_tip(self):
|
||||
legacy = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
current = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO sync_peer_identity VALUES(1,?,'desktop')",
|
||||
(PEER_A,),
|
||||
)
|
||||
for identity, generated in (
|
||||
(legacy, "2026-09-17T09:00:00+02:00"),
|
||||
(current, "2026-09-17T09:01:00+02:00"),
|
||||
):
|
||||
db.execute(
|
||||
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
identity, RUN, PEER_A, PEER_B, "desktop", generated,
|
||||
None, "a" * 64, "acknowledged", "{}",
|
||||
str(self.root / identity), generated,
|
||||
),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"ack_" + current[4:], current, RUN, PEER_A, PEER_B,
|
||||
"a" * 64, "consumed", "sqlite-commit-full",
|
||||
"2026-09-17T09:01:00+02:00", "", "b" * 64,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
_, manifest, _ = self.capture()
|
||||
self.assertEqual(current, manifest["parent_generation_id"])
|
||||
|
||||
def test_consumer_accepts_producer_selected_current_fork_tip(self):
|
||||
first = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
second = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
stale = "gen_cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
for identity, parent in (
|
||||
(stale, None),
|
||||
(first, stale),
|
||||
(second, stale),
|
||||
):
|
||||
db.execute(
|
||||
"INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
identity, RUN, PEER_B, PEER_A, parent, "a" * 64,
|
||||
"2026-09-17T09:00:00+02:00", "consumed",
|
||||
"sqlite-commit-full", "", "{}",
|
||||
),
|
||||
)
|
||||
generation.require_current_consumed_parent(db, PEER_B, first)
|
||||
generation.require_current_consumed_parent(db, PEER_B, second)
|
||||
with self.assertRaisesRegex(
|
||||
generation.GenerationError, "stale or unrelated"
|
||||
):
|
||||
generation.require_current_consumed_parent(db, PEER_B, stale)
|
||||
with self.assertRaisesRegex(
|
||||
generation.GenerationError, "stale or unrelated"
|
||||
):
|
||||
generation.require_current_consumed_parent(db, PEER_B, None)
|
||||
generation.require_current_consumed_parent(db, PEER_A, None)
|
||||
|
||||
def test_sleep_revision_becomes_acknowledged_only_after_correlated_ack(self):
|
||||
stage, manifest, checksum = self.capture()
|
||||
self.publish()
|
||||
|
|
|
|||
|
|
@ -156,7 +156,10 @@ class OrchestratorTest(unittest.TestCase):
|
|||
"assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n"
|
||||
"assert '--bt-adapter' in sys.argv\n"
|
||||
"v=" + repr(report) +
|
||||
"\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n"
|
||||
"\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\n"
|
||||
"p={'format':'trainlog-sync-progress','version':1,'run_id':v['run_id'],"
|
||||
"'phase':'android_archive_ack_observed','recovered_acknowledgements':1}\n"
|
||||
"print(json.dumps(p))\nprint(json.dumps(v))\n"
|
||||
)
|
||||
bt_marker = root / "bt-probe"
|
||||
bt_adapter = sandbox / "trainlog_generation_bt_adapter.py"
|
||||
|
|
@ -175,7 +178,7 @@ class OrchestratorTest(unittest.TestCase):
|
|||
bluetooth_device_address="AA:BB:CC:DD:EE:FF",
|
||||
)
|
||||
result = self.invoke(sandbox / "sync_orchestrator.py", values)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.returncode, 0, result.stderr or values[1].read_text())
|
||||
self.assertTrue(bt_marker.is_file())
|
||||
self.assertFalse(mtp_marker.exists())
|
||||
final = json.loads(values[1].read_text())
|
||||
|
|
|
|||
|
|
@ -368,6 +368,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
"taken_at": "2026-09-19T22:30:00+02:00",
|
||||
"dose_value": 75,
|
||||
"dose_unit": "mg",
|
||||
"quantity": 2,
|
||||
"note": "",
|
||||
"created_at": "2026-09-19T22:30:00+02:00",
|
||||
}
|
||||
|
|
@ -381,6 +382,36 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-event-bed_time']"
|
||||
)
|
||||
)
|
||||
wait.until(
|
||||
lambda current: current.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='heart-rate-sleep-factual']"
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(driver.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='heart-rate-medication-marker']"
|
||||
)),
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(driver.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='heart-rate-sleep-event-final_get_up']"
|
||||
)),
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
len(driver.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='heart-rate-hour-tick']"
|
||||
)),
|
||||
6,
|
||||
)
|
||||
self.assertIn(
|
||||
"venlafaxine · 75 mg ×2",
|
||||
driver.find_element(By.CSS_SELECTOR, ".heart-rate-overlay-list").text,
|
||||
)
|
||||
self.assertFalse(
|
||||
driver.find_elements(By.CSS_SELECTOR, ".heart-rate-line")
|
||||
)
|
||||
geometry = driver.execute_script(
|
||||
"""
|
||||
const axis=document.querySelector('.sleep-hour-axis').getBoundingClientRect();
|
||||
|
|
@ -395,11 +426,13 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
};
|
||||
const sleep=box('sleep-agenda-event-sleep');
|
||||
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||
.filter(value => getComputedStyle(value).display !== 'none')
|
||||
.map(value => {
|
||||
const tick=value.getBoundingClientRect();
|
||||
return {
|
||||
label:value.textContent,
|
||||
center:tick.left + tick.width / 2,
|
||||
left:value.style.left,
|
||||
};
|
||||
});
|
||||
return {
|
||||
|
|
@ -428,23 +461,27 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
self.assertAlmostEqual(
|
||||
expected_x, geometry[key], delta=1.0, msg=key
|
||||
)
|
||||
for tick_index in (4, 9, 10, 11, 21, 22):
|
||||
expected_x = geometry["left"] + geometry["width"] * tick_index / 24
|
||||
self.assertAlmostEqual(
|
||||
expected_x,
|
||||
geometry["ticks"][tick_index]["center"],
|
||||
delta=1.0,
|
||||
)
|
||||
agenda_tick_hours = tuple(range(0, 25, 2))
|
||||
self.assertEqual(
|
||||
[f"{(18 + hour) % 24:02d}" for hour in agenda_tick_hours],
|
||||
[tick["label"] for tick in geometry["ticks"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
[f"{tick_hour / 24 * 100:g}%" for tick_hour in agenda_tick_hours],
|
||||
[tick["left"] for tick in geometry["ticks"]],
|
||||
)
|
||||
self.assertTrue(
|
||||
driver.save_screenshot(
|
||||
str(EVIDENCE / "sleep-diary-alignment-firefox.png")
|
||||
)
|
||||
)
|
||||
|
||||
set_value("sleep-night-start", "2026-09-21")
|
||||
set_value("sleep-night-start", "2025-01-01")
|
||||
wait.until(
|
||||
lambda current: "Aucune donnée pour cette nuit."
|
||||
in current.page_source
|
||||
lambda current: current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-night-start']"
|
||||
).get_attribute("value")
|
||||
== "2025-01-01"
|
||||
)
|
||||
self.assertFalse(
|
||||
driver.find_elements(
|
||||
|
|
@ -492,6 +529,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
response = json.loads(bidi.recv())
|
||||
self.assertEqual("success", response["type"])
|
||||
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
|
||||
narrow_ticks = driver.execute_script(
|
||||
"""
|
||||
return [...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||
.filter(value => getComputedStyle(value).display !== 'none')
|
||||
.map(value => value.textContent);
|
||||
"""
|
||||
)
|
||||
self.assertEqual(["18", "00", "06", "12", "18"], narrow_ticks)
|
||||
self.assertLessEqual(
|
||||
driver.execute_script("return document.documentElement.scrollWidth"),
|
||||
driver.execute_script("return document.documentElement.clientWidth"),
|
||||
|
|
@ -599,7 +644,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
|
||||
)
|
||||
)
|
||||
== 1
|
||||
== 7
|
||||
)
|
||||
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
||||
download_path = EVIDENCE / "trainlog-sleep-diary.pdf"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
fail("desktop schema v33-v35 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):
|
||||
fail("desktop schema v34-v35 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):
|
||||
raise CausalError(f"desktop schema v20-v35 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):
|
||||
|
|
@ -243,7 +243,12 @@ def load(path: Path) -> dict:
|
|||
|
||||
def export_document(db: sqlite3.Connection) -> dict:
|
||||
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
|
||||
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id LIMIT ?", (MAX_OPERATIONS + 1,)).fetchall()
|
||||
rows = db.execute(
|
||||
"SELECT " + ",".join(keys) + " FROM sync_causal_operations "
|
||||
"WHERE target_kind IN('session','execution_draft','exercise','body_observation',"
|
||||
"'custom_equipment','feedback','body_zone_relation') "
|
||||
"ORDER BY operation_id LIMIT ?", (MAX_OPERATIONS + 1,),
|
||||
).fetchall()
|
||||
if len(rows) > MAX_OPERATIONS: raise CausalError("protection set exceeds artifact bound")
|
||||
document = {"format": FORMAT, "version": VERSION,
|
||||
"generated_at": dt.datetime.now().astimezone().isoformat(),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
raise LifecycleError(f"desktop schema v19-v35 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)
|
||||
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-v35 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)
|
||||
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-v35 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, 36))
|
||||
supported_versions = tuple(range(8, 37))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v35 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, 36))
|
||||
supported_versions = tuple(range(8, 37))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v35 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):
|
||||
raise ValueError("schema desktop v12-v35 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, 36))
|
||||
supported_versions = tuple(range(11, 37))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v35 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):
|
||||
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, 36):
|
||||
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):
|
||||
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, 36)
|
||||
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-v35 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):
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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, 36)
|
||||
supported_versions = range(15, 37)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v35 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()
|
||||
|
|
|
|||
338
tools/heart_rate_correction_exchange.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Versioned causal corrections for acknowledged Heart Rate V1 captures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from validate_json import parse_timestamp
|
||||
|
||||
|
||||
FORMAT = "trainlog-heart-rate-corrections"
|
||||
VERSION = 1
|
||||
TARGET_KIND = "heart_rate_tail"
|
||||
MAX_BYTES = 4 * 1024 * 1024
|
||||
MAX_CORRECTIONS = 256
|
||||
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$")
|
||||
OPERATION_ID = re.compile(rf"^hrx_{UUID4}$")
|
||||
HEX = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class CorrectionError(ValueError):
|
||||
"""A correction is malformed, conflicts, or targets incompatible facts."""
|
||||
|
||||
|
||||
def canonical(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def digest(value: object) -> str:
|
||||
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def exact_timestamp(value: str):
|
||||
try:
|
||||
return parse_timestamp(value, "heart_rate_timestamp")
|
||||
except (TypeError, ValueError) as error:
|
||||
raise CorrectionError("invalid heart-rate timestamp") from error
|
||||
|
||||
|
||||
def target_id(capture_id: str, cutoff: str) -> str:
|
||||
if not isinstance(capture_id, str) or not CAPTURE_ID.fullmatch(capture_id):
|
||||
raise CorrectionError("invalid heart-rate capture identity")
|
||||
exact_timestamp(cutoff)
|
||||
return f"{capture_id}|{cutoff}"
|
||||
|
||||
|
||||
def split_target(value: str) -> tuple[str, str]:
|
||||
parts = value.split("|", 1)
|
||||
if len(parts) != 2:
|
||||
raise CorrectionError("invalid heart-rate correction target")
|
||||
target_id(parts[0], parts[1])
|
||||
return parts[0], parts[1]
|
||||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
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:
|
||||
return db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def capture_snapshot(db: sqlite3.Connection, capture_id: str):
|
||||
row = db.execute(
|
||||
"SELECT capture_id,context_kind,context_id,started_at,ended_at,sensor_name "
|
||||
"FROM heart_rate_captures WHERE capture_id=?", (capture_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
samples = []
|
||||
for sample in db.execute(
|
||||
"SELECT sequence,observed_at,bpm,exercise_entry_id,sensor_contact_detected,"
|
||||
"energy_expended FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence",
|
||||
(capture_id,),
|
||||
):
|
||||
rr = [list(value) for value in db.execute(
|
||||
"SELECT rr_index,value_1024 FROM heart_rate_rr_intervals "
|
||||
"WHERE capture_id=? AND sample_sequence=? ORDER BY rr_index",
|
||||
(capture_id, sample[0]),
|
||||
)]
|
||||
samples.append([*sample, rr])
|
||||
return [*row, samples]
|
||||
|
||||
|
||||
def live_revision(db: sqlite3.Connection, capture_id: str) -> str | None:
|
||||
snapshot = capture_snapshot(db, capture_id)
|
||||
return None if snapshot is None else "lv_" + digest(snapshot)
|
||||
|
||||
|
||||
def authorize(db: sqlite3.Connection, capture_id: str, cutoff: str) -> None:
|
||||
row = db.execute(
|
||||
"SELECT context_kind,context_id,started_at,ended_at FROM heart_rate_captures "
|
||||
"WHERE capture_id=?", (capture_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise CorrectionError("heart-rate capture not found")
|
||||
if row[0] not in ("session", "cardio"):
|
||||
raise CorrectionError("only session heart-rate captures can be corrected")
|
||||
if row[3] is None:
|
||||
raise CorrectionError("active heart-rate capture cannot be corrected")
|
||||
cutoff_key = exact_timestamp(cutoff)
|
||||
if cutoff_key < exact_timestamp(row[2]) or cutoff_key > exact_timestamp(row[3]):
|
||||
raise CorrectionError("heart-rate cutoff is outside capture")
|
||||
session = db.execute(
|
||||
"SELECT ended_at FROM sessions WHERE session_id=?", (row[1],),
|
||||
).fetchone()
|
||||
if session is None or session[0] != cutoff:
|
||||
raise CorrectionError("heart-rate cutoff must equal the factual session end")
|
||||
timeline = ("session_timeline_exercises"
|
||||
if _table_exists(db, "session_timeline_exercises")
|
||||
else "session_exercise_timeline")
|
||||
latest = db.execute(
|
||||
f"SELECT MAX(ended_at) FROM {timeline} WHERE session_id=?", (row[1],),
|
||||
).fetchone()[0]
|
||||
if latest is not None and exact_timestamp(latest) > cutoff_key:
|
||||
raise CorrectionError("heart-rate cutoff precedes the last exercise marker")
|
||||
previous = None
|
||||
for expected, (sequence, observed_at) in enumerate(db.execute(
|
||||
"SELECT sequence,observed_at FROM heart_rate_samples "
|
||||
"WHERE capture_id=? ORDER BY sequence", (capture_id,),
|
||||
)):
|
||||
observed = exact_timestamp(observed_at)
|
||||
if sequence != expected or previous is not None and observed < previous:
|
||||
raise CorrectionError("heart-rate samples are not one chronological sequence")
|
||||
previous = observed
|
||||
|
||||
|
||||
def correction_payload(correction: dict) -> dict:
|
||||
return {key: correction[key] for key in (
|
||||
"operation_id", "capture_id", "cutoff", "creator_id",
|
||||
"predecessor_revision_id", "created_at",
|
||||
)}
|
||||
|
||||
|
||||
def validate_correction(value: object) -> dict:
|
||||
keys = {"operation_id", "capture_id", "cutoff", "creator_id",
|
||||
"predecessor_revision_id", "created_at", "payload_sha256"}
|
||||
if not isinstance(value, dict) or set(value) != keys:
|
||||
raise CorrectionError("invalid heart-rate correction shape")
|
||||
if not isinstance(value["operation_id"], str) or not OPERATION_ID.fullmatch(value["operation_id"]):
|
||||
raise CorrectionError("invalid heart-rate correction identity")
|
||||
target_id(value["capture_id"], value["cutoff"])
|
||||
for field in ("creator_id", "predecessor_revision_id"):
|
||||
if not isinstance(value[field], str) or not 0 < len(value[field].encode()) <= 128:
|
||||
raise CorrectionError(f"invalid {field}")
|
||||
exact_timestamp(value["created_at"])
|
||||
if not isinstance(value["payload_sha256"], str) or not HEX.fullmatch(value["payload_sha256"]):
|
||||
raise CorrectionError("invalid heart-rate correction digest")
|
||||
if digest(correction_payload(value)) != value["payload_sha256"]:
|
||||
raise CorrectionError("heart-rate correction digest mismatch")
|
||||
return value
|
||||
|
||||
|
||||
def apply_effect(db: sqlite3.Connection, capture_id: str, cutoff: str) -> None:
|
||||
# WHY: a stopped capture may cross an immutable generation before recovery
|
||||
# establishes the factual session end.
|
||||
# CONTRACT: the cutoff is inclusive; only its strict BPM/RR tail is removed.
|
||||
# INVARIANT: acknowledged generation bytes and retained identities never change.
|
||||
cutoff_key = exact_timestamp(cutoff)
|
||||
removed = [sequence for sequence, observed_at in db.execute(
|
||||
"SELECT sequence,observed_at FROM heart_rate_samples "
|
||||
"WHERE capture_id=? ORDER BY sequence", (capture_id,),
|
||||
) if exact_timestamp(observed_at) > cutoff_key]
|
||||
db.executemany(
|
||||
"DELETE FROM heart_rate_samples WHERE capture_id=? AND sequence=?",
|
||||
[(capture_id, sequence) for sequence in removed],
|
||||
)
|
||||
if db.execute(
|
||||
"UPDATE heart_rate_captures SET ended_at=? WHERE capture_id=?",
|
||||
(cutoff, capture_id),
|
||||
).rowcount != 1:
|
||||
raise CorrectionError("heart-rate capture disappeared during correction")
|
||||
|
||||
|
||||
def apply_correction(db: sqlite3.Connection, value: dict) -> str:
|
||||
correction = validate_correction(value)
|
||||
operation_id = correction["operation_id"]
|
||||
target = target_id(correction["capture_id"], correction["cutoff"])
|
||||
known = db.execute(
|
||||
"SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,"
|
||||
"payload_sha256,publication_context FROM sync_causal_operations "
|
||||
"WHERE operation_id=?", (operation_id,),
|
||||
).fetchone()
|
||||
expected = (TARGET_KIND, target, correction["creator_id"],
|
||||
correction["predecessor_revision_id"], correction["created_at"],
|
||||
correction["payload_sha256"], None)
|
||||
if known is not None:
|
||||
if tuple(known) != expected:
|
||||
raise CorrectionError("heart-rate correction identity reused")
|
||||
return "unchanged"
|
||||
authorize(db, correction["capture_id"], correction["cutoff"])
|
||||
state = db.execute(
|
||||
"SELECT current_revision_id,deleted FROM sync_causal_state "
|
||||
"WHERE target_kind=? AND target_id=?", (TARGET_KIND, target),
|
||||
).fetchone()
|
||||
current = state[0] if state else live_revision(db, correction["capture_id"])
|
||||
if state and state[1]:
|
||||
raise CorrectionError("concurrent heart-rate correction conflict")
|
||||
if current is None or current != correction["predecessor_revision_id"]:
|
||||
raise CorrectionError("heart-rate correction predecessor conflict")
|
||||
count = db.execute(
|
||||
"SELECT COUNT(*) FROM sync_causal_operations WHERE target_kind=?", (TARGET_KIND,),
|
||||
).fetchone()[0]
|
||||
if count >= MAX_CORRECTIONS:
|
||||
raise CorrectionError("heart-rate correction bound exceeded")
|
||||
db.execute(
|
||||
"INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,NULL)",
|
||||
(operation_id, TARGET_KIND, target, correction["creator_id"], current,
|
||||
correction["created_at"], correction["payload_sha256"]),
|
||||
)
|
||||
apply_effect(db, correction["capture_id"], correction["cutoff"])
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO sync_causal_state VALUES(?,?,?,?,?)",
|
||||
(TARGET_KIND, target, operation_id, 1, operation_id),
|
||||
)
|
||||
if len(canonical(export_document(db)).encode("utf-8")) > MAX_BYTES:
|
||||
raise CorrectionError("heart-rate correction artifact exceeds byte bound")
|
||||
return "applied"
|
||||
|
||||
|
||||
def local_correction(db: sqlite3.Connection, capture_id: str, cutoff: str,
|
||||
creator_id: str) -> tuple[dict, str]:
|
||||
target = target_id(capture_id, cutoff)
|
||||
state = db.execute(
|
||||
"SELECT current_revision_id,deleted FROM sync_causal_state "
|
||||
"WHERE target_kind=? AND target_id=?", (TARGET_KIND, target),
|
||||
).fetchone()
|
||||
if state and state[1]:
|
||||
row = db.execute(
|
||||
"SELECT operation_id,creator_id,predecessor_revision_id,created_at,payload_sha256 "
|
||||
"FROM sync_causal_operations WHERE operation_id=?", (state[0],),
|
||||
).fetchone()
|
||||
correction = {
|
||||
"operation_id": row[0], "capture_id": capture_id, "cutoff": cutoff,
|
||||
"creator_id": row[1], "predecessor_revision_id": row[2],
|
||||
"created_at": row[3], "payload_sha256": row[4],
|
||||
}
|
||||
return correction, "unchanged"
|
||||
authorize(db, capture_id, cutoff)
|
||||
predecessor = state[0] if state else live_revision(db, capture_id)
|
||||
if predecessor is None:
|
||||
raise CorrectionError("heart-rate capture not found")
|
||||
correction = {
|
||||
"operation_id": "hrx_" + str(uuid.uuid4()),
|
||||
"capture_id": capture_id,
|
||||
"cutoff": cutoff,
|
||||
"creator_id": creator_id,
|
||||
"predecessor_revision_id": predecessor,
|
||||
"created_at": dt.datetime.now().astimezone().isoformat(),
|
||||
}
|
||||
correction["payload_sha256"] = digest(correction_payload(correction))
|
||||
return correction, apply_correction(db, correction)
|
||||
|
||||
|
||||
def export_document(db: sqlite3.Connection) -> dict:
|
||||
corrections = []
|
||||
for row in db.execute(
|
||||
"SELECT operation_id,target_id,creator_id,predecessor_revision_id,created_at,"
|
||||
"payload_sha256 FROM sync_causal_operations WHERE target_kind=? "
|
||||
"ORDER BY operation_id LIMIT ?", (TARGET_KIND, MAX_CORRECTIONS + 1),
|
||||
):
|
||||
capture_id, cutoff = split_target(row[1])
|
||||
corrections.append({
|
||||
"operation_id": row[0], "capture_id": capture_id, "cutoff": cutoff,
|
||||
"creator_id": row[2], "predecessor_revision_id": row[3],
|
||||
"created_at": row[4], "payload_sha256": row[5],
|
||||
})
|
||||
if len(corrections) > MAX_CORRECTIONS:
|
||||
raise CorrectionError("heart-rate correction bound exceeded")
|
||||
result = {
|
||||
"format": FORMAT,
|
||||
"version": VERSION,
|
||||
"generated_at": dt.datetime.now().astimezone().isoformat(),
|
||||
"corrections": corrections,
|
||||
}
|
||||
if len(canonical(result).encode("utf-8")) > MAX_BYTES:
|
||||
raise CorrectionError("heart-rate correction artifact exceeds byte bound")
|
||||
return result
|
||||
|
||||
|
||||
def validate_document(value: object) -> dict:
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"format", "version", "generated_at", "corrections"}:
|
||||
raise CorrectionError("invalid heart-rate correction envelope")
|
||||
if value["format"] != FORMAT or type(value["version"]) is not int or value["version"] != VERSION:
|
||||
raise CorrectionError("unsupported heart-rate correction artifact")
|
||||
exact_timestamp(value["generated_at"])
|
||||
if not isinstance(value["corrections"], list) or len(value["corrections"]) > MAX_CORRECTIONS:
|
||||
raise CorrectionError("heart-rate correction bound exceeded")
|
||||
identities = set()
|
||||
for correction in value["corrections"]:
|
||||
validate_correction(correction)
|
||||
if correction["operation_id"] in identities:
|
||||
raise CorrectionError("duplicate heart-rate correction identity")
|
||||
identities.add(correction["operation_id"])
|
||||
return value
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
raw = path.read_bytes()
|
||||
if len(raw) > MAX_BYTES:
|
||||
raise CorrectionError("heart-rate correction artifact exceeds byte bound")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise CorrectionError("invalid heart-rate correction JSON") from error
|
||||
return validate_document(value)
|
||||
|
||||
|
||||
def _unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise CorrectionError("duplicate heart-rate correction JSON field")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def apply_document(db: sqlite3.Connection, value: dict) -> tuple[int, int]:
|
||||
validate_document(value)
|
||||
applied = 0
|
||||
unchanged = 0
|
||||
for correction in value["corrections"]:
|
||||
outcome = apply_correction(db, correction)
|
||||
applied += outcome == "applied"
|
||||
unchanged += outcome == "unchanged"
|
||||
return applied, unchanged
|
||||
|
|
@ -9,6 +9,7 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
|
||||
from trainlog_sqlite import connect_database
|
||||
from validate_json import parse_timestamp as exact_timestamp
|
||||
|
||||
|
||||
FORMAT = "trainlog-heart-rate"
|
||||
|
|
@ -134,6 +135,45 @@ def load(path: Path) -> dict:
|
|||
fail("heart-rate artifact exceeds 64 MiB")
|
||||
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
|
||||
|
||||
|
||||
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
||||
return db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def _corrected_capture(db: sqlite3.Connection, capture: dict) -> dict:
|
||||
if not _table_exists(db, "sync_causal_state"):
|
||||
return capture
|
||||
prefix = capture["capture_id"] + "|"
|
||||
cutoffs = []
|
||||
for target_id, in db.execute(
|
||||
"SELECT target_id FROM sync_causal_state "
|
||||
"WHERE target_kind='heart_rate_tail' AND deleted=1 AND target_id LIKE ?",
|
||||
(prefix + "%",),
|
||||
):
|
||||
if not target_id.startswith(prefix):
|
||||
continue
|
||||
raw = target_id[len(prefix):]
|
||||
timestamp(raw)
|
||||
value, parsed = raw, exact_timestamp(raw, "heart_rate_cutoff")
|
||||
cutoffs.append((parsed, value))
|
||||
if not cutoffs:
|
||||
return capture
|
||||
cutoff_key, cutoff = min(cutoffs)
|
||||
started = exact_timestamp(capture["started_at"], "started_at")
|
||||
ended = exact_timestamp(capture["ended_at"], "ended_at")
|
||||
if cutoff_key < started or cutoff_key > ended:
|
||||
fail("causal heart-rate cutoff is outside capture")
|
||||
samples = [sample for sample in capture["samples"]
|
||||
if exact_timestamp(sample["observed_at"], "observed_at") <= cutoff_key]
|
||||
if any(sample["sequence"] != expected for expected, sample in enumerate(samples)):
|
||||
fail("causal heart-rate truncation would break sample sequence")
|
||||
corrected = dict(capture)
|
||||
corrected["ended_at"] = cutoff
|
||||
corrected["samples"] = samples
|
||||
return corrected
|
||||
|
||||
def _same_capture(db: sqlite3.Connection, capture: dict) -> bool:
|
||||
row = db.execute(
|
||||
"SELECT context_kind,context_id,started_at,ended_at,sensor_name "
|
||||
|
|
@ -183,7 +223,13 @@ def _same_capture(db: sqlite3.Connection, capture: dict) -> bool:
|
|||
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||
applied = 0
|
||||
unchanged = 0
|
||||
for capture in root["captures"]:
|
||||
for raw_capture in root["captures"]:
|
||||
# WHY: acknowledged Heart Rate V1 generations are immutable, while a
|
||||
# later causal correction may remove a sensor tail from history.
|
||||
# CONTRACT: replay compares/inserts the old snapshot only after every
|
||||
# durable inclusive cutoff for this capture has been applied.
|
||||
# INVARIANT: a corrected tail cannot be resurrected by old bytes.
|
||||
capture = _corrected_capture(db, raw_capture)
|
||||
if _same_capture(db, capture):
|
||||
unchanged += 1
|
||||
continue
|
||||
|
|
@ -236,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):
|
||||
fail("desktop schema v30-v35 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, 36)
|
||||
supported_versions = range(18, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v35 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, 36)
|
||||
supported_versions = range(8, 37)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v35 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, 36)
|
||||
supported_versions = range(8, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v35 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, 36)
|
||||
supported_versions = range(12, 37)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v35 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, 36)
|
||||
supported_versions = range(11, 37)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v35 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):
|
||||
fail("schema desktop v17-v35 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, 36):
|
||||
if version not in range(5, 37):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v35 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):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v35")
|
||||
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, 36)
|
||||
supported_versions = range(15, 37)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v35 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"]):
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ done
|
|||
|
||||
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
||||
|
||||
# WHY: private pre-orchestrator rollouts installed this exact drop-in to call
|
||||
# syncd without its now-required bounded runtime paths. Leaving it in place
|
||||
# silently overrides the canonical unit below and creates an endless restart
|
||||
# loop. CONTRACT: remove only the known obsolete Trainlog-owned override;
|
||||
# preserve every unrecognized administrator customization. INVARIANT: the
|
||||
# effective ExecStart always owns sync-once plus both transport adapters.
|
||||
LEGACY_ROLLOUT="$HOME/.config/systemd/user/trainlog-syncd.service.d/rollout.conf"
|
||||
if [[ -f "$LEGACY_ROLLOUT" ]] &&
|
||||
grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3' "$LEGACY_ROLLOUT"; then
|
||||
rm -f -- "$LEGACY_ROLLOUT"
|
||||
fi
|
||||
|
||||
ln -sfn "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once"
|
||||
ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd"
|
||||
ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd"
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ RUNTIME_TOOLS = [
|
|||
"sync_generation_exchange.py",
|
||||
"sleep_diary_exchange.py",
|
||||
"cardio_session_exchange.py",
|
||||
"cardio_calibration_exchange.py",
|
||||
"cardio_guidance_exchange.py",
|
||||
"heart_rate_exchange.py",
|
||||
"cardio_calibration_exchange.py",
|
||||
"cardio_guidance_exchange.py",
|
||||
"heart_rate_exchange.py",
|
||||
"heart_rate_correction_exchange.py",
|
||||
"session_timeline_exchange.py",
|
||||
"trainlog_sqlite.py",
|
||||
"validate_json.py",
|
||||
|
|
@ -118,8 +119,8 @@ def main() -> int:
|
|||
"source_commit": subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.5",
|
||||
"desktop_schema": 35,
|
||||
"product_version": "0.1.6",
|
||||
"desktop_schema": 36,
|
||||
"android_schema": 32,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
|
|
@ -140,6 +141,7 @@ def main() -> int:
|
|||
"cardio-calibrations-v1",
|
||||
"cardio-guidance-v1",
|
||||
"heart-rate-v1",
|
||||
"heart-rate-corrections-v1",
|
||||
"session-timeline-v1",
|
||||
],
|
||||
"entry_points": [
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
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
|
||||
|
|
|
|||
308
tools/recover_heart_rate_tail.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Transactionally truncate one acknowledged session heart-rate capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import heart_rate_correction_exchange as correction_exchange
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
class RecoveryError(ValueError):
|
||||
"""The requested recovery does not match the persisted factual session."""
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
raw = json.dumps(value, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":"), default=str).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _rows(db: sqlite3.Connection, query: str, parameters: tuple = ()) -> list[list]:
|
||||
return [list(row) for row in db.execute(query, parameters)]
|
||||
|
||||
|
||||
def protected_snapshot(db: sqlite3.Connection, session_id: str,
|
||||
capture_id: str) -> dict:
|
||||
session = db.execute(
|
||||
"SELECT id FROM sessions WHERE session_id=?", (session_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
raise RecoveryError("session not found")
|
||||
session_row = session[0]
|
||||
occurrence_rows = [row[0] for row in db.execute(
|
||||
"SELECT id FROM session_exercises WHERE session_row_id=? ORDER BY id",
|
||||
(session_row,),
|
||||
)]
|
||||
occurrence_marks = ",".join("?" for _ in occurrence_rows) or "NULL"
|
||||
protected = {
|
||||
"session": _rows(db, "SELECT * FROM sessions WHERE id=?", (session_row,)),
|
||||
"occurrences": _rows(
|
||||
db, "SELECT * FROM session_exercises WHERE session_row_id=? ORDER BY id",
|
||||
(session_row,),
|
||||
),
|
||||
"sets": _rows(
|
||||
db, f"SELECT * FROM performed_sets WHERE session_exercise_row_id IN "
|
||||
f"({occurrence_marks}) ORDER BY id", tuple(occurrence_rows),
|
||||
),
|
||||
"continuous": _rows(
|
||||
db, f"SELECT * FROM continuous_activity WHERE session_exercise_row_id IN "
|
||||
f"({occurrence_marks}) ORDER BY session_exercise_row_id", tuple(occurrence_rows),
|
||||
),
|
||||
"maxima": _rows(
|
||||
db, f"SELECT * FROM max_results WHERE session_exercise_row_id IN "
|
||||
f"({occurrence_marks}) ORDER BY session_exercise_row_id", tuple(occurrence_rows),
|
||||
),
|
||||
"other_captures": _rows(
|
||||
db, "SELECT capture_id,context_kind,context_id,started_at,ended_at,sensor_name "
|
||||
"FROM heart_rate_captures WHERE capture_id<>? ORDER BY capture_id", (capture_id,),
|
||||
),
|
||||
"other_sample_counts": _rows(
|
||||
db, "SELECT capture_id,COUNT(*),MIN(observed_at),MAX(observed_at) "
|
||||
"FROM heart_rate_samples WHERE capture_id<>? GROUP BY capture_id "
|
||||
"ORDER BY capture_id", (capture_id,),
|
||||
),
|
||||
"other_samples": _rows(
|
||||
db, "SELECT * FROM heart_rate_samples WHERE capture_id<>? "
|
||||
"ORDER BY capture_id,sequence", (capture_id,),
|
||||
),
|
||||
"other_rr": _rows(
|
||||
db, "SELECT * FROM heart_rate_rr_intervals WHERE capture_id<>? "
|
||||
"ORDER BY capture_id,sample_sequence,rr_index", (capture_id,),
|
||||
),
|
||||
}
|
||||
if db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='exercise_feedback'",
|
||||
).fetchone():
|
||||
feedback_ids = [row[0] for row in db.execute(
|
||||
f"SELECT feedback_id FROM exercise_feedback WHERE session_exercise_row_id IN "
|
||||
f"({occurrence_marks}) ORDER BY feedback_id", tuple(occurrence_rows),
|
||||
)]
|
||||
feedback_marks = ",".join("?" for _ in feedback_ids) or "NULL"
|
||||
protected["feedback"] = _rows(
|
||||
db, f"SELECT * FROM exercise_feedback WHERE feedback_id IN ({feedback_marks}) "
|
||||
"ORDER BY feedback_id", tuple(feedback_ids),
|
||||
)
|
||||
protected["feedback_revisions"] = _rows(
|
||||
db, f"SELECT * FROM exercise_feedback_revisions WHERE feedback_id IN "
|
||||
f"({feedback_marks}) ORDER BY feedback_id,revision_id", tuple(feedback_ids),
|
||||
)
|
||||
if db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='session_followups'",
|
||||
).fetchone():
|
||||
followup_ids = [row[0] for row in db.execute(
|
||||
"SELECT followup_id FROM session_followups WHERE session_row_id=? "
|
||||
"ORDER BY followup_id", (session_row,),
|
||||
)]
|
||||
followup_marks = ",".join("?" for _ in followup_ids) or "NULL"
|
||||
protected["session_followups"] = _rows(
|
||||
db, f"SELECT * FROM session_followups WHERE followup_id IN "
|
||||
f"({followup_marks}) ORDER BY followup_id", tuple(followup_ids),
|
||||
)
|
||||
protected["session_followup_revisions"] = _rows(
|
||||
db, f"SELECT * FROM session_followup_revisions WHERE followup_id IN "
|
||||
f"({followup_marks}) ORDER BY followup_id,revision_id", tuple(followup_ids),
|
||||
)
|
||||
if db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
||||
"AND name='session_timeline_sessions'",
|
||||
).fetchone():
|
||||
protected["timeline_session"] = _rows(
|
||||
db, "SELECT * FROM session_timeline_sessions WHERE session_id=?", (session_id,),
|
||||
)
|
||||
protected["timeline_exercises"] = _rows(
|
||||
db, "SELECT * FROM session_timeline_exercises WHERE session_id=? "
|
||||
"ORDER BY entry_id", (session_id,),
|
||||
)
|
||||
else:
|
||||
protected["timeline_exercises"] = _rows(
|
||||
db, "SELECT * FROM session_exercise_timeline WHERE session_id=? "
|
||||
"ORDER BY entry_id", (session_id,),
|
||||
)
|
||||
for table in ("sleep_diary_entries", "sleep_diary_revisions", "sleep_diary_events",
|
||||
"sleep_medications", "sleep_medication_intakes"):
|
||||
if db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
|
||||
).fetchone():
|
||||
protected[table] = _rows(db, f"SELECT * FROM {table} ORDER BY rowid")
|
||||
return protected
|
||||
|
||||
|
||||
def capture_report(db: sqlite3.Connection, capture_id: str, cutoff: str) -> dict:
|
||||
cutoff_key = correction_exchange.exact_timestamp(cutoff)
|
||||
capture = db.execute(
|
||||
"SELECT context_kind,context_id,started_at,ended_at,sensor_name "
|
||||
"FROM heart_rate_captures WHERE capture_id=?", (capture_id,),
|
||||
).fetchone()
|
||||
if capture is None:
|
||||
raise RecoveryError("capture not found")
|
||||
samples = db.execute(
|
||||
"SELECT sequence,observed_at,bpm,exercise_entry_id FROM heart_rate_samples "
|
||||
"WHERE capture_id=? ORDER BY sequence", (capture_id,),
|
||||
).fetchall()
|
||||
keep = [row for row in samples
|
||||
if correction_exchange.exact_timestamp(row[1]) <= cutoff_key]
|
||||
remove = [row for row in samples
|
||||
if correction_exchange.exact_timestamp(row[1]) > cutoff_key]
|
||||
rr = db.execute(
|
||||
"SELECT s.observed_at FROM heart_rate_rr_intervals r "
|
||||
"JOIN heart_rate_samples s ON s.capture_id=r.capture_id "
|
||||
"AND s.sequence=r.sample_sequence WHERE r.capture_id=?",
|
||||
(capture_id,),
|
||||
).fetchall()
|
||||
rr_keep = sum(correction_exchange.exact_timestamp(row[0]) <= cutoff_key for row in rr)
|
||||
values = [row[2] for row in samples]
|
||||
kept_values = [row[2] for row in keep]
|
||||
|
||||
def stats(items: list[int]) -> dict:
|
||||
return {
|
||||
"minimum": min(items) if items else None,
|
||||
"average": sum(items) / len(items) if items else None,
|
||||
"maximum": max(items) if items else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"capture_id": capture_id,
|
||||
"context_kind": capture[0],
|
||||
"context_id": capture[1],
|
||||
"started_at": capture[2],
|
||||
"ended_at": capture[3],
|
||||
"sensor_name": capture[4],
|
||||
"bpm_total": len(samples),
|
||||
"bpm_keep": len(keep),
|
||||
"bpm_remove": len(remove),
|
||||
"rr_total": len(rr),
|
||||
"rr_keep": rr_keep,
|
||||
"rr_remove": len(rr) - rr_keep,
|
||||
"first_removed_at": remove[0][1] if remove else None,
|
||||
"last_removed_at": remove[-1][1] if remove else None,
|
||||
"removed_exercise_entry_ids": sorted({row[3] for row in remove},
|
||||
key=lambda value: value or ""),
|
||||
"current_stats": stats(values),
|
||||
"kept_stats": stats(kept_values),
|
||||
}
|
||||
|
||||
|
||||
def load_operation(path: Path) -> dict:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(value, dict) and set(value) == {"format", "version", "generated_at",
|
||||
"corrections"}:
|
||||
correction_exchange.load(path)
|
||||
if len(value["corrections"]) != 1:
|
||||
raise RecoveryError("correction artifact must contain exactly one operation")
|
||||
return value["corrections"][0]
|
||||
return correction_exchange.validate_correction(value)
|
||||
|
||||
|
||||
def recover(db: sqlite3.Connection, session_id: str, capture_id: str, cutoff: str,
|
||||
creator_id: str, apply: bool, operation: dict | None = None,
|
||||
fail_after_effect: bool = False) -> tuple[dict, dict | None]:
|
||||
"""Apply or simulate one exact correction without touching sibling facts.
|
||||
|
||||
CONTRACT: an emitted operation is the immutable content of the separate
|
||||
heart-rate-corrections V1 companion. INVARIANT: every mutation commits
|
||||
together or rolls back.
|
||||
"""
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
correction_exchange.require_schema(db)
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
emitted = operation
|
||||
try:
|
||||
session = db.execute(
|
||||
"SELECT started_at,ended_at FROM sessions WHERE session_id=?", (session_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
raise RecoveryError("session not found")
|
||||
if session[1] != cutoff:
|
||||
raise RecoveryError("cutoff does not equal the factual session end")
|
||||
capture = db.execute(
|
||||
"SELECT context_id FROM heart_rate_captures WHERE capture_id=?", (capture_id,),
|
||||
).fetchone()
|
||||
if capture is None or capture[0] != session_id:
|
||||
raise RecoveryError("capture does not belong to the requested session")
|
||||
before = capture_report(db, capture_id, cutoff)
|
||||
protected_before = protected_snapshot(db, session_id, capture_id)
|
||||
if operation is not None:
|
||||
if operation.get("capture_id") != capture_id or operation.get("cutoff") != cutoff:
|
||||
raise RecoveryError("operation does not match requested capture and cutoff")
|
||||
outcome = correction_exchange.apply_correction(db, operation)
|
||||
elif before["bpm_remove"] == 0 and before["ended_at"] == cutoff:
|
||||
outcome = "unchanged"
|
||||
emitted = None
|
||||
else:
|
||||
emitted, outcome = correction_exchange.local_correction(
|
||||
db, capture_id, cutoff, creator_id,
|
||||
)
|
||||
if fail_after_effect:
|
||||
raise RecoveryError("injected failure after correction effect")
|
||||
after = capture_report(db, capture_id, cutoff)
|
||||
protected_after = protected_snapshot(db, session_id, capture_id)
|
||||
if protected_before != protected_after:
|
||||
raise RecoveryError("recovery modified protected session or unrelated data")
|
||||
if after["ended_at"] != cutoff or after["bpm_remove"] != 0 or after["rr_remove"] != 0:
|
||||
raise RecoveryError("post-cutoff heart-rate data remain after correction")
|
||||
if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
||||
raise RecoveryError("foreign key check failed after correction")
|
||||
if apply:
|
||||
db.commit()
|
||||
else:
|
||||
db.rollback()
|
||||
return {
|
||||
"mode": "apply" if apply else "dry-run",
|
||||
"outcome": outcome,
|
||||
"session_id": session_id,
|
||||
"cutoff": cutoff,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"protected_sha256": _digest(protected_before),
|
||||
"protected_unchanged": True,
|
||||
}, emitted
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--database", required=True, type=Path)
|
||||
parser.add_argument("--session-id", required=True)
|
||||
parser.add_argument("--capture-id", required=True)
|
||||
parser.add_argument("--cutoff", required=True)
|
||||
parser.add_argument("--creator-id", default="peer_android_recovery")
|
||||
parser.add_argument("--operation-input", type=Path)
|
||||
parser.add_argument("--operation-output", type=Path)
|
||||
parser.add_argument("--report-output", type=Path)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
operation = load_operation(args.operation_input) if args.operation_input else None
|
||||
db = connect_database(args.database)
|
||||
try:
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
report, emitted = recover(
|
||||
db, args.session_id, args.capture_id, args.cutoff, args.creator_id,
|
||||
args.apply, operation,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
if args.operation_output and emitted is not None:
|
||||
args.operation_output.write_text(
|
||||
json.dumps(emitted, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8",
|
||||
)
|
||||
rendered = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
if args.report_output:
|
||||
args.report_output.write_text(rendered + "\n", encoding="utf-8")
|
||||
print(rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"HEART_RATE_RECOVERY=FAIL {error}")
|
||||
raise SystemExit(1)
|
||||
444
tools/recover_sleep_revision_collision.py
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Recover one unpublished local Sleep branch from an immutable peer collision."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import sleep_diary_exchange as exchange
|
||||
|
||||
|
||||
REVISION_ID = re.compile(r"slr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
||||
|
||||
|
||||
class RecoveryError(ValueError):
|
||||
"""A collision cannot be recovered without changing established facts."""
|
||||
|
||||
|
||||
def _new_revision_id() -> str:
|
||||
return f"slr_{uuid.uuid4()}"
|
||||
|
||||
|
||||
def _canonical_digest(value: object) -> str:
|
||||
payload = json.dumps(value, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _incoming_entry(document: dict, collision_id: str) -> dict:
|
||||
matches = [item for item in document["entries"] if item["revision_id"] == collision_id]
|
||||
if len(matches) != 1:
|
||||
raise RecoveryError("collision revision is not unique in incoming artifact")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _merge_scalar(local: object, remote: object, label: str) -> object:
|
||||
if local == remote:
|
||||
return local
|
||||
if local is None:
|
||||
return remote
|
||||
if remote is None:
|
||||
return local
|
||||
raise RecoveryError(f"human decision required for {label}")
|
||||
|
||||
|
||||
def _merge_identity_list(local: list[dict], remote: list[dict], identity: str,
|
||||
label: str) -> list[dict]:
|
||||
merged = {value[identity]: value for value in local}
|
||||
for value in remote:
|
||||
existing = merged.get(value[identity])
|
||||
if existing is not None and existing != value:
|
||||
raise RecoveryError(f"{label} identity reused with different content")
|
||||
merged[value[identity]] = value
|
||||
return [merged[key] for key in sorted(merged)]
|
||||
|
||||
|
||||
def _copy_local_revision(db: sqlite3.Connection, entry_id: str, source_id: str,
|
||||
target_id: str) -> None:
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_revisions "
|
||||
"SELECT ?,entry_id,parent_revision_id,created_at,sleep_quality,wake_quality,day_form,"
|
||||
"treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?",
|
||||
(target_id, source_id, entry_id),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_events "
|
||||
"SELECT ?,event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?",
|
||||
(target_id, source_id),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
|
||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
|
||||
"SELECT ?,intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
|
||||
"quantity,note,created_at FROM sleep_medication_intakes WHERE revision_id=?",
|
||||
(target_id, source_id),
|
||||
)
|
||||
|
||||
|
||||
def _insert_revision(db: sqlite3.Connection, item: dict, revision_id: str,
|
||||
parent_revision_id: str | None, created_at: str) -> None:
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||
(revision_id, item["entry_id"], parent_revision_id, created_at,
|
||||
item["sleep_quality"], item["wake_quality"], item["day_form"],
|
||||
item["treatment_and_notes"]),
|
||||
)
|
||||
db.executemany(
|
||||
"INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
|
||||
[(revision_id, value["event_id"], value["type"], value["start_at"], value["end_at"])
|
||||
for value in item["events"]],
|
||||
)
|
||||
db.executemany(
|
||||
"INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,"
|
||||
"medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
[(revision_id, value["intake_id"], value["medication_id"],
|
||||
value["medication_name"], value["taken_at"], value["dose_value"],
|
||||
value["dose_unit"], value["quantity"], value["note"], value["created_at"])
|
||||
for value in item["intakes"]],
|
||||
)
|
||||
|
||||
|
||||
def recover(db: sqlite3.Connection, document: dict, collision_id: str, recovered_at: str,
|
||||
id_factory: Callable[[], str] = _new_revision_id,
|
||||
fail_after_reidentification: bool = False,
|
||||
rejected_generation: tuple[dict, str] | None = None) -> dict:
|
||||
"""Preserve both branches and advance through one non-conflicting successor.
|
||||
|
||||
CONTRACT: only a local current revision that has never crossed an immutable
|
||||
publication boundary may be re-identified. The incoming revision retains its
|
||||
published identity and exact payload. A fresh successor descends from that
|
||||
published revision, while the re-identified local side branch remains durable.
|
||||
INVARIANT: every mutation commits in one IMMEDIATE transaction or none does.
|
||||
"""
|
||||
exchange.validate(document)
|
||||
if document["version"] != 2 or not REVISION_ID.fullmatch(collision_id):
|
||||
raise RecoveryError("Sleep Diary V2 collision identity required")
|
||||
incoming = _incoming_entry(document, collision_id)
|
||||
incoming_payload = exchange.incoming_revision_payload(incoming, 2)
|
||||
owner = db.execute(
|
||||
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?", (collision_id,)
|
||||
).fetchone()
|
||||
if owner is None or owner[0] != incoming["entry_id"]:
|
||||
raise RecoveryError("local collision revision is unavailable")
|
||||
entry_id = owner[0]
|
||||
current = db.execute(
|
||||
"SELECT night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted "
|
||||
"FROM sleep_diary_entries WHERE entry_id=?", (entry_id,)
|
||||
).fetchone()
|
||||
if current is None or current[4] != collision_id:
|
||||
raise RecoveryError("local collision revision is not the current tip")
|
||||
local_payload = exchange.persisted_revision_payload(db, entry_id, collision_id)
|
||||
if local_payload == incoming_payload:
|
||||
raise RecoveryError("revision is already an identical replay")
|
||||
if tuple(current[:3]) != (incoming["night_start_date"], incoming["night_end_date"],
|
||||
incoming["created_at"]) or bool(current[5]) != incoming["deleted"]:
|
||||
raise RecoveryError("entry envelope requires a human decision")
|
||||
if db.execute(
|
||||
"SELECT 1 FROM sleep_diary_revisions WHERE parent_revision_id=? LIMIT 1", (collision_id,)
|
||||
).fetchone() is not None:
|
||||
raise RecoveryError("local collision revision already has descendants")
|
||||
publication = db.execute(
|
||||
"SELECT validated_revision_id,acknowledged_revision_id FROM sleep_diary_publication_state "
|
||||
"WHERE entry_id=?", (entry_id,)
|
||||
).fetchone()
|
||||
if publication is not None and collision_id in publication:
|
||||
raise RecoveryError("local collision revision already crossed publication state")
|
||||
try:
|
||||
immutable_reference = db.execute(
|
||||
"SELECT 1 FROM sleep_diary_generation_entries WHERE entry_id=? AND revision_id=? LIMIT 1",
|
||||
(entry_id, collision_id),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
immutable_reference = None
|
||||
if immutable_reference is not None:
|
||||
raise RecoveryError("local collision revision is referenced by an immutable generation")
|
||||
if incoming["parent_revision_id"] is not None and db.execute(
|
||||
"SELECT 1 FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?",
|
||||
(incoming["parent_revision_id"], entry_id),
|
||||
).fetchone() is None:
|
||||
raise RecoveryError("incoming causal parent is unavailable locally")
|
||||
|
||||
merged = dict(incoming)
|
||||
merged["sleep_quality"] = _merge_scalar(
|
||||
local_payload["sleep_quality"], incoming["sleep_quality"], "sleep_quality"
|
||||
)
|
||||
merged["wake_quality"] = _merge_scalar(
|
||||
local_payload["wake_quality"], incoming["wake_quality"], "wake_quality"
|
||||
)
|
||||
merged["day_form"] = _merge_scalar(
|
||||
local_payload["day_form"], incoming["day_form"], "day_form"
|
||||
)
|
||||
if local_payload["treatment_and_notes"] != incoming["treatment_and_notes"]:
|
||||
raise RecoveryError("human decision required for treatment_and_notes")
|
||||
merged["events"] = _merge_identity_list(
|
||||
local_payload["events"], incoming["events"], "event_id", "sleep event"
|
||||
)
|
||||
merged["intakes"] = _merge_identity_list(
|
||||
local_payload["intakes"], incoming["intakes"], "intake_id", "medication intake"
|
||||
)
|
||||
|
||||
local_revision_id = id_factory()
|
||||
reconciliation_revision_id = id_factory()
|
||||
if (not REVISION_ID.fullmatch(local_revision_id) or
|
||||
not REVISION_ID.fullmatch(reconciliation_revision_id) or
|
||||
len({collision_id, local_revision_id, reconciliation_revision_id}) != 3):
|
||||
raise RecoveryError("revision identity generator returned an invalid or duplicate id")
|
||||
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
_copy_local_revision(db, entry_id, collision_id, local_revision_id)
|
||||
changed = db.execute(
|
||||
"UPDATE sleep_diary_entries SET current_revision_id=? "
|
||||
"WHERE entry_id=? AND current_revision_id=?",
|
||||
(local_revision_id, entry_id, collision_id),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise RecoveryError("guarded local branch re-identification failed")
|
||||
if fail_after_reidentification:
|
||||
raise RecoveryError("injected recovery failure")
|
||||
db.execute("DELETE FROM sleep_diary_events WHERE revision_id=?", (collision_id,))
|
||||
db.execute("DELETE FROM sleep_medication_intakes WHERE revision_id=?", (collision_id,))
|
||||
db.execute("DELETE FROM sleep_diary_revisions WHERE revision_id=?", (collision_id,))
|
||||
_insert_revision(db, incoming, collision_id, incoming["parent_revision_id"],
|
||||
incoming["updated_at"])
|
||||
merged["updated_at"] = recovered_at
|
||||
_insert_revision(db, merged, reconciliation_revision_id, collision_id, recovered_at)
|
||||
changed = db.execute(
|
||||
"UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=? "
|
||||
"WHERE entry_id=? AND current_revision_id=?",
|
||||
(recovered_at, reconciliation_revision_id, entry_id, local_revision_id),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise RecoveryError("guarded reconciliation advance failed")
|
||||
rejection_rearmed = False
|
||||
if rejected_generation is not None:
|
||||
rejection_rearmed = _rearm_desktop_rejection(
|
||||
db, rejected_generation[0], rejected_generation[1]
|
||||
)
|
||||
if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
||||
raise RecoveryError("foreign key check failed during recovery")
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
merged_payload = dict(merged)
|
||||
merged_payload["revision_id"] = reconciliation_revision_id
|
||||
merged_payload["parent_revision_id"] = collision_id
|
||||
return {
|
||||
"entry_id": entry_id,
|
||||
"collision_revision_id": collision_id,
|
||||
"local_reidentified_revision_id": local_revision_id,
|
||||
"reconciliation_revision_id": reconciliation_revision_id,
|
||||
"reconciliation_parent_revision_id": collision_id,
|
||||
"local_payload_sha256": _canonical_digest(local_payload),
|
||||
"incoming_payload_sha256": _canonical_digest(incoming_payload),
|
||||
"merged_payload_sha256": _canonical_digest(
|
||||
exchange.incoming_revision_payload(merged_payload, 2)
|
||||
),
|
||||
"event_count": len(merged["events"]),
|
||||
"intake_count": len(merged["intakes"]),
|
||||
"desktop_rejection_rearmed": rejection_rearmed,
|
||||
}
|
||||
|
||||
|
||||
def _rearm_desktop_rejection(db: sqlite3.Connection, manifest: dict,
|
||||
manifest_digest: str) -> bool:
|
||||
generation_id = manifest["generation_id"]
|
||||
row = db.execute(
|
||||
"SELECT run_id,producer_peer_id,consumer_peer_id,parent_generation_id,manifest_sha256,"
|
||||
"result,durability,diagnostic FROM sync_consumed_generations WHERE generation_id=?",
|
||||
(generation_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
expected = (
|
||||
manifest["run_id"], manifest["producer"]["peer_id"], manifest["consumer_peer_id"],
|
||||
manifest["parent_generation_id"], manifest_digest, "rejected", "sqlite-commit-rejection",
|
||||
)
|
||||
if tuple(row[:7]) != expected:
|
||||
raise RecoveryError("generation rejection ledger does not match immutable manifest")
|
||||
# The deployed consumer could lose ValueError text while recording this
|
||||
# exact collision (StopIteration has an empty message). The collision has
|
||||
# already been proven directly against the manifest-owned Sleep artifact.
|
||||
if row[7] not in (
|
||||
"",
|
||||
"sleep diary revision identity reused with different content",
|
||||
"schema desktop v8 à v35 requis",
|
||||
):
|
||||
raise RecoveryError("generation rejection has an unrelated diagnostic")
|
||||
if db.execute(
|
||||
"SELECT 1 FROM sync_consumed_generations WHERE parent_generation_id=? LIMIT 1",
|
||||
(generation_id,),
|
||||
).fetchone() is not None:
|
||||
raise RecoveryError("rejected generation already has a durable descendant")
|
||||
ack = db.execute(
|
||||
"SELECT ack_id,result,durability,manifest_sha256 FROM sync_acknowledgements "
|
||||
"WHERE generation_id=?", (generation_id,),
|
||||
).fetchall()
|
||||
expected_ack_id = "ack_" + generation_id.removeprefix("gen_")
|
||||
if ack != [(expected_ack_id, "rejected", "sqlite-commit-rejection", manifest_digest)]:
|
||||
raise RecoveryError("generation rejection ACK does not match immutable manifest")
|
||||
db.execute("DELETE FROM sync_acknowledgements WHERE generation_id=?", (generation_id,))
|
||||
db.execute("DELETE FROM sync_consumed_generations WHERE generation_id=?", (generation_id,))
|
||||
return True
|
||||
|
||||
|
||||
def rearm_desktop_consumer(db: sqlite3.Connection, document: dict, manifest: dict,
|
||||
manifest_digest: str, collision_id: str) -> dict:
|
||||
"""Rearm an exact retry after recovery or a recovery-adjacent schema rejection.
|
||||
|
||||
CONTRACT: the published collision identity must already contain the exact
|
||||
manifest-owned payload. Only the matching rejected consumption and ACK are
|
||||
removed; immutable generation bytes and recovered Sleep history are unchanged.
|
||||
"""
|
||||
exchange.validate(document)
|
||||
incoming = _incoming_entry(document, collision_id)
|
||||
persisted = exchange.persisted_revision_payload(db, incoming["entry_id"], collision_id)
|
||||
if persisted != exchange.incoming_revision_payload(incoming, 2):
|
||||
raise RecoveryError("recovered collision payload no longer matches immutable artifact")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
rearmed = _rearm_desktop_rejection(db, manifest, manifest_digest)
|
||||
if not rearmed:
|
||||
raise RecoveryError("desktop has no matching rejected generation to rearm")
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return {
|
||||
"generation_id": manifest["generation_id"],
|
||||
"manifest_sha256": manifest_digest,
|
||||
"status": "ready_for_retry",
|
||||
"immutable_artifacts_rewritten": False,
|
||||
}
|
||||
|
||||
|
||||
def rearm_android_producer(db: sqlite3.Connection, manifest: dict, manifest_digest: str,
|
||||
collision_id: str) -> dict:
|
||||
"""Rearm one rejected immutable Android generation after causal recovery.
|
||||
|
||||
CONTRACT: manifest bytes and generation-to-Sleep mapping remain unchanged.
|
||||
Only the exact rejection ACK is removed and its producer state returns to
|
||||
waiting_acknowledgement so the normal retry can record a consumed ACK.
|
||||
"""
|
||||
generation_id = manifest["generation_id"]
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT run_id,producer_peer_id,consumer_peer_id,generated_at,parent_generation_id,"
|
||||
"manifest_sha256,status FROM sync_generations WHERE generation_id=?",
|
||||
(generation_id,),
|
||||
).fetchone()
|
||||
expected = (
|
||||
manifest["run_id"], manifest["producer"]["peer_id"], manifest["consumer_peer_id"],
|
||||
manifest["generated_at"], manifest["parent_generation_id"], manifest_digest, "rejected",
|
||||
)
|
||||
if row is None or tuple(row) != expected:
|
||||
raise RecoveryError("Android rejected generation does not match immutable manifest")
|
||||
mapping = db.execute(
|
||||
"SELECT revision_id FROM sleep_diary_generation_entries WHERE generation_id=? "
|
||||
"AND entry_id=(SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?)",
|
||||
(generation_id, collision_id),
|
||||
).fetchone()
|
||||
if mapping != (collision_id,):
|
||||
raise RecoveryError("Android generation does not own the collided Sleep revision")
|
||||
ack = db.execute(
|
||||
"SELECT ack_id,result,durability,manifest_sha256 FROM sync_acknowledgements "
|
||||
"WHERE generation_id=?", (generation_id,),
|
||||
).fetchall()
|
||||
expected_ack_id = "ack_" + generation_id.removeprefix("gen_")
|
||||
if ack != [(expected_ack_id, "rejected", "sqlite-commit-rejection", manifest_digest)]:
|
||||
raise RecoveryError("Android rejection ACK does not match immutable manifest")
|
||||
db.execute("DELETE FROM sync_acknowledgements WHERE generation_id=?", (generation_id,))
|
||||
changed = db.execute(
|
||||
"UPDATE sync_generations SET status='waiting_acknowledgement',acknowledged_at=NULL "
|
||||
"WHERE generation_id=? AND status='rejected'", (generation_id,),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise RecoveryError("Android generation rearm guard failed")
|
||||
if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
||||
raise RecoveryError("foreign key check failed during Android rearm")
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return {
|
||||
"generation_id": generation_id,
|
||||
"manifest_sha256": manifest_digest,
|
||||
"status": "waiting_acknowledgement",
|
||||
"immutable_artifacts_rewritten": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
desktop = subparsers.add_parser("recover-desktop")
|
||||
desktop.add_argument("--database", required=True, type=Path)
|
||||
desktop.add_argument("--generation-directory", required=True, type=Path)
|
||||
desktop.add_argument("--collision-id", required=True)
|
||||
desktop.add_argument("--recovered-at", default=None)
|
||||
desktop.add_argument("--report", type=Path)
|
||||
desktop_retry = subparsers.add_parser("rearm-desktop")
|
||||
desktop_retry.add_argument("--database", required=True, type=Path)
|
||||
desktop_retry.add_argument("--generation-directory", required=True, type=Path)
|
||||
desktop_retry.add_argument("--collision-id", required=True)
|
||||
desktop_retry.add_argument("--report", type=Path)
|
||||
android = subparsers.add_parser("rearm-android")
|
||||
android.add_argument("--database", required=True, type=Path)
|
||||
android.add_argument("--generation-directory", required=True, type=Path)
|
||||
android.add_argument("--collision-id", required=True)
|
||||
android.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
import sync_generation_exchange as generation
|
||||
|
||||
manifest_path = args.generation_directory / "manifest.json"
|
||||
preliminary = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest, manifest_digest = generation.validate_published(
|
||||
args.generation_directory, preliminary["consumer_peer_id"]
|
||||
)
|
||||
sleep_descriptor = next(
|
||||
(value for value in manifest["artifacts"] if value["logical_name"] == "sleep-diary"),
|
||||
None,
|
||||
)
|
||||
if sleep_descriptor is None:
|
||||
raise RecoveryError("generation has no Sleep Diary artifact")
|
||||
artifact = args.generation_directory / Path(sleep_descriptor["filename"]).name
|
||||
document = exchange.load(artifact)
|
||||
with exchange.connect_database(args.database) as db:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.command == "recover-desktop":
|
||||
if version != 35:
|
||||
raise RecoveryError("desktop schema v35 pre-immutability recovery required")
|
||||
recovered_at = args.recovered_at or datetime.now().astimezone().isoformat()
|
||||
report = recover(
|
||||
db, document, args.collision_id, recovered_at,
|
||||
rejected_generation=(manifest, manifest_digest),
|
||||
)
|
||||
elif args.command == "rearm-desktop":
|
||||
if version != 36:
|
||||
raise RecoveryError("desktop schema v36 post-recovery retry required")
|
||||
report = rearm_desktop_consumer(
|
||||
db, document, manifest, manifest_digest, args.collision_id
|
||||
)
|
||||
else:
|
||||
if version not in (32, 33):
|
||||
raise RecoveryError("Android schema v32 or v33 required")
|
||||
report = rearm_android_producer(
|
||||
db, manifest, manifest_digest, args.collision_id
|
||||
)
|
||||
encoded = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
if args.report is not None:
|
||||
args.report.write_text(encoded, encoding="utf-8")
|
||||
print(encoded, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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):
|
||||
fail("desktop schema v31-v35 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()
|
||||
|
|
|
|||
|
|
@ -217,8 +217,119 @@ def revision_ancestry(db: sqlite3.Connection, parent_sql: str, owner_id: str,
|
|||
return result
|
||||
|
||||
|
||||
def _persisted_entry(db: sqlite3.Connection, row: tuple) -> dict:
|
||||
"""Build one durable entry independently of its publication state."""
|
||||
entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted = row
|
||||
revision = db.execute(
|
||||
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
|
||||
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
|
||||
).fetchone()
|
||||
if revision is None:
|
||||
fail("sleep diary current revision is missing")
|
||||
ancestry = revision_ancestry(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_diary_revisions "
|
||||
"WHERE entry_id=? AND revision_id=?",
|
||||
entry_id,
|
||||
revision_id,
|
||||
REVISION_ID,
|
||||
)
|
||||
events = [
|
||||
{"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]}
|
||||
for value in db.execute(
|
||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
||||
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
intakes = [
|
||||
{"intake_id": value[0], "medication_id": value[1], "medication_name": value[2],
|
||||
"taken_at": value[3], "dose_value": value[4], "dose_unit": value[5],
|
||||
"quantity": value[6], "note": value[7], "created_at": value[8]}
|
||||
for value in db.execute(
|
||||
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
|
||||
"quantity,note,created_at FROM sleep_medication_intakes "
|
||||
"WHERE revision_id=? ORDER BY intake_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
return {
|
||||
"entry_id": entry_id, "night_start_date": night_start, "night_end_date": night_end,
|
||||
"created_at": created_at, "updated_at": updated_at, "revision_id": revision_id,
|
||||
"parent_revision_id": revision[0], "ancestry": ancestry, "deleted": bool(deleted),
|
||||
"sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
|
||||
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes,
|
||||
}
|
||||
|
||||
|
||||
def persisted_revision_payload(db: sqlite3.Connection, entry_id: str,
|
||||
revision_id: str) -> dict | None:
|
||||
"""Return the immutable content owned by one revision identity.
|
||||
|
||||
WHY: a replay can name an older revision while the mutable entry tip has
|
||||
advanced. Comparing only the current tip would let a reused historical
|
||||
identity hide behind its descendant. CONTRACT: parent, scalar observations,
|
||||
events, and medication occurrences are immutable for a revision_id.
|
||||
"""
|
||||
revision = db.execute(
|
||||
"SELECT parent_revision_id,created_at,sleep_quality,wake_quality,day_form,"
|
||||
"COALESCE(treatment_and_notes,'') FROM sleep_diary_revisions "
|
||||
"WHERE revision_id=? AND entry_id=?",
|
||||
(revision_id, entry_id),
|
||||
).fetchone()
|
||||
if revision is None:
|
||||
return None
|
||||
events = [
|
||||
{"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]}
|
||||
for value in db.execute(
|
||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
||||
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
intakes = [
|
||||
{"intake_id": value[0], "medication_id": value[1], "medication_name": value[2],
|
||||
"taken_at": value[3], "dose_value": value[4], "dose_unit": value[5],
|
||||
"quantity": value[6], "note": value[7], "created_at": value[8]}
|
||||
for value in db.execute(
|
||||
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
|
||||
"quantity,COALESCE(note,''),created_at FROM sleep_medication_intakes "
|
||||
"WHERE revision_id=? ORDER BY intake_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
return {
|
||||
"entry_id": entry_id,
|
||||
"revision_id": revision_id,
|
||||
"parent_revision_id": revision[0],
|
||||
"created_at": revision[1],
|
||||
"sleep_quality": revision[2],
|
||||
"wake_quality": revision[3],
|
||||
"day_form": revision[4],
|
||||
"treatment_and_notes": revision[5],
|
||||
"events": events,
|
||||
"intakes": intakes,
|
||||
}
|
||||
|
||||
|
||||
def incoming_revision_payload(item: dict, wire_version: int) -> dict:
|
||||
"""Normalize V1 quantity before immutable-identity comparison."""
|
||||
intakes = []
|
||||
for intake in item["intakes"]:
|
||||
normalized = dict(intake)
|
||||
normalized.setdefault("quantity", 1 if wire_version == 1 else None)
|
||||
intakes.append(normalized)
|
||||
return {
|
||||
"entry_id": item["entry_id"],
|
||||
"revision_id": item["revision_id"],
|
||||
"parent_revision_id": item["parent_revision_id"],
|
||||
"created_at": item["updated_at"],
|
||||
"sleep_quality": item["sleep_quality"],
|
||||
"wake_quality": item["wake_quality"],
|
||||
"day_form": item["day_form"],
|
||||
"treatment_and_notes": item["treatment_and_notes"],
|
||||
"events": sorted(item["events"], key=lambda value: value["event_id"]),
|
||||
"intakes": sorted(intakes, key=lambda value: value["intake_id"]),
|
||||
}
|
||||
|
||||
|
||||
def build(db: sqlite3.Connection) -> dict:
|
||||
entries = []
|
||||
# CONTRACT: local durability does not publish a draft. A generation may
|
||||
# capture only the current tip explicitly validated by the user.
|
||||
rows = db.execute(
|
||||
|
|
@ -229,37 +340,7 @@ def build(db: sqlite3.Connection) -> dict:
|
|||
).fetchall()
|
||||
if len(rows) > MAX_ENTRIES:
|
||||
fail("sleep diary entry bound exceeded")
|
||||
for entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted in rows:
|
||||
revision = db.execute(
|
||||
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
|
||||
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
|
||||
).fetchone()
|
||||
if revision is None:
|
||||
fail("sleep diary current revision is missing")
|
||||
ancestry = revision_ancestry(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_diary_revisions "
|
||||
"WHERE entry_id=? AND revision_id=?",
|
||||
entry_id,
|
||||
revision_id,
|
||||
REVISION_ID,
|
||||
)
|
||||
events = [
|
||||
{"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]}
|
||||
for row in db.execute(
|
||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
||||
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
intakes = [{"intake_id": row[0], "medication_id": row[1], "medication_name": row[2],
|
||||
"taken_at": row[3], "dose_value": row[4], "dose_unit": row[5],
|
||||
"quantity": row[6], "note": row[7], "created_at": row[8]}
|
||||
for row in db.execute("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", (revision_id,))]
|
||||
entries.append({"entry_id": entry_id, "night_start_date": night_start,
|
||||
"night_end_date": night_end, "created_at": created_at, "updated_at": updated_at,
|
||||
"revision_id": revision_id, "parent_revision_id": revision[0], "ancestry": ancestry,
|
||||
"deleted": bool(deleted), "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
|
||||
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes})
|
||||
entries = [_persisted_entry(db, row) for row in rows]
|
||||
medications = []
|
||||
for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"):
|
||||
ancestry = revision_ancestry(
|
||||
|
|
@ -328,7 +409,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|||
)
|
||||
if local is not None and not remote_descends_from_local and medication["parent_revision_id"] != local[0]:
|
||||
fail("concurrent medication revision")
|
||||
if local is None and medication["parent_revision_id"] is not None: fail("unknown medication parent")
|
||||
# CONTRACT: this artifact is a current-state companion, not a revision
|
||||
# log. A fresh peer may seed a non-root tip only when the validated
|
||||
# complete ancestry accompanies it; legacy payloads without ancestry
|
||||
# retain the strict unknown-parent rejection.
|
||||
if (local is None and medication["parent_revision_id"] is not None and
|
||||
"ancestry" not in medication):
|
||||
fail("unknown medication parent")
|
||||
if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection")
|
||||
if local is None:
|
||||
db.execute("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", (medication["medication_id"], medication["created_at"], medication["updated_at"], medication["revision_id"], int(medication["deleted"])))
|
||||
|
|
@ -340,9 +427,29 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|||
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||
(item["entry_id"],),
|
||||
).fetchone()
|
||||
known_owner = db.execute(
|
||||
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?",
|
||||
(item["revision_id"],),
|
||||
).fetchone()
|
||||
if known_owner is not None:
|
||||
persisted_revision = persisted_revision_payload(
|
||||
db, known_owner[0], item["revision_id"]
|
||||
)
|
||||
incoming_revision = incoming_revision_payload(item, wire_version)
|
||||
if known_owner[0] != item["entry_id"] or persisted_revision != incoming_revision:
|
||||
fail("sleep diary revision identity reused with different content")
|
||||
if local is not None and local[0] == item["revision_id"]:
|
||||
persisted = next(
|
||||
value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"]
|
||||
# INVARIANT: an identical durable revision may still be a local
|
||||
# draft because publication state is intentionally separate. The
|
||||
# incoming validated snapshot must compare the durable content
|
||||
# directly before it acknowledges that revision.
|
||||
persisted = _persisted_entry(
|
||||
db,
|
||||
db.execute(
|
||||
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,"
|
||||
"current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||
(item["entry_id"],),
|
||||
).fetchone(),
|
||||
)
|
||||
persisted_payload = dict(persisted)
|
||||
incoming_payload = dict(item)
|
||||
|
|
@ -379,7 +486,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|||
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
|
||||
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
|
||||
fail("concurrent sleep diary revision")
|
||||
if local is None and item["parent_revision_id"] is not None:
|
||||
# WHY: one-tap Sleep creates several local revisions before its first
|
||||
# synchronization. The current snapshot carries their bounded lineage
|
||||
# by identity, so a new desktop must be able to seed the tip without
|
||||
# inventing historical payloads. INVARIANT: missing ancestry still
|
||||
# rejects a non-root first snapshot.
|
||||
if (local is None and item["parent_revision_id"] is not None and
|
||||
"ancestry" not in item):
|
||||
fail("sleep diary revision has an unknown parent")
|
||||
if local is not None and local[1] and not item["deleted"]:
|
||||
fail("sleep diary deletion cannot be resurrected")
|
||||
|
|
@ -429,8 +542,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):
|
||||
fail("desktop schema v30-v35 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")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import cardio_calibration_exchange
|
|||
import cardio_guidance_exchange
|
||||
import execution_draft_exchange
|
||||
import heart_rate_exchange
|
||||
import heart_rate_correction_exchange
|
||||
import session_timeline_exchange
|
||||
import import_equipment_associations
|
||||
import import_equipment_definitions
|
||||
|
|
@ -90,6 +91,7 @@ SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
|||
("trainlog-cardio-calibrations", 1),
|
||||
("trainlog-cardio-guidance", 1),
|
||||
("trainlog-heart-rate", 1),
|
||||
("trainlog-heart-rate-corrections", 1),
|
||||
("trainlog-session-timeline", 1),
|
||||
}
|
||||
|
||||
|
|
@ -219,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)
|
||||
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 v35 required")
|
||||
raise GenerationError("desktop schema v24 through v36 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -417,7 +419,29 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
"size": size, "sha256": checksum, "required": required})
|
||||
parent = None
|
||||
with closing(connect_database(database)) as db:
|
||||
parent_rows = db.execute("SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2", (producer, consumer)).fetchall()
|
||||
# WHY: archived generations retain immutable audit rows, including
|
||||
# forks, but have left the bounded active publication window.
|
||||
# CONTRACT: only unarchived acknowledged rows with an exact durable
|
||||
# consumed ACK participate in active parent selection; multiple
|
||||
# active tips remain a hard failure.
|
||||
# INVARIANT: archival evidence is never rewritten or deleted.
|
||||
parent_rows = db.execute(
|
||||
"SELECT g.generation_id FROM sync_generations g WHERE "
|
||||
"g.producer_peer_id=? AND g.consumer_peer_id=? AND "
|
||||
"g.status='acknowledged' AND EXISTS(SELECT 1 FROM "
|
||||
"sync_acknowledgements k WHERE k.generation_id=g.generation_id "
|
||||
"AND k.result='consumed' AND k.durability='sqlite-commit-full') "
|
||||
"AND NOT EXISTS(SELECT 1 FROM "
|
||||
"sync_generation_archives a WHERE a.generation_id=g.generation_id) "
|
||||
"AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE "
|
||||
"c.parent_generation_id=g.generation_id AND c.status='acknowledged' "
|
||||
"AND EXISTS(SELECT 1 FROM sync_acknowledgements ck WHERE "
|
||||
"ck.generation_id=c.generation_id AND ck.result='consumed' AND "
|
||||
"ck.durability='sqlite-commit-full') "
|
||||
"AND NOT EXISTS(SELECT 1 FROM sync_generation_archives ca WHERE "
|
||||
"ca.generation_id=c.generation_id)) LIMIT 2",
|
||||
(producer, consumer),
|
||||
).fetchall()
|
||||
if len(parent_rows) > 1: raise GenerationError("outgoing generation lineage has multiple tips")
|
||||
parent = parent_rows[0][0] if parent_rows else None
|
||||
manifest = {"format": MANIFEST, "version": 1, "generation_id": generation,
|
||||
|
|
@ -630,6 +654,36 @@ def record_consumed(db: sqlite3.Connection, manifest: dict, manifest_digest: str
|
|||
return ack
|
||||
|
||||
|
||||
def require_current_consumed_parent(
|
||||
db: sqlite3.Connection, producer_peer_id: str, declared_parent: str | None
|
||||
) -> None:
|
||||
"""Require the producer to extend one locally observed consumed tip."""
|
||||
# WHY: a producer can archive one branch after a transient fork, while the
|
||||
# consumer retains both immutable consumed rows and cannot observe that
|
||||
# producer-local archive decision.
|
||||
# CONTRACT: a non-empty lineage must extend one of its current consumed
|
||||
# tips; an empty lineage requires a null parent. An old or unrelated parent
|
||||
# remains a hard failure.
|
||||
# INVARIANT: this check never rewrites or discards either fork's audit row.
|
||||
tip_predicate = (
|
||||
"g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 "
|
||||
"FROM sync_consumed_generations c WHERE c.parent_generation_id="
|
||||
"g.generation_id AND c.result='consumed')"
|
||||
)
|
||||
has_tip = db.execute(
|
||||
"SELECT 1 FROM sync_consumed_generations g WHERE " + tip_predicate + " LIMIT 1",
|
||||
(producer_peer_id,),
|
||||
).fetchone() is not None
|
||||
parent_is_tip = declared_parent is not None and db.execute(
|
||||
"SELECT 1 FROM sync_consumed_generations g WHERE g.generation_id=? AND "
|
||||
+ tip_predicate
|
||||
+ " LIMIT 1",
|
||||
(declared_parent, producer_peer_id),
|
||||
).fetchone() is not None
|
||||
if (not has_tip and declared_parent is not None) or (has_tip and not parent_is_tip):
|
||||
raise GenerationError("generation lineage is stale or unrelated")
|
||||
|
||||
|
||||
def record_rejected(database: Path, manifest: dict, manifest_digest: str, diagnostic: str) -> dict:
|
||||
# Android's platform JSON quoter escapes solidus characters while the
|
||||
# desktop canonical encoder does not. Rejection diagnostics are bounded
|
||||
|
|
@ -685,6 +739,10 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
if "sleep-diary" in listed else None)
|
||||
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
|
||||
if "heart-rate" in listed else None)
|
||||
heart_rate_corrections = (
|
||||
heart_rate_correction_exchange.load(listed["heart-rate-corrections"])
|
||||
if "heart-rate-corrections" in listed else None
|
||||
)
|
||||
cardio_calibrations = (
|
||||
cardio_calibration_exchange.load(listed["cardio-calibrations"])
|
||||
if "cardio-calibrations" in listed else None
|
||||
|
|
@ -712,15 +770,17 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
raise GenerationError("generation identity reused with different manifest")
|
||||
db.rollback()
|
||||
return json.loads(known[1])
|
||||
predecessors = db.execute("SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2", (manifest["producer"]["peer_id"],)).fetchall()
|
||||
if len(predecessors) > 1: raise GenerationError("consumed generation lineage has multiple tips")
|
||||
expected_parent = predecessors[0][0] if predecessors else None
|
||||
if manifest["parent_generation_id"] != expected_parent:
|
||||
raise GenerationError("generation lineage is stale or unrelated")
|
||||
require_current_consumed_parent(
|
||||
db,
|
||||
manifest["producer"]["peer_id"],
|
||||
manifest["parent_generation_id"],
|
||||
)
|
||||
# Causal state is applied first. Complete-envelope helpers retain
|
||||
# item-level conflict rules while avoiding the legacy global guard.
|
||||
for operation in causal["operations"]:
|
||||
causal_delete_exchange.apply_operation(db, operation)
|
||||
if heart_rate_corrections is not None:
|
||||
heart_rate_correction_exchange.apply_document(db, heart_rate_corrections)
|
||||
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
|
||||
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True)
|
||||
import_mobile_export.apply_payload(db, history, complete_causal_envelope=True)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ MAX_DIAGNOSTIC = 1024
|
|||
PHASES = {
|
||||
"requested",
|
||||
"waiting_android_publication",
|
||||
"android_archive_ack_observed",
|
||||
"running",
|
||||
"local_import_committed",
|
||||
"published",
|
||||
|
|
@ -297,6 +298,7 @@ def read_worker(
|
|||
deadline = time.monotonic() + timeout
|
||||
allowed = [
|
||||
"waiting_android_publication",
|
||||
"android_archive_ack_observed",
|
||||
"running",
|
||||
"local_import_committed",
|
||||
"published",
|
||||
|
|
@ -338,6 +340,7 @@ def read_worker(
|
|||
"inbound_generation_id",
|
||||
"outbound_generation_id",
|
||||
"manifest_sha256",
|
||||
"recovered_acknowledgements",
|
||||
}
|
||||
or value.get("version") != 1
|
||||
or value.get("run_id") != run_id
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 35
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
27
tui/include/trainlog/web_equipment.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* Trainlog loopback Web equipment projection and transactional merge. */
|
||||
#ifndef TRAINLOG_WEB_EQUIPMENT_H
|
||||
#define TRAINLOG_WEB_EQUIPMENT_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
/* CONTRACT: returned JSON is heap-owned by the caller and must be freed. */
|
||||
TrainlogStatus
|
||||
trainlog_web_equipment_list_json(TrainlogDatabase *database, char **output, size_t *output_size);
|
||||
|
||||
/* WHY: duplicate physical-machine identities split historical context and MAX
|
||||
* lookup. CONTRACT: only a custom duplicate may be absorbed; every relational
|
||||
* reference is repointed in one IMMEDIATE transaction before the definition is
|
||||
* deleted. INVARIANT: performed facts, timestamps, provenance and values are
|
||||
* never rewritten, a matching causal marker makes exact replay idempotent, and
|
||||
* embedded draft JSON causes a conflict rather than a partial or textual
|
||||
* rewrite. */
|
||||
TrainlogStatus trainlog_web_equipment_merge_json(TrainlogDatabase *database,
|
||||
const char *input,
|
||||
size_t input_size,
|
||||
char **output,
|
||||
size_t *output_size);
|
||||
|
||||
#endif
|
||||
|
|
@ -48,6 +48,8 @@ if web_assets_enabled
|
|||
'../web/src/api/health.ts',
|
||||
'../web/src/api/dashboard.ts',
|
||||
'../web/src/api/analysis.ts',
|
||||
'../web/src/api/heartRate.ts',
|
||||
'../web/src/api/heartRate.test.ts',
|
||||
'../web/src/api/sleepDiary.ts',
|
||||
'../web/src/api/analysis.test.ts',
|
||||
'../web/src/analysis/messages.ts',
|
||||
|
|
@ -64,6 +66,10 @@ if web_assets_enabled
|
|||
'../web/src/components/DeleteConfirmationDialog.tsx',
|
||||
'../web/src/components/Footer.tsx',
|
||||
'../web/src/components/Header.tsx',
|
||||
'../web/src/components/HeartRateTimelinePanel.tsx',
|
||||
'../web/src/components/HeartRateTimelinePanel.test.tsx',
|
||||
'../web/src/components/heartRateVariation.ts',
|
||||
'../web/src/components/heartRateVariation.test.ts',
|
||||
'../web/src/components/SyncControl.tsx',
|
||||
'../web/src/components/SyncControl.test.tsx',
|
||||
'../web/src/components/Tile.tsx',
|
||||
|
|
@ -75,10 +81,15 @@ if web_assets_enabled
|
|||
'../web/src/dashboard/dashboardFormat.ts',
|
||||
'../web/src/dashboard/dashboardLayout.ts',
|
||||
'../web/src/presentation/DatePreferences.tsx',
|
||||
'../web/src/presentation/LanguagePreferences.tsx',
|
||||
'../web/src/presentation/dateFormat.ts',
|
||||
'../web/src/routes/DashboardPage.tsx',
|
||||
'../web/src/routes/AnalysisPage.tsx',
|
||||
'../web/src/routes/SleepDiaryWorkspace.tsx',
|
||||
'../web/src/routes/sleepTimelineGeometry.ts',
|
||||
'../web/src/routes/sleepTimelineGeometry.test.ts',
|
||||
'../web/src/routes/sleepVisualProjection.ts',
|
||||
'../web/src/routes/sleepVisualProjection.test.ts',
|
||||
'../web/src/report/sleepDiaryPdf.ts',
|
||||
'../web/src/report/sleepDiaryPdf.test.ts',
|
||||
'../web/src/routes/AnalysisPage.test.tsx',
|
||||
|
|
@ -86,11 +97,15 @@ if web_assets_enabled
|
|||
'../web/src/routes/SessionsPage.tsx',
|
||||
'../web/src/api/sessions.ts',
|
||||
'../web/src/api/exercises.ts',
|
||||
'../web/src/api/equipment.ts',
|
||||
'../web/src/api/equipment.test.ts',
|
||||
'../web/src/api/exercises.test.ts',
|
||||
'../web/src/api/programs.ts',
|
||||
'../web/src/routes/ProgramsTab.tsx',
|
||||
'../web/src/routes/ProgramsCalendarPage.tsx',
|
||||
'../web/src/routes/ExercisesPage.tsx',
|
||||
'../web/src/routes/EquipmentPage.tsx',
|
||||
'../web/src/routes/SettingsPage.tsx',
|
||||
'../web/src/routes/ExercisesPage.test.tsx',
|
||||
'../web/src/theme/app.css',
|
||||
'../web/src/theme/tokens.css',
|
||||
|
|
@ -226,6 +241,7 @@ trainlog_core_sources = files(
|
|||
'src/web_programs.c',
|
||||
'src/web_sleep.c',
|
||||
'src/web_exercises.c',
|
||||
'src/web_equipment.c',
|
||||
'src/duration.c',
|
||||
'src/id.c',
|
||||
'src/timeutil.c',
|
||||
|
|
@ -873,6 +889,14 @@ test_schema_v34_migration = executable(
|
|||
)
|
||||
test('schema_v34_migration', test_schema_v34_migration)
|
||||
|
||||
test_schema_v35_migration = executable(
|
||||
'test_schema_v35_migration',
|
||||
'tests/test_schema_v35_migration.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('schema_v35_migration', test_schema_v35_migration)
|
||||
|
||||
test_schema_v7_migration = executable(
|
||||
'test_schema_v7_migration',
|
||||
'tests/test_schema_v7_migration.c',
|
||||
|
|
@ -952,11 +976,21 @@ test(
|
|||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
|
||||
)
|
||||
test(
|
||||
'sleep_revision_collision_recovery',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_sleep_revision_collision_recovery.py'],
|
||||
)
|
||||
test(
|
||||
'heart_rate_exchange',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'],
|
||||
)
|
||||
test(
|
||||
'heart_rate_tail_recovery',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_heart_rate_tail_recovery.py'],
|
||||
)
|
||||
test(
|
||||
'session_timeline_exchange',
|
||||
find_program('python3'),
|
||||
|
|
|
|||
|
|
@ -21,13 +21,16 @@ typedef struct TileContract {
|
|||
} TileContract;
|
||||
|
||||
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
||||
{"next-session", 4U, 8U, 3U, 7U},
|
||||
{"activity", 5U, 12U, 3U, 7U},
|
||||
{"progression", 6U, 12U, 4U, 9U},
|
||||
{"last-session", 3U, 7U, 3U, 6U},
|
||||
{"max-records", 3U, 8U, 3U, 7U},
|
||||
{"muscle-distribution", 5U, 12U, 4U, 9U},
|
||||
{"cardio-recovery", 5U, 12U, 4U, 8U}};
|
||||
/* CONTRACT: every tile can reach full width and multiple useful vertical
|
||||
* sizes. Per-tile minima preserve legibility; the shared 12-column grid
|
||||
* and overlap validation remain the bounded layout authority. */
|
||||
{"next-session", 4U, 12U, 3U, 10U},
|
||||
{"activity", 5U, 12U, 3U, 12U},
|
||||
{"progression", 6U, 12U, 4U, 12U},
|
||||
{"last-session", 3U, 12U, 3U, 10U},
|
||||
{"max-records", 3U, 12U, 3U, 10U},
|
||||
{"muscle-distribution", 5U, 12U, 4U, 12U},
|
||||
{"cardio-recovery", 5U, 12U, 4U, 12U}};
|
||||
|
||||
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
|
||||
{"next-session", 0U, 0U, 5U, 4U},
|
||||
|
|
|
|||
|
|
@ -1549,6 +1549,35 @@ static TrainlogStatus ensure_v35_medication_quantity(TrainlogDatabase *database)
|
|||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
/* WHY: revision identity is the causal content address used by synchronized
|
||||
* peers; changing a persisted row in place can make two peers assign different
|
||||
* facts to the same UUID. CONTRACT: v36 makes every Sleep revision payload
|
||||
* append-only. Tips and publication markers remain mutable through their owning
|
||||
* tables. INVARIANT: correction always appends a fresh revision identity. */
|
||||
static const char *const MIGRATE_V35_TO_V36_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_update BEFORE UPDATE ON "
|
||||
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_delete BEFORE DELETE ON "
|
||||
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_update BEFORE UPDATE ON "
|
||||
"sleep_diary_events "
|
||||
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_delete BEFORE DELETE ON "
|
||||
"sleep_diary_events "
|
||||
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_update BEFORE UPDATE ON "
|
||||
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_delete BEFORE DELETE ON "
|
||||
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_update BEFORE UPDATE ON "
|
||||
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
|
||||
"immutable');END;"
|
||||
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_delete BEFORE DELETE ON "
|
||||
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
|
||||
"immutable');END;"
|
||||
"PRAGMA user_version=36;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
|
||||
|
|
@ -2383,7 +2412,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
||||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
||||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35) {
|
||||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
||||
version == 36) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2552,6 +2582,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 35) {
|
||||
status = ensure_v35_medication_quantity(database);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
||||
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -2571,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 v35" : "migrate database to schema v35",
|
||||
version == 0 ? "create schema v36" : "migrate database to schema v36",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||