diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt index 7e76bcb77..e80330ff2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt @@ -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_" which aren't valid UUIDs. The server's @@ -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") { - "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 @@ -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" } @@ -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 diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt index 46f8b0a1b..bf50488ee 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt @@ -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" @@ -454,9 +454,9 @@ class PortalPullPaginationTest { assertEquals(emptyList(), known.cycleIds) assertEquals(listOf(b1, b2), known.badgeIds) assertEquals( - emptyList(), + 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", ) } @@ -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" @@ -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(), 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 diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt index aee5df74d..0a01bab32 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt @@ -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 @@ -1511,9 +1511,9 @@ class SyncManagerTest { assertEquals(emptyList(), knownIds.cycleIds) assertEquals(listOf(badge1, badge2, badge3), knownIds.badgeIds) assertEquals( - emptyList(), + 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", ) } @@ -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(), knownIds.personalRecordIds) + assertEquals(listOf(personalRecordId), knownIds.personalRecordIds) } @Test diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt index 0c49ec2cc..30070a723 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt @@ -80,6 +80,7 @@ open class FakePortalApiClient : val pullCallCursors: MutableList = mutableListOf() val pullCallProfileIds: MutableList = mutableListOf() val pullCallTimestampsMs: MutableList = mutableListOf() + val pullKnownEntityIdsHistory: MutableList = mutableListOf() var pullTimestampSourceMs: () -> Long = { currentTimeMillis() } var lastIntegrationSyncRequest: IntegrationSyncRequest? = null var lastPlaygroundSimulationRequest: IntegrationPlaygroundSimulationRequest? = null @@ -114,6 +115,7 @@ open class FakePortalApiClient : ): Result { pullCallCount++ lastPullKnownEntityIds = knownEntityIds + pullKnownEntityIdsHistory += knownEntityIds lastPullDeviceId = deviceId lastPullProfileId = profileId pullCallProfileIds += profileId