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:
@@ -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 };
|
||||
Reference in New Issue
Block a user