From d72a379a2a26f229e5b10f597b7b99d498308764 Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Sun, 26 Oct 2025 16:27:49 +0100 Subject: [PATCH] fix: prevent overlapping syncs by implementing mutex lock in SyncRepository --- .../app/data/repository/SyncRepository.kt | 9 +++ CollabTableServer/src/routes/syncRoutes.ts | 68 ++++++++++++++++--- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index 52945b4..b51827e 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -7,6 +7,8 @@ import com.collabtable.app.data.api.SyncRequest import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.utils.Logger import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext class SyncRepository(context: Context) { @@ -25,6 +27,8 @@ class SyncRepository(context: Context) { suspend fun performSync(): Result = withContext(Dispatchers.IO) { + // Prevent overlapping syncs across screens/viewmodels + syncMutex.withLock { try { val lastSync = getLastSyncTimestamp() val isInitialSync = lastSync == 0L @@ -164,5 +168,10 @@ class SyncRepository(context: Context) { Logger.e("Sync", "[ERROR] Sync error: ${e.message}") return@withContext Result.failure(e) } + } } + + companion object { + private val syncMutex = Mutex() + } } diff --git a/CollabTableServer/src/routes/syncRoutes.ts b/CollabTableServer/src/routes/syncRoutes.ts index eef55a1..5420038 100644 --- a/CollabTableServer/src/routes/syncRoutes.ts +++ b/CollabTableServer/src/routes/syncRoutes.ts @@ -173,9 +173,10 @@ router.post('/sync', async (req: Request, res: Response) => { let serverLists, serverFields, serverItems, serverItemValues; if (lastSyncTimestamp === 0) { - serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0'); - serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0'); - serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0'); + // Include deleted rows on initial sync to propagate tombstones + serverLists = await dbAdapter.queryAll('SELECT * FROM lists'); + serverFields = await dbAdapter.queryAll('SELECT * FROM fields'); + serverItems = await dbAdapter.queryAll('SELECT * FROM items'); serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values'); } else { serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]); @@ -184,10 +185,55 @@ router.post('/sync', async (req: Request, res: Response) => { serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]); } - // Convert isDeleted from INTEGER (0/1) to BOOLEAN - serverLists = (serverLists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted })); - serverFields = (serverFields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted })); - serverItems = (serverItems as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted })); + // Normalize output for clients (camelCase keys, ms timestamps, boolean isDeleted) + const toMillis = (v: any): number | null => { + if (v === null || v === undefined) return null; + if (typeof v === 'number') return v < 1e12 ? Math.trunc(v * 1000) : Math.trunc(v); + if (typeof v === 'string') { + if (/^\d+$/.test(v)) { + const n = Number(v); + return n < 1e12 ? Math.trunc(n * 1000) : Math.trunc(n); + } + const t = Date.parse(v); + return Number.isNaN(t) ? null : Math.trunc(t); + } + return null; + }; + const pick = (obj: any, a: string, b: string) => obj[a] ?? obj[b]; + + const normLists = (serverLists as any[]).map(l => ({ + id: l.id, + name: l.name, + createdAt: toMillis(pick(l, 'createdAt', 'createdat')), + updatedAt: toMillis(pick(l, 'updatedAt', 'updatedat')), + isDeleted: !!pick(l, 'isDeleted', 'isdeleted'), + // orderIndex is local-only, do not include + })); + const normFields = (serverFields as any[]).map(f => ({ + id: f.id, + listId: pick(f, 'listId', 'listid'), + name: f.name, + fieldType: f.fieldType ?? f.fieldtype, + fieldOptions: f.fieldOptions ?? f.fieldoptions ?? '', + order: f.order, + createdAt: toMillis(pick(f, 'createdAt', 'createdat')), + updatedAt: toMillis(pick(f, 'updatedAt', 'updatedat')), + isDeleted: !!pick(f, 'isDeleted', 'isdeleted'), + })); + const normItems = (serverItems as any[]).map(i => ({ + id: i.id, + listId: pick(i, 'listId', 'listid'), + createdAt: toMillis(pick(i, 'createdAt', 'createdat')), + updatedAt: toMillis(pick(i, 'updatedAt', 'updatedat')), + isDeleted: !!pick(i, 'isDeleted', 'isdeleted'), + })); + const normValues = (serverItemValues as any[]).map(v => ({ + id: v.id, + itemId: pick(v, 'itemId', 'itemid'), + fieldId: pick(v, 'fieldId', 'fieldid'), + value: v.value, + updatedAt: toMillis(pick(v, 'updatedAt', 'updatedat')), + })); const serverTimestamp = Date.now(); @@ -206,10 +252,10 @@ router.post('/sync', async (req: Request, res: Response) => { } res.json({ - lists: serverLists, - fields: serverFields, - items: serverItems, - itemValues: serverItemValues, + lists: normLists, + fields: normFields, + items: normItems, + itemValues: normValues, serverTimestamp }); } catch (error) {