Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,7 @@ class SyncManager(
val rawRoutineIds = syncRepository.getAllRoutineIds(mergeProfileId)
val rawCycleIds = syncRepository.getAllCycleIds(mergeProfileId)
val rawBadgeIds = syncRepository.getAllBadgeIds(mergeProfileId)
val rawPersonalRecordIds = syncRepository.getAllPersonalRecordIds(mergeProfileId)

// fix(pull 400): TemplateConverter mints cycle-derived routine IDs as
// "cycle_routine_<uuid>" which aren't valid UUIDs. The server's
Expand Down Expand Up @@ -1475,19 +1476,29 @@ class SyncManager(
list.takeLast(SyncConfig.MAX_PARITY_IDS)
}

val knownEntityIds = KnownEntityIds(
val knownPersonalRecordIds = capParity(
filterUuids(rawPersonalRecordIds, "personalRecordIds"),
"personalRecordIds",
).toMutableList()

fun currentKnownEntityIds(): KnownEntityIds = KnownEntityIds(
sessionIds = capParity(filteredSessionIds, "sessionIds"),
routineIds = capParity(filteredRoutineIds, "routineIds"),
cycleIds = capParity(filteredCycleIds, "cycleIds"),
badgeIds = capParity(filteredBadgeIds, "badgeIds"),
// PRs use timestamp LWW, so the portal must return newer states even
// for UUIDs already present locally (including remote tombstones).
personalRecordIds = emptyList(),
// Send known PR UUIDs so the portal can page with
// get_personal_records_excluding_ids and still return tombstones
// via get_personal_record_tombstones. Empty lists force the server
// to rely only on the cursor, which loops when many PRs share one
// microsecond timestamp.
personalRecordIds = capParity(knownPersonalRecordIds.toList(), "personalRecordIds"),
)

val entityIds = currentKnownEntityIds()
Logger.i("SyncManager") {
Comment thread
9thLevelSoftware marked this conversation as resolved.
"Parity sync: sending ${knownEntityIds.sessionIds.size} session IDs, " +
"${knownEntityIds.routineIds.size} routine IDs, ${knownEntityIds.cycleIds.size} cycle IDs"
"Parity sync: sending ${entityIds.sessionIds.size} session IDs, " +
"${entityIds.routineIds.size} routine IDs, ${entityIds.cycleIds.size} cycle IDs, " +
"${entityIds.personalRecordIds.size} personal record IDs"
}

var pagesProcessed = 0
Expand Down Expand Up @@ -1544,11 +1555,14 @@ class SyncManager(
)
}

val knownEntityIds = currentKnownEntityIds()

// Fetch next page
// DIAGNOSTIC: Log pull request parameters to trace sync issues
Logger.d("SyncManager") {
"PULL REQUEST: deviceId=$deviceId, profileId=$activeProfileId, " +
"knownSessions=${knownEntityIds.sessionIds.size}, knownRoutines=${knownEntityIds.routineIds.size}, " +
"knownPersonalRecords=${knownEntityIds.personalRecordIds.size}, " +
"cursor=$currentCursor"
}

Expand Down Expand Up @@ -1653,6 +1667,13 @@ class SyncManager(
return Result.failure(mergeResult.exceptionOrNull() ?: PortalApiException("Merge failed"))
}

for (record in pullResponse.personalRecords) {
val id = record.id
if (CANONICAL_UUID_REGEX.matches(id) && id !in knownPersonalRecordIds) {
knownPersonalRecordIds += id
}
}

// Update pagination state
finalSyncTime = pullResponse.syncTime

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ class PortalPullPaginationTest {
// ==================== Parity Sync: knownEntityIds ====================

@Test
fun pullSendsKnownEntityIdsButOmitsPersonalRecordsForLwwRefresh() = runTest {
fun pullSendsKnownPersonalRecordIdsForParityAndTombstones() = runTest {
authenticate()
val s1 = "11111111-1111-4111-a111-111111111111"
val s2 = "22222222-2222-4222-a222-222222222222"
Expand All @@ -454,9 +454,9 @@ class PortalPullPaginationTest {
assertEquals(emptyList<String>(), known.cycleIds)
assertEquals(listOf(b1, b2), known.badgeIds)
assertEquals(
emptyList<String>(),
listOf(pr1),
known.personalRecordIds,
"Known PR UUIDs must be omitted so the portal returns newer active rows and tombstones",
"Known PR UUIDs must be sent so the portal can page and return tombstones",
)
}

Expand Down Expand Up @@ -484,7 +484,7 @@ class PortalPullPaginationTest {
}

@Test
fun pullFiltersNonUuidBadgesAndOmitsPersonalRecordsBeforeSend() = runTest {
fun pullFiltersNonUuidBadgesAndPersonalRecordsBeforeSend() = runTest {
authenticate()
val realBadge = "77777777-7777-4777-a777-777777777777"
val realPr = "88888888-8888-4888-a888-888888888888"
Expand All @@ -501,7 +501,54 @@ class PortalPullPaginationTest {
known.badgeIds,
"Local numeric badge ids must be filtered before send; portal parity uses UUID row ids",
)
assertEquals(emptyList<String>(), known.personalRecordIds)
assertEquals(
listOf(realPr),
known.personalRecordIds,
"Local numeric PR ids must be filtered; canonical UUIDs must be sent",
)
}

@Test
fun pullIncludesPersonalRecordIdsFromEarlierPagesInTheSamePull() = runTest {
authenticate()
val localPr = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val page1Pr = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
fakeSyncRepo.personalRecordIds = listOf(localPr)
fakeApi.pushResult = Result.success(PortalSyncPushResponse(syncTime = "2026-03-02T12:00:00Z"))
fakeApi.pullResultsQueue = mutableListOf(
Result.success(
PortalSyncPullResponse(
syncTime = 1L,
hasMore = true,
nextCursor = "pr-page-2",
personalRecords = listOf(
PullPersonalRecordDto(
id = page1Pr,
userId = "user-123",
exerciseName = "Bench",
updatedAt = "2026-07-08T14:24:09.648091+00:00",
),
),
),
),
Result.success(
PortalSyncPullResponse(
syncTime = 2L,
hasMore = false,
personalRecords = emptyList(),
),
),
)

createManager().sync()

assertEquals(2, fakeApi.pullKnownEntityIdsHistory.size)
assertEquals(listOf(localPr), fakeApi.pullKnownEntityIdsHistory[0].personalRecordIds)
assertEquals(
listOf(localPr, page1Pr),
fakeApi.pullKnownEntityIdsHistory[1].personalRecordIds,
"IDs merged on page 1 must be sent on page 2 so a stuck cursor cannot replay them",
)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1477,7 +1477,7 @@ class SyncManagerTest {
// ===== Parity Sync Tests =====

@Test
fun pullSendsKnownEntityIdsButOmitsPersonalRecordsForLwwRefresh() = runTest {
fun pullSendsKnownEntityIdsIncludingPersonalRecords() = runTest {
setupAuthenticated()
// Set up fake repository with known entity IDs (simulating local database content).
// IDs must be canonical UUIDs — SyncManager filters non-UUIDs before send to
Expand Down Expand Up @@ -1511,9 +1511,9 @@ class SyncManagerTest {
assertEquals(emptyList<String>(), knownIds.cycleIds)
assertEquals(listOf(badge1, badge2, badge3), knownIds.badgeIds)
assertEquals(
emptyList<String>(),
listOf(pr1, pr2),
knownIds.personalRecordIds,
"Known PR UUIDs must be omitted so the portal returns newer active rows and tombstones",
"Known PR UUIDs must be sent so the portal can page and return tombstones",
)
}

Expand All @@ -1539,7 +1539,7 @@ class SyncManagerTest {
knownIds.badgeIds,
"Local numeric badge ids must be filtered before send; portal parity uses UUID row ids",
)
assertEquals(emptyList<String>(), knownIds.personalRecordIds)
assertEquals(listOf(personalRecordId), knownIds.personalRecordIds)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ open class FakePortalApiClient :
val pullCallCursors: MutableList<String?> = mutableListOf()
val pullCallProfileIds: MutableList<String?> = mutableListOf()
val pullCallTimestampsMs: MutableList<Long> = mutableListOf()
val pullKnownEntityIdsHistory: MutableList<KnownEntityIds> = mutableListOf()
var pullTimestampSourceMs: () -> Long = { currentTimeMillis() }
var lastIntegrationSyncRequest: IntegrationSyncRequest? = null
var lastPlaygroundSimulationRequest: IntegrationPlaygroundSimulationRequest? = null
Expand Down Expand Up @@ -114,6 +115,7 @@ open class FakePortalApiClient :
): Result<PortalSyncPullResponse> {
pullCallCount++
lastPullKnownEntityIds = knownEntityIds
pullKnownEntityIdsHistory += knownEntityIds
lastPullDeviceId = deviceId
lastPullProfileId = profileId
pullCallProfileIds += profileId
Expand Down
Loading