fix(sync): recover sleep sync and canonical web mutations

This commit is contained in:
fy59 2026-09-24 05:05:38 +02:00
parent e7fbd6ff86
commit d7ca6a1924
9 changed files with 189 additions and 12 deletions

View file

@ -294,7 +294,20 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
repository.inSyncGenerationTransaction { db -> repository.inSyncGenerationTransaction { db ->
val retained = val retained =
db.rawQuery( 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. Current acknowledgements always have durable
* ledger evidence and remain counted until archived.
* INVARIANT: captured, published and unacknowledged rows
* are always retained and never pruned for capacity. */
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " +
"(status IN('captured','published','waiting_acknowledgement') 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), arrayOf(consumerPeerId),
) )
.use { .use {

View file

@ -872,6 +872,13 @@ class SyncGenerationServiceTest {
events = emptyList(), events = emptyList(),
), ),
) as TrainlogRepository.SaveSleepDiaryResult.Saved ) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
sleepEntry.entryId,
sleepEntry.revisionId,
"2026-09-23T07:00:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val heartCapture = val heartCapture =
source.startHeartRateCapture( source.startHeartRateCapture(
HeartRateContextKind.SLEEP, HeartRateContextKind.SLEEP,
@ -932,6 +939,10 @@ class SyncGenerationServiceTest {
"acknowledged", "acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()), sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
) )
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals( assertEquals(
0, 0,
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(), JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
@ -958,6 +969,69 @@ class SyncGenerationServiceTest {
.trim(), .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( run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath, java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath, desktopDb.absolutePath,

View file

@ -126,7 +126,10 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
char revision[TRAINLOG_ID_MAX + 1U], char revision[TRAINLOG_ID_MAX + 1U],
bool *active_draft) { bool *active_draft) {
static const char DRAFT_SQL[] = static const char DRAFT_SQL[] =
"SELECT revision_id,state FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS(" "SELECT revision_id,CASE WHEN state='active' AND "
"COALESCE(json_array_length(payload_json,'$.exercises'),"
"json_array_length(payload_json,'$.entries'),0)>0 THEN 'active' ELSE '' END "
"FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS("
"SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 " "SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 "
"AND deleted=1)"; "AND deleted=1)";
static const char HISTORY_SQL[] = static const char HISTORY_SQL[] =
@ -156,6 +159,11 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
(void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current); (void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current);
/* WHY: an imported zero-occurrence active row is an orphaned projection,
* not a resumable Android workout. CONTRACT: Web may causally delete that
* exact canonical row, while a genuinely active draft with occurrences
* remains protected from concurrent deletion. INVARIANT: the tombstone is
* stored by the existing transaction and prevents replay resurrection. */
*active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0; *active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0;
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR; : TRAINLOG_STATUS_DATABASE_ERROR;

View file

@ -46,7 +46,8 @@ static const ListDefinition LISTS[] = {
{"draft", {"draft",
"SELECT " "SELECT "
"d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d." "d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d."
"payload_json,'$.exercises'),0),COALESCE(d.started_at,''),NULL,NULL " "payload_json,'$.exercises'),json_array_length(d.payload_json,'$.entries'),0),"
"COALESCE(d.started_at,''),NULL,NULL "
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " "FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
"f.session_id=d.session_id) " "f.session_id=d.session_id) "
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND " "AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND "

View file

@ -158,7 +158,8 @@ static bool active_draft_conflicts_without_mutation(void) {
CHECK(sqlite3_exec(database->connection, CHECK(sqlite3_exec(database->connection,
"INSERT INTO execution_drafts VALUES(" "INSERT INTO execution_drafts VALUES("
"'se_55555555-5555-4555-8555-555555555555','training',NULL," "'se_55555555-5555-4555-8555-555555555555','training',NULL,"
"'2026-09-18T10:00:00Z','mu_active',NULL,'active','{}');", "'2026-09-18T10:00:00Z','mu_active',NULL,'active',"
"'{\"exercises\":[{\"entry_id\":\"entry_live\"}]}');",
NULL, NULL,
NULL, NULL,
NULL) == SQLITE_OK); NULL) == SQLITE_OK);
@ -177,6 +178,53 @@ static bool active_draft_conflicts_without_mutation(void) {
return true; return true;
} }
static bool empty_active_draft_is_causally_deleted_and_replay_safe(void) {
char path[] = "/tmp/trainlog-web-delete-empty-active-XXXXXX";
TrainlogDatabase *database = NULL;
char *response = NULL;
char *replayed = NULL;
size_t response_size = 0U;
size_t replayed_size = 0U;
int descriptor = mkstemp(path);
CHECK(descriptor >= 0);
CHECK(close(descriptor) == 0);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO execution_drafts VALUES("
"'se_56565656-5656-4565-8565-565656565656','training',NULL,"
"'2026-09-20T10:00:00Z','mu_empty',NULL,'active',"
"'{\"exercises\":[]}');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_session_delete_json(database,
"draft",
"se_56565656-5656-4565-8565-565656565656",
"mu_empty",
"request-delete-empty",
&response,
&response_size) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_session_delete_json(database,
"draft",
"se_56565656-5656-4565-8565-565656565656",
"mu_empty",
"request-delete-empty",
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(response_size == replayed_size && memcmp(response, replayed, response_size) == 0);
CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 0);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sync_causal_state WHERE target_kind='execution_draft' "
"AND target_id='se_56565656-5656-4565-8565-565656565656' AND deleted=1") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 1);
free(replayed);
free(response);
trainlog_database_close(database);
CHECK(unlink(path) == 0);
return true;
}
static bool completed_program_execution_deletion_is_atomic_and_idempotent(void) { static bool completed_program_execution_deletion_is_atomic_and_idempotent(void) {
char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX"; char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX";
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
@ -276,6 +324,7 @@ int main(void) {
return proposal_deletion_is_durable_and_preserves_derivatives() && return proposal_deletion_is_durable_and_preserves_derivatives() &&
draft_and_history_use_causal_deletion() && draft_and_history_use_causal_deletion() &&
active_draft_conflicts_without_mutation() && active_draft_conflicts_without_mutation() &&
empty_active_draft_is_causally_deleted_and_replay_safe() &&
completed_program_execution_deletion_is_atomic_and_idempotent() completed_program_execution_deletion_is_atomic_and_idempotent()
? EXIT_SUCCESS ? EXIT_SUCCESS
: EXIT_FAILURE; : EXIT_FAILURE;

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { parseEquipment } from './equipment' import { mergeEquipment, parseEquipment } from './equipment'
describe('equipment API', () => { describe('equipment API', () => {
it('accepts bounded factual merge consequences', () => { it('accepts bounded factual merge consequences', () => {
@ -19,4 +19,33 @@ describe('equipment API', () => {
historical_occurrences: 0, preparation_references: 0, program_references: 0, historical_occurrences: 0, preparation_references: 0, program_references: 0,
}] })).toThrow('equipment_invalid') }] })).toThrow('equipment_invalid')
}) })
it('merges on an insecure origin without crypto.randomUUID', async () => {
vi.stubGlobal('crypto', {
getRandomValues: (bytes: Uint8Array) => {
bytes.fill(0x11)
return bytes
},
randomUUID: undefined,
})
const token = 'a'.repeat(64)
const response = (body: unknown, headers: Record<string, string> = {}) => ({
ok: true,
headers: new Headers(headers),
json: async () => body,
})
const fetch = vi.fn()
.mockResolvedValueOnce(response({ phase: 'idle', result: 'ready' }, {
'X-Trainlog-CSRF-Token': token,
}))
.mockResolvedValueOnce(response({ api_version: 1, items: [] }))
vi.stubGlobal('fetch', fetch)
await expect(mergeEquipment('canonical', 'duplicate')).resolves.toEqual({
api_version: 1,
items: [],
})
expect(fetch.mock.calls[1][1].headers['X-Trainlog-Request-ID'])
.toBe('11111111-1111-4111-9111-111111111111')
})
}) })

View file

@ -1,4 +1,4 @@
import { mutationCsrfToken } from './sync' import { mutationCsrfToken, newUuidV4 } from './sync'
export interface EquipmentItem { export interface EquipmentItem {
equipment_id: string equipment_id: string
@ -41,7 +41,7 @@ export async function mergeEquipment(canonicalId: string, duplicateId: string):
headers: { headers: {
Accept: 'application/json', 'Content-Type': 'application/json', Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': await mutationCsrfToken(), 'X-Trainlog-CSRF-Token': await mutationCsrfToken(),
'X-Trainlog-Request-ID': crypto.randomUUID(), 'X-Trainlog-Request-ID': newUuidV4(),
}, },
body: JSON.stringify({ canonical_id: canonicalId, duplicate_id: duplicateId }), body: JSON.stringify({ canonical_id: canonicalId, duplicate_id: duplicateId }),
}) })

