refactor: remove WebSocket implementation and switch to HTTP-only sync with configurable polling interval

This commit is contained in:
2025-10-26 15:12:45 +01:00
parent 74fecb3ec2
commit 394ebfd76c
11 changed files with 171 additions and 391 deletions
@@ -1,166 +0,0 @@
package com.collabtable.app.data.api
import android.content.Context
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger
import com.google.gson.Gson
import com.google.gson.JsonObject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.logging.HttpLoggingInterceptor
import java.util.UUID
import java.util.concurrent.TimeUnit
private data class MessageEnvelope(
val id: String? = null,
val type: String,
val payload: Any? = null,
)
object WebSocketSyncClient {
private val gson = Gson()
private val logging =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
private val client: OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.pingInterval(15, TimeUnit.SECONDS)
.build()
private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
// Expecting something like: http://host:port/api/
var url = httpApiBaseUrl
// Ensure trailing slash removed for manipulation
if (url.endsWith("/")) url = url.dropLast(1)
// Replace scheme
url =
when {
url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
else -> "ws://$url" // best effort
}
// Replace trailing /api with /api/ws
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
}
suspend fun sync(
context: Context,
request: SyncRequest,
): Result<SyncResponse> =
withContext(Dispatchers.IO) {
val prefs = PreferencesManager.getInstance(context)
val baseUrl = prefs.getServerUrl()
val password = prefs.getServerPassword()
val wsUrl = buildWebSocketUrl(baseUrl)
val messageId = UUID.randomUUID().toString()
val deferred = CompletableDeferred<Result<SyncResponse>>()
var socket: WebSocket? = null
val httpRequestBuilder = Request.Builder().url(wsUrl)
if (!password.isNullOrBlank()) {
httpRequestBuilder.addHeader("Authorization", "Bearer $password")
}
val httpRequest = httpRequestBuilder.build()
val listener =
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
// Send sync message when opened
val envelope =
MessageEnvelope(
id = messageId,
type = "sync",
payload = request,
)
val json = gson.toJson(envelope)
webSocket.send(json)
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
try {
val root = gson.fromJson(text, JsonObject::class.java)
val type = root.get("type")?.asString
val id = root.get("id")?.asString
if (type == "syncResponse" && id == messageId) {
val payload = root.getAsJsonObject("payload")
val resp = gson.fromJson(payload, SyncResponse::class.java)
if (!deferred.isCompleted) {
deferred.complete(Result.success(resp))
webSocket.close(1000, "done")
}
} else if (type == "error" && !deferred.isCompleted) {
val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error"
deferred.complete(Result.failure(Exception(msg)))
webSocket.close(1002, "error")
}
} catch (e: Exception) {
if (!deferred.isCompleted) {
deferred.complete(Result.failure(e))
}
}
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
// 1008 indicates policy violation, used here for Unauthorized
if (!deferred.isCompleted) {
if (code == 1008) {
deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)")))
} else {
deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason")))
}
}
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
if (!deferred.isCompleted) {
deferred.complete(Result.failure(t))
}
}
}
try {
socket = client.newWebSocket(httpRequest, listener)
// Wait up to 30s for response (WS roundtrip)
val result = withTimeout(30_000) { deferred.await() }
return@withContext result
} catch (e: Exception) {
Logger.w("WS", "Falling back to HTTP sync: ${e.message}")
return@withContext Result.failure(e)
} finally {
// OkHttp cleans up sockets automatically when no references remain
// but we try to close if still open
socket?.close(1000, null)
}
}
}
@@ -25,6 +25,10 @@ class PreferencesManager(context: Context) {
private val _sortOrder = MutableStateFlow(getSortOrder())
val sortOrder: StateFlow<String> = _sortOrder.asStateFlow()
// Sync polling interval in milliseconds (configurable)
private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs())
val syncPollIntervalMs: StateFlow<Long> = _syncPollIntervalMs.asStateFlow()
fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
}
@@ -124,6 +128,18 @@ class PreferencesManager(context: Context) {
_sortOrder.value = normalized
}
// Sync interval: read/write with sensible bounds to avoid too-aggressive polling
fun getSyncPollIntervalMs(): Long {
val raw = prefs.getLong(KEY_SYNC_POLL_INTERVAL_MS, DEFAULT_SYNC_POLL_INTERVAL_MS)
return raw.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS)
}
fun setSyncPollIntervalMs(ms: Long) {
val clamped = ms.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS)
prefs.edit().putLong(KEY_SYNC_POLL_INTERVAL_MS, clamped).apply()
_syncPollIntervalMs.value = clamped
}
companion object {
private const val KEY_SERVER_URL = "server_url"
private const val KEY_FIRST_RUN = "first_run"
@@ -133,7 +149,11 @@ class PreferencesManager(context: Context) {
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
private const val KEY_AMOLED_DARK = "amoled_dark"
private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms"
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 5000L
private const val MIN_SYNC_POLL_INTERVAL_MS = 250L
private const val MAX_SYNC_POLL_INTERVAL_MS = 600_000L // 10 minutes
const val THEME_MODE_SYSTEM = "system"
const val THEME_MODE_LIGHT = "light"
const val THEME_MODE_DARK = "dark"
@@ -4,7 +4,6 @@ import android.content.Context
import androidx.room.withTransaction
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.SyncRequest
import com.collabtable.app.data.api.WebSocketSyncClient
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers
@@ -60,25 +59,19 @@ class SyncRepository(context: Context) {
itemValues = localValues,
)
// Try WebSocket first; on failure, fall back to HTTP
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
val syncResponse =
if (wsResult.isSuccess) {
wsResult.getOrThrow()
} else {
val response = api.sync(syncRequest)
if (!response.isSuccessful) {
if (response.code() == 401) {
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.")
setLastSyncTimestamp(0)
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
}
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
}
response.body()!!
// HTTP-only sync
val response = api.sync(syncRequest)
if (!response.isSuccessful) {
if (response.code() == 401) {
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.")
setLastSyncTimestamp(0)
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
}
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
}
val syncResponse = response.body()!!
// Only log receive when there are actual server changes
val inTotal =
syncResponse.lists.size +
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
@@ -2522,11 +2523,8 @@ fun ManageColumnsDialog(
val reorderState =
rememberReorderableLazyListState(
onMove = { from, to ->
// Map from Lazy positions (with dividers) to data indices.
// Insert AFTER the upper item when hovering between rows.
val fromData = from.index / 2
val toData = (to.index + 1) / 2
reorderedFields.move(fromData, toData)
// Indices from reorderable refer to draggable items only
reorderedFields.move(from.index, to.index)
},
)
@@ -2540,53 +2538,41 @@ fun ManageColumnsDialog(
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Interleave dividers as separate Lazy items so dividers don't move with dragged rows
items(
count = if (reorderedFields.isEmpty()) 0 else reorderedFields.size * 2 - 1,
key = { pos ->
if (pos % 2 == 0) reorderedFields[pos / 2].id else "divider-$pos"
},
) { pos ->
if (pos % 2 == 1) {
Divider()
} else {
val index = pos / 2
val field = reorderedFields[index]
ReorderableItem(
reorderState,
key = field.id,
) { _ ->
Box(
modifier = Modifier.animateItemPlacement(),
) {
ColumnItem(
field = field,
onEdit = { fieldToEdit = field },
onDelete = { fieldToDelete = field },
onMoveUp = {
if (index > 0) {
val item = reorderedFields.removeAt(index)
reorderedFields.add(index - 1, item)
}
},
onMoveDown = {
if (index < reorderedFields.size - 1) {
val item = reorderedFields.removeAt(index)
reorderedFields.add(index + 1, item)
}
},
canMoveUp = index > 0,
canMoveDown = index < reorderedFields.size - 1,
dragHandle = {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Reorder",
modifier = Modifier.detectReorder(reorderState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
itemsIndexed(reorderedFields, key = { _, field -> field.id }) { index, field ->
ReorderableItem(
reorderState,
key = field.id,
) { _ ->
Box(
modifier = Modifier.animateItemPlacement(),
) {
ColumnItem(
field = field,
onEdit = { fieldToEdit = field },
onDelete = { fieldToDelete = field },
onMoveUp = {
if (index > 0) {
val item = reorderedFields.removeAt(index)
reorderedFields.add(index - 1, item)
}
},
onMoveDown = {
if (index < reorderedFields.size - 1) {
val item = reorderedFields.removeAt(index)
reorderedFields.add(index + 1, item)
}
},
canMoveUp = index > 0,
canMoveDown = index < reorderedFields.size - 1,
dragHandle = {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Reorder",
modifier = Modifier.detectReorder(reorderState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}
}
@@ -65,9 +65,11 @@ class ListDetailViewModel(
private fun startPeriodicSync() {
viewModelScope.launch {
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
while (true) {
performSync()
kotlinx.coroutines.delay(5000) // Sync every 5 seconds
val delayMs = prefs.getSyncPollIntervalMs()
kotlinx.coroutines.delay(delayMs)
}
}
}
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
@@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -173,11 +172,8 @@ fun ListsScreen(
onMove = { from, to ->
dragging = true
val newList = working.toMutableList()
// Map from Lazy positions (with dividers) to data indices.
// to.index can be odd (between items). Insert AFTER the upper item for better feel.
val fromData = from.index / 2
val toData = (to.index + 1) / 2
newList.move(fromData, toData)
// Indices provided by reorderable correspond to draggable items only (dividers ignored)
newList.move(from.index, to.index)
working = newList
},
onDragEnd = { _, _ ->
@@ -195,35 +191,23 @@ fun ListsScreen(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Interleave dividers as separate list items so they don't move with dragged rows
items(
count = if (working.isEmpty()) 0 else working.size * 2 - 1,
key = { pos ->
if (pos % 2 == 0) working[pos / 2].id else "divider-$pos"
},
) { pos ->
if (pos % 2 == 1) {
Divider()
} else {
val index = pos / 2
val list = working[index]
ReorderableItem(reorderState, key = list.id) { _ ->
Box(modifier = Modifier.animateItemPlacement()) {
ListItem(
list = list,
onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = list },
dragHandle = {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Reorder",
modifier = Modifier.detectReorder(reorderState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
itemsIndexed(working, key = { _, it -> it.id }) { _, list ->
ReorderableItem(reorderState, key = list.id) { _ ->
Box(modifier = Modifier.animateItemPlacement()) {
ListItem(
list = list,
onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = list },
dragHandle = {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Reorder",
modifier = Modifier.detectReorder(reorderState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}
}
@@ -99,8 +99,10 @@ class ListsViewModel(
}
private suspend fun startPeriodicSync() {
val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context)
while (true) {
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync
val delayMs = prefs.getSyncPollIntervalMs()
kotlinx.coroutines.delay(delayMs)
performSync()
}
}
@@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.TextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -72,6 +73,9 @@ fun SettingsScreen(
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
val syncIntervalMs by preferencesManager.syncPollIntervalMs.collectAsState()
var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) }
var syncIntervalError by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
@@ -216,6 +220,72 @@ fun SettingsScreen(
}
}
// Sync settings
Text(
text = "Sync",
style = MaterialTheme.typography.titleMedium,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "HTTP polling interval (ms)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextField(
value = syncIntervalInput,
onValueChange = {
syncIntervalInput = it.filter { ch -> ch.isDigit() }
syncIntervalError = null
},
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text("5000") },
)
Button(onClick = {
val parsed = syncIntervalInput.toLongOrNull()
if (parsed == null) {
syncIntervalError = "Enter a valid number"
} else {
// Bounds are enforced in setter
preferencesManager.setSyncPollIntervalMs(parsed)
syncIntervalError = null
}
}) {
Text("Apply")
}
}
if (syncIntervalError != null) {
Text(
text = syncIntervalError!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
)
} else {
Text(
text = "Current: ${'$'}syncIntervalMs ms (min 250, max 600000)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors =
+3 -5
View File
@@ -19,10 +19,9 @@
"cors": "^2.8.5",
"better-sqlite3": "^11.0.0",
"dotenv": "^16.3.1",
"ws": "^8.16.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.57.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.3.3",
"pg": "^8.11.3"
@@ -32,7 +31,6 @@
"@types/cors": "^2.8.17",
"@types/node": "^20.10.6",
"@types/better-sqlite3": "^7.6.11",
"@types/ws": "^8.5.10",
"@types/pg": "^8.10.2",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0"
+2 -5
View File
@@ -8,8 +8,6 @@ import fieldRoutes from './routes/fieldRoutes';
import itemRoutes from './routes/itemRoutes';
import syncRoutes from './routes/syncRoutes';
import webRoutes from './routes/webRoutes';
import { createServer } from 'http';
import { initWebSocket } from './ws';
dotenv.config();
@@ -48,9 +46,8 @@ app.use('/', webRoutes);
console.log('- Items');
console.log('- ItemValues');
const server = createServer(app);
initWebSocket(server);
server.listen(PORT, () => {
// Start HTTP server (WebSocket removed)
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
-106
View File
@@ -1,106 +0,0 @@
import { Server as HttpServer, IncomingMessage } from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { dbAdapter } from './db';
interface SyncRequest {
lastSyncTimestamp: number;
lists: any[];
fields: any[];
items: any[];
itemValues: any[];
}
interface MessageEnvelope<T = any> {
id?: string;
type: string;
payload?: T;
}
function checkAuth(req: any): boolean {
const serverPassword = process.env.SERVER_PASSWORD;
if (!serverPassword) return true; // auth disabled
const auth = req.headers?.authorization as string | undefined;
if (!auth) return false;
const parts = auth.split(' ');
return parts.length === 2 && parts[0] === 'Bearer' && parts[1] === serverPassword;
}
const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt';
async function processSync(data: SyncRequest) {
await dbAdapter.transaction(async (tx) => {
for (const list of data.lists || []) {
await tx.execute(UPSERT_LIST, [list.id, list.name, list.createdAt, list.updatedAt, list.isDeleted ? 1 : 0]);
}
for (const field of data.fields || []) {
await tx.execute(UPSERT_FIELD, [field.id, field.name, field.fieldType, field.fieldOptions, field.listId, field.order, field.createdAt, field.updatedAt, field.isDeleted ? 1 : 0]);
}
for (const item of data.items || []) {
await tx.execute(UPSERT_ITEM, [item.id, item.listId, item.createdAt, item.updatedAt, item.isDeleted ? 1 : 0]);
}
for (const val of data.itemValues || []) {
await tx.execute(UPSERT_ITEM_VALUE, [val.id, val.itemId, val.fieldId, val.value, val.updatedAt]);
}
});
let serverLists: any[], serverFields: any[], serverItems: any[], serverItemValues: any[];
const since = data.lastSyncTimestamp;
if (since === 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');
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
} else {
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [since]);
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [since]);
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [since]);
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [since]);
}
serverLists = serverLists.map(l => ({ ...l, isDeleted: !!l.isDeleted }));
serverFields = serverFields.map(f => ({ ...f, isDeleted: !!f.isDeleted }));
serverItems = serverItems.map(i => ({ ...i, isDeleted: !!i.isDeleted }));
return {
lists: serverLists,
fields: serverFields,
items: serverItems,
itemValues: serverItemValues,
serverTimestamp: Date.now(),
};
}
export function initWebSocket(server: HttpServer) {
const wss = new WebSocketServer({ server, path: '/api/ws' });
console.log('WebSocket server initialized at /api/ws');
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
if (!checkAuth(req)) {
ws.close(1008, 'Unauthorized');
return;
}
ws.on('message', async (raw: Buffer) => {
try {
const msg = JSON.parse(raw.toString()) as MessageEnvelope;
if (msg.type === 'ping') {
const resp: MessageEnvelope = { type: 'pong', id: msg.id };
ws.send(JSON.stringify(resp));
return;
}
if (msg.type === 'sync') {
const respData = await processSync(msg.payload as SyncRequest);
const resp: MessageEnvelope = { type: 'syncResponse', id: msg.id, payload: respData };
ws.send(JSON.stringify(resp));
return;
}
} catch (e) {
const err: MessageEnvelope = { type: 'error', payload: { message: 'Invalid message' } };
ws.send(JSON.stringify(err));
}
});
});
}