2025-10-24 20:27:18 +02:00
import { Router , Request , Response } from 'express' ;
2025-10-25 17:02:42 +02:00
import { dbAdapter } from '../db' ;
2025-10-24 20:27:18 +02:00
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 ()
};
2025-10-24 22:43:18 +02:00
// Print stats every 60 seconds (reduced spam)
2025-10-24 20:27:18 +02:00
setInterval (() => {
if ( syncStats . totalSyncs > 0 ) {
const elapsed = Math . round (( Date . now () - syncStats . lastReset ) / 1000 );
2025-10-24 22:43:18 +02:00
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` );
}
2025-10-24 20:27:18 +02:00
// Reset stats
syncStats = {
totalSyncs : 0 ,
listsReceived : 0 ,
fieldsReceived : 0 ,
itemsReceived : 0 ,
valuesReceived : 0 ,
listsSent : 0 ,
fieldsSent : 0 ,
itemsSent : 0 ,
valuesSent : 0 ,
lastReset : Date.now ()
};
}
2025-10-24 22:43:18 +02:00
}, 60000 ); // Changed from 30s to 60s
2025-10-24 20:27:18 +02:00
// Helper function to get prepared statements (lazy initialization)
2025-10-25 17:02:42 +02:00
// 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' ;
2025-10-24 20:27:18 +02:00
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 ;
2025-10-24 22:43:18 +02:00
// Only log significant sync events (not empty syncs)
2025-10-24 20:27:18 +02:00
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0 ;
2025-10-24 22:43:18 +02:00
const isInitialSync = lastSyncTimestamp === 0 ;
2025-10-24 20:27:18 +02:00
if ( hasIncomingData ) {
2025-10-24 22:43:18 +02:00
const timestamp = new Date (). toLocaleTimeString ();
console . log ( ` \ n[SYNC] [ ${ timestamp } ] Receiving data from client` );
if ( incomingLists > 0 ) {
console . log ( ` [LISTS] ${ incomingLists } list(s)` );
2025-10-24 20:27:18 +02:00
lists . forEach ( list => {
2025-10-24 22:43:18 +02:00
const action = list . isDeleted ? 'Deleted' : ( lastSyncTimestamp === 0 ? 'Created' : 'Updated' );
console . log ( ` ${ action } : " ${ list . name } "` );
2025-10-24 20:27:18 +02:00
});
}
2025-10-24 22:43:18 +02:00
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` );
2025-10-24 20:27:18 +02:00
}
// Save incoming data from client using a transaction
2025-10-25 17:02:42 +02:00
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
]);
2025-10-24 20:27:18 +02:00
}
}
2025-10-25 17:02:42 +02:00
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
]);
2025-10-24 20:27:18 +02:00
}
}
2025-10-25 17:02:42 +02:00
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
]);
2025-10-24 20:27:18 +02:00
}
}
2025-10-25 17:02:42 +02:00
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
]);
2025-10-24 20:27:18 +02:00
}
}
});
2025-10-24 22:14:20 +02:00
// IMPORTANT: Get updates from server AFTER saving incoming data
// This ensures clients receive back any data they just sent (for confirmation)
2025-10-24 20:27:18 +02:00
// If lastSyncTimestamp is 0, get all data (initial sync)
2025-10-24 20:58:54 +02:00
// IMPORTANT: Include deleted items so clients can sync deletions
2025-10-24 20:27:18 +02:00
let serverLists , serverFields , serverItems , serverItemValues ;
if ( lastSyncTimestamp === 0 ) {
2025-10-26 16:27:49 +01:00
// 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' );
2025-10-25 17:02:42 +02:00
serverItemValues = await dbAdapter . queryAll ( 'SELECT * FROM item_values' );
2025-10-24 20:27:18 +02:00
} else {
2025-10-25 17:02:42 +02:00
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 ]);
2025-10-24 20:27:18 +02:00
}
2025-10-26 16:27:49 +01:00
// 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 < 1 e12 ? Math . trunc ( v * 1000 ) : Math . trunc ( v );
if ( typeof v === 'string' ) {
if ( /^\d+$/ . test ( v )) {
const n = Number ( v );
return n < 1 e12 ? 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' )),
}));
2025-10-24 20:27:18 +02:00
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 ;
2025-10-24 22:43:18 +02:00
// Only log when sending significant data back
2025-10-24 20:27:18 +02:00
const hasOutgoingData = ( serverLists as any []). length > 0 || ( serverFields as any []). length > 0 || ( serverItems as any []). length > 0 || ( serverItemValues as any []). length > 0 ;
2025-10-24 22:43:18 +02:00
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` );
2025-10-24 20:27:18 +02:00
}
res . json ({
2025-10-26 16:27:49 +01:00
lists : normLists ,
fields : normFields ,
items : normItems ,
itemValues : normValues ,
2025-10-24 20:27:18 +02:00
serverTimestamp
});
} catch ( error ) {
2025-10-24 22:43:18 +02:00
console . error ( '[ERROR] Sync error:' , error );
2025-10-24 20:27:18 +02:00
res . status ( 500 ). json ({ error : 'Sync failed' });
}
});
export default router ;