Files
CollabTable/CollabTableServer/src/routes/syncRoutes.ts
T

268 lines
11 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db';
const router = Router();
interface SyncRequest {
lastSyncTimestamp: number;
lists: any[];
fields: any[];
items: any[];
itemValues: any[];
}
// Stats tracking
let syncStats = {
totalSyncs: 0,
listsReceived: 0,
fieldsReceived: 0,
itemsReceived: 0,
valuesReceived: 0,
listsSent: 0,
fieldsSent: 0,
itemsSent: 0,
valuesSent: 0,
lastReset: Date.now()
};
// Print stats every 60 seconds (reduced spam)
setInterval(() => {
if (syncStats.totalSyncs > 0) {
const elapsed = Math.round((Date.now() - syncStats.lastReset) / 1000);
console.log(`\n[STATS] Sync Summary (last ${elapsed}s):`);
console.log(` ${syncStats.totalSyncs} sync requests processed`);
if (syncStats.listsReceived > 0 || syncStats.fieldsReceived > 0 || syncStats.itemsReceived > 0 || syncStats.valuesReceived > 0) {
console.log(` [IN] Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`);
}
if (syncStats.listsSent > 0 || syncStats.fieldsSent > 0 || syncStats.itemsSent > 0 || syncStats.valuesSent > 0) {
console.log(` [OUT] Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values`);
}
// Reset stats
syncStats = {
totalSyncs: 0,
listsReceived: 0,
fieldsReceived: 0,
itemsReceived: 0,
valuesReceived: 0,
listsSent: 0,
fieldsSent: 0,
itemsSent: 0,
valuesSent: 0,
lastReset: Date.now()
};
}
}, 60000); // Changed from 30s to 60s
// Helper function to get prepared statements (lazy initialization)
// We'll perform upserts with positional params for cross-DB portability
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';
router.post('/sync', async (req: Request, res: Response) => {
try {
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body;
// Track stats
syncStats.totalSyncs++;
const incomingLists = lists?.length || 0;
const incomingFields = fields?.length || 0;
const incomingItems = items?.length || 0;
const incomingValues = itemValues?.length || 0;
syncStats.listsReceived += incomingLists;
syncStats.fieldsReceived += incomingFields;
syncStats.itemsReceived += incomingItems;
syncStats.valuesReceived += incomingValues;
// Only log significant sync events (not empty syncs)
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
const isInitialSync = lastSyncTimestamp === 0;
if (hasIncomingData) {
const timestamp = new Date().toLocaleTimeString();
console.log(`\n[SYNC] [${timestamp}] Receiving data from client`);
if (incomingLists > 0) {
console.log(` [LISTS] ${incomingLists} list(s)`);
lists.forEach(list => {
const action = list.isDeleted ? 'Deleted' : (lastSyncTimestamp === 0 ? 'Created' : 'Updated');
console.log(` ${action}: "${list.name}"`);
});
}
if (incomingFields > 0) {
console.log(` [FIELDS] ${incomingFields} field(s)`);
}
if (incomingItems > 0) {
console.log(` [ITEMS] ${incomingItems} item(s)`);
}
if (incomingValues > 0) {
console.log(` [VALUES] ${incomingValues} value(s)`);
}
} else if (isInitialSync) {
const timestamp = new Date().toLocaleTimeString();
console.log(`\n[SYNC] [${timestamp}] Initial sync from new client`);
}
// Save incoming data from client using a transaction
await dbAdapter.transaction(async (tx) => {
if (lists && lists.length > 0) {
for (const list of lists) {
await tx.execute(UPSERT_LIST, [
list.id,
list.name,
list.createdAt,
list.updatedAt,
list.isDeleted ? 1 : 0
]);
}
}
if (fields && fields.length > 0) {
for (const field of 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
]);
}
}
if (items && items.length > 0) {
for (const item of items) {
await tx.execute(UPSERT_ITEM, [
item.id,
item.listId,
item.createdAt,
item.updatedAt,
item.isDeleted ? 1 : 0
]);
}
}
if (itemValues && itemValues.length > 0) {
for (const value of itemValues) {
await tx.execute(UPSERT_ITEM_VALUE, [
value.id,
value.itemId,
value.fieldId,
value.value,
value.updatedAt
]);
}
}
});
// IMPORTANT: Get updates from server AFTER saving incoming data
// This ensures clients receive back any data they just sent (for confirmation)
// If lastSyncTimestamp is 0, get all data (initial sync)
// IMPORTANT: Include deleted items so clients can sync deletions
let serverLists, serverFields, serverItems, serverItemValues;
if (lastSyncTimestamp === 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]);
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]);
}
// 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();
// Track outgoing stats
syncStats.listsSent += (serverLists as any[]).length;
syncStats.fieldsSent += (serverFields as any[]).length;
syncStats.itemsSent += (serverItems as any[]).length;
syncStats.valuesSent += (serverItemValues as any[]).length;
// Only log when sending significant data back
const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0;
if (hasOutgoingData && !isInitialSync) {
console.log(` [OUT] Sending back: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
} else if (isInitialSync && hasOutgoingData) {
console.log(` [OUT] Sending initial data: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
}
res.json({
lists: normLists,
fields: normFields,
items: normItems,
itemValues: normValues,
serverTimestamp
});
} catch (error) {
console.error('[ERROR] Sync error:', error);
res.status(500).json({ error: 'Sync failed' });
}
});
export default router;