View file

@ -1,4 +1,4 @@
import { mutationCsrfToken } from './sync' import { mutationCsrfToken, newUuidV4 } from './sync'
export type RecordingMode = 'sets' | 'continuous' export type RecordingMode = 'sets' | 'continuous'
export type TrackingMode = 'reps' | 'duration' export type TrackingMode = 'reps' | 'duration'
@ -113,7 +113,7 @@ export async function fetchBodyZones(signal?: AbortSignal): Promise<BodyZoneChoi
async function mutation(path: string, method: 'POST' | 'PUT' | 'DELETE', async function mutation(path: string, method: 'POST' | 'PUT' | 'DELETE',
input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> { input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> {
const csrf = await mutationCsrfToken() const csrf = await mutationCsrfToken()
const requestId = crypto.randomUUID() const requestId = newUuidV4()
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: 'application/json', 'Content-Type': 'application/json', Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf, 'X-Trainlog-CSRF-Token': csrf,

View file

@ -157,7 +157,7 @@ export async function startSync(requestId: string): Promise<SyncStatus> {
if (/^[0-9a-f]{64}$/.test(token)) csrfToken = token if (/^[0-9a-f]{64}$/.test(token)) csrfToken = token
return parse(value) return parse(value)
} }
export function newRequestId(): string { export function newUuidV4(): string {
/* /*
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure * WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
* context, so randomUUID() is legitimately unavailable there. * context, so randomUUID() is legitimately unavailable there.
@ -176,7 +176,10 @@ export function newRequestId(): string {
hexadecimal.slice(8, 10).join(''), hexadecimal.slice(8, 10).join(''),
hexadecimal.slice(10, 16).join(''), hexadecimal.slice(10, 16).join(''),
].join('-') ].join('-')
return `sy_${uuid}` return uuid
}
export function newRequestId(): string {
return `sy_${newUuidV4()}`
} }
export function syncIsActive(phase: SyncPhase): boolean { export function syncIsActive(phase: SyncPhase): boolean {
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase) return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)