fix(sync): recover sleep sync and canonical web mutations
This commit is contained in:
parent
e7fbd6ff86
commit
d7ca6a1924
9 changed files with 189 additions and 12 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue