Refactor database access to use a unified dbAdapter for SQLite and PostgreSQL

- Replaced direct database calls with dbAdapter methods in fieldRoutes, itemRoutes, listRoutes, syncRoutes, and webRoutes.
- Implemented a new db.ts file to handle database interactions for both SQLite and PostgreSQL.
- Added WebSocket support for real-time synchronization in WebSocketSyncClient and ws.ts.
- Updated TypeScript configuration to include WebSocket types.
- Created a new WebSocket server for handling sync requests and responses.
- Ensured compatibility with both SQLite and PostgreSQL by using parameterized queries.
This commit is contained in:
2025-10-25 17:02:42 +02:00
parent 22ec511ca7
commit e5e72a39ac
24 changed files with 1183 additions and 395 deletions
+266
View File
@@ -0,0 +1,266 @@
import dotenv from 'dotenv';
import BetterSqlite3, { Database as SqliteDB } from 'better-sqlite3';
import { Pool, PoolClient, QueryResult } from 'pg';
import path from 'path';
import fs from 'fs';
dotenv.config();
type Param = any;
type ExecResult = { changes: number };
type TxClient = {
query: (text: string, params?: any[]) => Promise<QueryResult<any>>;
};
interface DBAdapter {
queryAll(sql: string, params?: Param[]): Promise<any[]>;
queryOne(sql: string, params?: Param[]): Promise<any | undefined>;
execute(sql: string, params?: Param[]): Promise<ExecResult>;
transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T>;
initialize(): Promise<void>;
}
function ensureDataDir(dbPath: string) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function replaceQuotedIdentifiers(sql: string) {
// Convert backticks to double quotes for portability
return sql.replace(/`([^`]+)`/g, '"$1"');
}
function convertQMarksToPg(sql: string) {
// Replace each ? with $1, $2, ...
let idx = 0;
return sql.replace(/\?/g, () => `$${++idx}`);
}
// ---------- SQLite Adapter (wrapped async) ----------
class SqliteAdapter implements DBAdapter {
private db: SqliteDB;
constructor() {
const DB_PATH = process.env.DB_PATH || './data/collabtable.db';
ensureDataDir(DB_PATH);
this.db = new BetterSqlite3(DB_PATH);
this.db.pragma('foreign_keys = ON');
}
async initialize(): Promise<void> {
// Create tables and indexes
this.db.exec(`
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS fields (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fieldType TEXT NOT NULL,
fieldOptions TEXT,
listId TEXT NOT NULL,
"order" INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
CREATE TABLE IF NOT EXISTS item_values (
id TEXT PRIMARY KEY,
itemId TEXT NOT NULL,
fieldId TEXT NOT NULL,
value TEXT,
updatedAt INTEGER NOT NULL,
FOREIGN KEY (itemId) REFERENCES items(id),
FOREIGN KEY (fieldId) REFERENCES fields(id)
);
CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId);
CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);
CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);
CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);
`);
}
async queryAll(sql: string, params: Param[] = []): Promise<any[]> {
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
return stmt.all(...params);
}
async queryOne(sql: string, params: Param[] = []): Promise<any | undefined> {
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
return stmt.get(...params);
}
async execute(sql: string, params: Param[] = []): Promise<ExecResult> {
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
const res = stmt.run(...params);
return { changes: res.changes || 0 };
}
async transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T> {
const tx = this.db.transaction((innerFn: (tx: DBAdapter) => Promise<T>) => {
// We can reuse the same adapter since better-sqlite3 is transactional per connection
return innerFn(this);
});
return tx(fn);
}
}
// ---------- Postgres Adapter ----------
class PostgresAdapter implements DBAdapter {
private pool: Pool;
constructor() {
const connectionString = process.env.DATABASE_URL;
if (connectionString) {
this.pool = new Pool({ connectionString });
} else {
this.pool = new Pool({
host: process.env.PGHOST || 'localhost',
port: parseInt(process.env.PGPORT || '5432', 10),
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || '',
database: process.env.PGDATABASE || 'collabtable'
});
}
}
async initialize(): Promise<void> {
// Create tables if not exist, keeping schema aligned with sqlite (isDeleted INTEGER 0/1)
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(`
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
createdAt BIGINT NOT NULL,
updatedAt BIGINT NOT NULL,
isDeleted INTEGER DEFAULT 0
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS fields (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fieldType TEXT NOT NULL,
fieldOptions TEXT,
listId TEXT NOT NULL,
"order" INTEGER NOT NULL,
createdAt BIGINT NOT NULL,
updatedAt BIGINT NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
createdAt BIGINT NOT NULL,
updatedAt BIGINT NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS item_values (
id TEXT PRIMARY KEY,
itemId TEXT NOT NULL,
fieldId TEXT NOT NULL,
value TEXT,
updatedAt BIGINT NOT NULL,
FOREIGN KEY (itemId) REFERENCES items(id),
FOREIGN KEY (fieldId) REFERENCES fields(id)
);
`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
private async runQuery(sql: string, params: Param[] = [], client?: PoolClient): Promise<QueryResult<any>> {
// Replace backticks and convert ? to $1..$n
const text = convertQMarksToPg(replaceQuotedIdentifiers(sql));
const runner = client || this.pool;
// @ts-ignore - Pool and PoolClient share query signature
return runner.query(text, params);
}
async queryAll(sql: string, params: Param[] = []): Promise<any[]> {
const res = await this.runQuery(sql, params);
return res.rows;
}
async queryOne(sql: string, params: Param[] = []): Promise<any | undefined> {
const res = await this.runQuery(sql, params);
return res.rows[0];
}
async execute(sql: string, params: Param[] = []): Promise<ExecResult> {
const res = await this.runQuery(sql, params);
return { changes: res.rowCount || 0 };
}
async transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const txAdapter: DBAdapter = {
initialize: async () => {},
queryAll: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows),
queryOne: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows[0]),
execute: (sql, params) => this.runQuery(sql, params, client).then(r => ({ changes: r.rowCount || 0 })),
transaction: async (inner) => {
// Nested transactions are treated as flat for simplicity
return inner(txAdapter);
}
} as DBAdapter;
const result = await fn(txAdapter);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}
const clientType = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
export const dbAdapter: DBAdapter = clientType === 'postgres' || clientType === 'postgresql'
? new PostgresAdapter()
: new SqliteAdapter();
export async function initializeDatabase() {
await dbAdapter.initialize();
}
export type { DBAdapter };
+31 -19
View File
@@ -1,13 +1,15 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { initializeDatabase } from './database';
import { initializeDatabase } from './db';
import { authenticatePassword } from './middleware/auth';
import listRoutes from './routes/listRoutes';
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();
@@ -36,23 +38,33 @@ app.use('/api', syncRoutes);
app.use('/', webRoutes);
// Initialize database and start server
try {
initializeDatabase();
console.log('Database synchronized successfully');
console.log('Database models loaded:');
console.log('- Lists');
console.log('- Fields');
console.log('- Items');
console.log('- ItemValues');
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`);
});
} catch (error) {
console.error('Database initialization error:', error);
process.exit(1);
}
(async () => {
try {
await initializeDatabase();
console.log('Database synchronized successfully');
console.log('Database models loaded:');
console.log('- Lists');
console.log('- Fields');
console.log('- Items');
console.log('- ItemValues');
const server = createServer(app);
initWebSocket(server);
server.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();
if (dbClient === 'postgres' || dbClient === 'postgresql') {
const source = process.env.DATABASE_URL ? 'DATABASE_URL' : 'PGHOST/PGDATABASE';
console.log(`Database: PostgreSQL (${source})`);
} else {
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`);
}
});
} catch (error) {
console.error('Database initialization error:', error);
process.exit(1);
}
})();
export default app;
+16 -19
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
import { dbAdapter } from '../db';
const router = Router();
// Get fields for a list
router.get('/list/:listId', async (req: Request, res: Response) => {
try {
const fields = db.prepare('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY `order` ASC').all(req.params.listId);
const fields = await dbAdapter.queryAll('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY "order" ASC', [req.params.listId]);
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
res.json(formattedFields);
} catch (error) {
@@ -18,12 +18,11 @@ router.get('/list/:listId', async (req: Request, res: Response) => {
router.post('/', async (req: Request, res: Response) => {
try {
const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0);
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(id);
await dbAdapter.execute(
'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0]
);
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]);
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create field' });
@@ -35,17 +34,16 @@ router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const { name, fieldType, fieldOptions, order } = req.body;
const result = db.prepare(`
UPDATE fields
SET name = ?, fieldType = ?, fieldOptions = ?, \`order\` = ?, updatedAt = ?
WHERE id = ?
`).run(name, fieldType, fieldOptions, order, updatedAt, req.params.id);
const result = await dbAdapter.execute(
'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, "order" = ?, updatedAt = ? WHERE id = ?',
[name, fieldType, fieldOptions, order, updatedAt, req.params.id]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' });
}
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(req.params.id);
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [req.params.id]);
res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update field' });
@@ -56,11 +54,10 @@ router.put('/:id', async (req: Request, res: Response) => {
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE fields
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
const result = await dbAdapter.execute(
'UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE id = ?',
[updatedAt, req.params.id]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' });
+22 -29
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
import { dbAdapter } from '../db';
const router = Router();
// Get items for a list
router.get('/list/:listId', async (req: Request, res: Response) => {
try {
const items = db.prepare('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC').all(req.params.listId);
const items = await dbAdapter.queryAll('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC', [req.params.listId]);
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
res.json(formattedItems);
} catch (error) {
@@ -17,7 +17,7 @@ router.get('/list/:listId', async (req: Request, res: Response) => {
// Get item values
router.get('/:itemId/values', async (req: Request, res: Response) => {
try {
const values = db.prepare('SELECT * FROM item_values WHERE itemId = ?').all(req.params.itemId);
const values = await dbAdapter.queryAll('SELECT * FROM item_values WHERE itemId = ?', [req.params.itemId]);
res.json(values);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch item values' });
@@ -28,12 +28,11 @@ router.get('/:itemId/values', async (req: Request, res: Response) => {
router.post('/', async (req: Request, res: Response) => {
try {
const { id, listId, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?)
`).run(id, listId, createdAt, updatedAt, isDeleted ? 1 : 0);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id);
await dbAdapter.execute(
'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)',
[id, listId, createdAt, updatedAt, isDeleted ? 1 : 0]
);
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [id]);
res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create item' });
@@ -44,15 +43,11 @@ router.post('/', async (req: Request, res: Response) => {
router.post('/values', async (req: Request, res: Response) => {
try {
const { id, itemId, fieldId, value, updatedAt } = req.body;
db.prepare(`
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
value = ?,
updatedAt = ?
`).run(id, itemId, fieldId, value, updatedAt, value, updatedAt);
const itemValue = db.prepare('SELECT * FROM item_values WHERE id = ?').get(id);
await dbAdapter.execute(
'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = ?, updatedAt = ?',
[id, itemId, fieldId, value, updatedAt, value, updatedAt]
);
const itemValue = await dbAdapter.queryOne('SELECT * FROM item_values WHERE id = ?', [id]);
res.json(itemValue);
} catch (error) {
res.status(500).json({ error: 'Failed to save item value' });
@@ -63,17 +58,16 @@ router.post('/values', async (req: Request, res: Response) => {
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE items
SET updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
const result = await dbAdapter.execute(
'UPDATE items SET updatedAt = ? WHERE id = ?',
[updatedAt, req.params.id]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' });
}
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id);
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]);
res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update item' });
@@ -84,11 +78,10 @@ router.put('/:id', async (req: Request, res: Response) => {
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE items
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
const result = await dbAdapter.execute(
'UPDATE items SET isDeleted = 1, updatedAt = ? WHERE id = ?',
[updatedAt, req.params.id]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' });
+19 -23
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
import { dbAdapter } from '../db';
const router = Router();
// Get all lists
router.get('/', async (req: Request, res: Response) => {
try {
const lists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC').all();
const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC');
// Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
res.json(formattedLists);
@@ -18,7 +18,7 @@ router.get('/', async (req: Request, res: Response) => {
// Get single list
router.get('/:id', async (req: Request, res: Response) => {
try {
const list = db.prepare('SELECT * FROM lists WHERE id = ? AND isDeleted = 0').get(req.params.id);
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ? AND isDeleted = 0', [req.params.id]);
if (!list) {
return res.status(404).json({ error: 'List not found' });
}
@@ -32,12 +32,11 @@ router.get('/:id', async (req: Request, res: Response) => {
router.post('/', async (req: Request, res: Response) => {
try {
const { id, name, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?)
`).run(id, name, createdAt, updatedAt, isDeleted ? 1 : 0);
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(id);
await dbAdapter.execute(
'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)',
[id, name, createdAt, updatedAt, isDeleted ? 1 : 0]
);
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [id]);
res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create list' });
@@ -48,17 +47,15 @@ router.post('/', async (req: Request, res: Response) => {
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE lists
SET name = ?, updatedAt = ?
WHERE id = ?
`).run(req.body.name, updatedAt, req.params.id);
const result = await dbAdapter.execute(
'UPDATE lists SET name = ?, updatedAt = ? WHERE id = ?',
[req.body.name, updatedAt, req.params.id]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' });
}
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(req.params.id);
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [req.params.id]);
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update list' });
@@ -69,12 +66,11 @@ router.put('/:id', async (req: Request, res: Response) => {
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE lists
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
const result = await dbAdapter.execute(
'UPDATE lists SET isDeleted = 1, updatedAt = ? WHERE id = ?',
[updatedAt, req.params.id]
);
if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' });
}
+55 -94
View File
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
import { dbAdapter } from '../db';
const router = Router();
@@ -57,43 +57,11 @@ setInterval(() => {
}, 60000); // Changed from 30s to 60s
// Helper function to get prepared statements (lazy initialization)
function getUpsertStatements() {
return {
upsertList: db.prepare(`
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
VALUES (@id, @name, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
name = @name,
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertField: db.prepare(`
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
VALUES (@id, @name, @fieldType, @fieldOptions, @listId, @order, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
name = @name,
fieldType = @fieldType,
fieldOptions = @fieldOptions,
\`order\` = @order,
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertItem: db.prepare(`
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
VALUES (@id, @listId, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertItemValue: db.prepare(`
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
VALUES (@id, @itemId, @fieldId, @value, @updatedAt)
ON CONFLICT(id) DO UPDATE SET
value = @value,
updatedAt = @updatedAt
`)
};
}
// 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 {
@@ -144,65 +112,60 @@ router.post('/sync', async (req: Request, res: Response) => {
}
// Save incoming data from client using a transaction
const transaction = db.transaction((data: SyncRequest) => {
// Get prepared statements lazily
const { upsertList, upsertField, upsertItem, upsertItemValue } = getUpsertStatements();
if (data.lists && data.lists.length > 0) {
for (const list of data.lists) {
upsertList.run({
id: list.id,
name: list.name,
createdAt: list.createdAt,
updatedAt: list.updatedAt,
isDeleted: list.isDeleted ? 1 : 0
});
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 (data.fields && data.fields.length > 0) {
for (const field of data.fields) {
upsertField.run({
id: field.id,
name: field.name,
fieldType: field.fieldType,
fieldOptions: field.fieldOptions,
listId: field.listId,
order: field.order,
createdAt: field.createdAt,
updatedAt: field.updatedAt,
isDeleted: field.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 (data.items && data.items.length > 0) {
for (const item of data.items) {
upsertItem.run({
id: item.id,
listId: item.listId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
isDeleted: item.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 (data.itemValues && data.itemValues.length > 0) {
for (const value of data.itemValues) {
upsertItemValue.run({
id: value.id,
itemId: value.itemId,
fieldId: value.fieldId,
value: value.value,
updatedAt: value.updatedAt
});
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
]);
}
}
});
transaction({ lastSyncTimestamp, lists, fields, items, itemValues });
// 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)
@@ -210,17 +173,15 @@ router.post('/sync', async (req: Request, res: Response) => {
let serverLists, serverFields, serverItems, serverItemValues;
if (lastSyncTimestamp === 0) {
// Initial sync: send all non-deleted items (AFTER processing incoming data)
serverLists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0').all();
serverFields = db.prepare('SELECT * FROM fields WHERE isDeleted = 0').all();
serverItems = db.prepare('SELECT * FROM items WHERE isDeleted = 0').all();
serverItemValues = db.prepare('SELECT * FROM item_values').all();
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 {
// Incremental sync: send all updates including deletions (use >= to include items updated exactly at lastSyncTimestamp)
serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt >= ?').all(lastSyncTimestamp);
serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt >= ?').all(lastSyncTimestamp);
serverItems = db.prepare('SELECT * FROM items WHERE updatedAt >= ?').all(lastSyncTimestamp);
serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt >= ?').all(lastSyncTimestamp);
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]);
}
// Convert isDeleted from INTEGER (0/1) to BOOLEAN
+6 -5
View File
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
import { dbAdapter } from '../db';
const router = Router();
@@ -306,10 +306,11 @@ router.get('/web/data', async (req: Request, res: Response) => {
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all();
const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all();
const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all();
const values = db.prepare('SELECT * FROM item_values').all();
// Exclude deleted lists and items from web UI
const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC');
const fields = await dbAdapter.queryAll('SELECT * FROM fields ORDER BY listId ASC, "order" ASC');
const items = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0 ORDER BY listId ASC, updatedAt DESC');
const values = await dbAdapter.queryAll('SELECT * FROM item_values');
// Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
+106
View File
@@ -0,0 +1,106 @@
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));
}
});
});
}