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 ->
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue