2025-10-25 17:02:42 +02:00
|
|
|
import dotenv from 'dotenv';
|
|
|
|
|
import BetterSqlite3, { Database as SqliteDB } from 'better-sqlite3';
|
2025-10-25 17:33:48 +02:00
|
|
|
import { Pool, PoolClient, QueryResult, types as pgTypes } from 'pg';
|
2025-10-25 17:02:42 +02:00
|
|
|
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() {
|
2025-10-25 17:33:48 +02:00
|
|
|
// Ensure BIGINT (int8) is parsed as number to avoid string timestamps
|
|
|
|
|
// OID 20 = INT8, OID 1700 = NUMERIC (keep default for now)
|
|
|
|
|
pgTypes.setTypeParser(20, (val: string) => parseInt(val, 10));
|
2025-10-25 17:02:42 +02:00
|
|
|
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 };
|