You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
import { normalizeWhatsAppId } from './utils/whatsapp';
|
|
import { mkdirSync } from 'fs';
|
|
import { join, resolve, dirname } from 'path';
|
|
import { Migrator } from './db/migrator';
|
|
import { migrations } from './db/migrations';
|
|
|
|
function applyDefaultPragmas(instance: Database): void {
|
|
try {
|
|
instance.exec(`PRAGMA busy_timeout = 5000;`);
|
|
// Intentar activar WAL (si no es soportado, SQLite devolverá 'memory' u otro modo)
|
|
instance.query(`PRAGMA journal_mode = WAL`).get();
|
|
instance.exec(`PRAGMA synchronous = NORMAL;`);
|
|
instance.exec(`PRAGMA wal_autocheckpoint = 1000;`);
|
|
// Asegurar claves foráneas siempre activas
|
|
instance.exec(`PRAGMA foreign_keys = ON;`);
|
|
} catch (e) {
|
|
console.warn('[db] No se pudieron aplicar PRAGMAs (WAL, busy_timeout...):', e);
|
|
}
|
|
}
|
|
|
|
// Function to get a database instance. Defaults to 'data/tasks.db'
|
|
export function getDb(filename: string = 'tasks.db'): Database {
|
|
// Prioridad 1: DB_PATH (ruta completa al archivo). Si está definida, se usa tal cual.
|
|
const dbPathEnv = process?.env?.DB_PATH ? String(process.env.DB_PATH).trim() : '';
|
|
if (dbPathEnv) {
|
|
const absolutePath = resolve(dbPathEnv);
|
|
// Crear directorio padre si no existe
|
|
try {
|
|
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
} catch (err) {
|
|
if ((err as any)?.code !== 'EEXIST') throw err; // Solo ignorar "ya existe"
|
|
}
|
|
const instance = new Database(absolutePath);
|
|
applyDefaultPragmas(instance);
|
|
return instance;
|
|
}
|
|
|
|
// Prioridad 2: DATA_DIR + filename (comportamiento actual)
|
|
// Determine base directory for the database (env DATA_DIR or default './data'), resolve to absolute
|
|
const dataDir = process?.env?.DATA_DIR ? String(process.env.DATA_DIR) : 'data';
|
|
const dirPath = resolve(dataDir);
|
|
|
|
// Try to create data directory if it doesn't exist (ignore if already exists)
|
|
try {
|
|
mkdirSync(dirPath, { recursive: true });
|
|
} catch (err) {
|
|
if ((err as any)?.code !== 'EEXIST') throw err; // Only ignore "already exists" errors
|
|
}
|
|
|
|
const instance = new Database(join(dirPath, filename));
|
|
applyDefaultPragmas(instance);
|
|
return instance;
|
|
}
|
|
|
|
// Default export for the main application database
|
|
export const db = getDb();
|
|
|
|
// Initialize function now accepts a database instance
|
|
export function initializeDatabase(instance: Database) {
|
|
// Aplicar PRAGMAs por defecto (WAL, busy_timeout, FK, etc.)
|
|
applyDefaultPragmas(instance);
|
|
|
|
// Ejecutar migraciones con el Migrator; si no deja el esquema listo, aplicar fallback.
|
|
let migratorError: unknown = null;
|
|
try {
|
|
Migrator.migrateToLatest(instance, { withBackup: false, allowBaseline: false });
|
|
} catch (e) {
|
|
migratorError = e;
|
|
console.error('[initializeDatabase] Error al aplicar migraciones con Migrator:', e);
|
|
}
|
|
|
|
// Verificación mínima: si las tablas base no existen, aplicar fallback secuencial.
|
|
const tableExists = (name: string): boolean => {
|
|
try {
|
|
const row = instance
|
|
.query(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)
|
|
.get(name) as any;
|
|
return Boolean(row && row.name === name);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const needsFallback =
|
|
!tableExists('users') ||
|
|
!tableExists('tasks') ||
|
|
!tableExists('response_queue');
|
|
|
|
if (needsFallback) {
|
|
console.warn('[initializeDatabase] Migrator no dejó el esquema listo; aplicando fallback de migraciones secuenciales');
|
|
try {
|
|
instance.transaction(() => {
|
|
try { instance.exec(`PRAGMA foreign_keys = ON;`); } catch {}
|
|
for (const m of migrations) {
|
|
m.up(instance);
|
|
}
|
|
})();
|
|
} catch (fallbackErr) {
|
|
console.error('[initializeDatabase] Fallback de migraciones falló:', fallbackErr);
|
|
throw fallbackErr;
|
|
}
|
|
} else if (migratorError) {
|
|
// Si el Migrator falló pero el esquema ya está correcto, sólo loggeamos.
|
|
console.warn('[initializeDatabase] Migrator reportó error, pero el esquema parece estar correcto. Continuando.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures a user exists in the database based on their raw WhatsApp ID.
|
|
* If the user exists, updates their last_seen timestamp.
|
|
* If the user does not exist, creates them.
|
|
* Uses the normalizeWhatsAppId utility.
|
|
* Stores timestamps with millisecond precision.
|
|
*
|
|
* @param rawUserId The raw WhatsApp ID (e.g., '12345@s.whatsapp.net').
|
|
* @param instance The database instance to use (defaults to the main db).
|
|
* @returns The normalized user ID if successful, otherwise null.
|
|
*/
|
|
export function ensureUserExists(rawUserId: string | null | undefined, instance: Database = db): string | null {
|
|
const normalizedId = normalizeWhatsAppId(rawUserId);
|
|
|
|
if (!normalizedId) {
|
|
console.error(`[ensureUserExists] Could not normalize or invalid user ID provided: ${rawUserId}`);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Use strftime for millisecond precision timestamps
|
|
const insertStmt = instance.prepare(`
|
|
INSERT INTO users (id, first_seen, last_seen)
|
|
VALUES (?, strftime('%Y-%m-%d %H:%M:%f', 'now'), strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
ON CONFLICT(id) DO NOTHING;
|
|
`);
|
|
|
|
const updateStmt = instance.prepare(`
|
|
UPDATE users
|
|
SET last_seen = strftime('%Y-%m-%d %H:%M:%f', 'now')
|
|
WHERE id = ?;
|
|
`);
|
|
|
|
// Run as transaction for atomicity
|
|
instance.transaction(() => {
|
|
insertStmt.run(normalizedId);
|
|
// Update last_seen even if the user was just inserted or already existed
|
|
updateStmt.run(normalizedId);
|
|
})(); // Immediately invoke the transaction
|
|
|
|
return normalizedId;
|
|
|
|
} catch (error) {
|
|
console.error(`[ensureUserExists] Database error for user ID ${normalizedId}:`, error);
|
|
return null;
|
|
}
|
|
}
|