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 ->
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. 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),
)
.use {

View file

@ -872,6 +872,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,
@ -932,6 +939,10 @@ class SyncGenerationServiceTest {
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
@ -958,6 +969,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,

View file

@ -126,7 +126,10 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
char revision[TRAINLOG_ID_MAX + 1U],
bool *active_draft) {
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 "
"AND deleted=1)";
static const char HISTORY_SQL[] =
@ -156,6 +159,11 @@ static TrainlogStatus load_current_revision(TrainlogDatabase *database,
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(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;
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;

View file

@ -46,7 +46,8 @@ static const ListDefinition LISTS[] = {
{"draft",
"SELECT "
"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 "
"f.session_id=d.session_id) "
"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,
"INSERT INTO execution_drafts VALUES("
"'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) == SQLITE_OK);
@ -177,6 +178,53 @@ static bool active_draft_conflicts_without_mutation(void) {
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) {
char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX";
TrainlogDatabase *database = NULL;
@ -276,6 +324,7 @@ int main(void) {
return proposal_deletion_is_durable_and_preserves_derivatives() &&
draft_and_history_use_causal_deletion() &&
active_draft_conflicts_without_mutation() &&
empty_active_draft_is_causally_deleted_and_replay_safe() &&
completed_program_execution_deletion_is_atomic_and_idempotent()
? EXIT_SUCCESS
: EXIT_FAILURE;

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parseEquipment } from './equipment'
import { describe, expect, it, vi } from 'vitest'
import { mergeEquipment, parseEquipment } from './equipment'
describe('equipment API', () => {
it('accepts bounded factual merge consequences', () => {
@ -19,4 +19,33 @@ describe('equipment API', () => {
historical_occurrences: 0, preparation_references: 0, program_references: 0,
}] })).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 {
equipment_id: string
@ -41,7 +41,7 @@ export async function mergeEquipment(canonicalId: string, duplicateId: string):
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'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 }),
})

View file

@ -1,4 +1,4 @@
import { mutationCsrfToken } from './sync'
import { mutationCsrfToken, newUuidV4 } from './sync'
export type RecordingMode = 'sets' | 'continuous'
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',
input?: ExerciseInput, revision?: string): Promise<ExerciseDetail | { state: 'retired' }> {
const csrf = await mutationCsrfToken()
const requestId = crypto.randomUUID()
const requestId = newUuidV4()
const headers: Record<string, string> = {
Accept: 'application/json', 'Content-Type': 'application/json',
'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
return parse(value)
}
export function newRequestId(): string {
export function newUuidV4(): string {
/*
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
* context, so randomUUID() is legitimately unavailable there.
@ -176,7 +176,10 @@ export function newRequestId(): string {
hexadecimal.slice(8, 10).join(''),
hexadecimal.slice(10, 16).join(''),
].join('-')
return `sy_${uuid}`
return uuid
}
export function newRequestId(): string {
return `sy_${newUuidV4()}`
}
export function syncIsActive(phase: SyncPhase): boolean {
return !['idle', 'completed', 'failed', 'interrupted', 'explicitly_degraded'].includes(phase)