数据层可插拔(SQLite/MariaDB) + 后端 services 骨架

- database.ts 改为按 DB_TYPE 切换 SQLite / MariaDB(mysql2),sqlite3 按需动态导入
- index.ts 改为初始化数据库后再监听端口
- 新增 AuthService / HealthService / AnalysisService / GarminService
- 新增 .gitignore、server/.env(忽略) 与 .env.example
- README 增加数据库配置说明;package.json 增加 mysql2/@types
This commit is contained in:
ericwyuan
2026-08-23 11:58:07 +08:00
parent d73405decb
commit 6b05d04773
11 changed files with 22362 additions and 122 deletions

View File

@@ -20,9 +20,6 @@ app.use(cors({
credentials: true
}));
// Initialize database
initializeDatabase();
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/garmin', garminRoutes);
@@ -37,6 +34,14 @@ app.get('/api/health/status', (req, res) => {
// Error handling
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
// Initialize database, then start accepting requests
initializeDatabase()
.then(() => {
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
})
.catch((err) => {
console.error('[db] Failed to initialize database:', err);
process.exit(1);
});

View File

@@ -0,0 +1,130 @@
import { allAsync } from '../utils/database';
import { getSummary } from './HealthService';
const METRIC_COLUMNS: Record<string, string> = {
steps: 'steps',
heart_rate: 'heart_rate',
sleep_duration: 'sleep_duration',
sleep_quality: 'sleep_quality',
stress: 'stress',
calories_burned: 'calories_burned',
};
export async function getTrends(metric: string, userId: string, startDate?: string, endDate?: string) {
const column = METRIC_COLUMNS[metric] || 'steps';
const params: any[] = [userId];
let sql = 'WHERE user_id = ?';
if (startDate) { sql += ' AND date >= ?'; params.push(startDate); }
if (endDate) { sql += ' AND date <= ?'; params.push(endDate); }
const rows = await allAsync(
`SELECT date, ${column} AS value FROM health_data ${sql}
AND ${column} IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({ date: r.date, value: r.value }));
}
export interface Recommendation {
id: string;
category: string;
recommendation: string;
priority: 'high' | 'medium' | 'low';
basedOn: string[];
}
export async function getRecommendations(userId: string): Promise<Recommendation[]> {
// Look at the most recent 14 days of data.
const recent = await getSummary(userId);
const last14 = recent.slice(-14);
const recs: Recommendation[] = [];
if (last14.length === 0) {
return [
{
id: 'no-data',
category: '数据',
recommendation: '暂无健康数据,请先同步你的 Garmin 设备数据。',
priority: 'low',
basedOn: [],
},
];
}
const avg = (key: string) =>
last14.reduce((sum, r) => sum + (r[key] ?? 0), 0) / last14.length;
const avgSteps = avg('steps');
const avgSleep = last14.filter((r) => r.sleep).reduce(
(s, r) => s + (r.sleep?.duration ?? 0),
0
) / Math.max(1, last14.filter((r) => r.sleep).length);
const avgStress = avg('stress');
const avgRestingHr = avg('heartRate');
const avgHrv = avg('heartRateVariability');
if (avgSteps > 0 && avgSteps < 8000) {
recs.push({
id: 'steps',
category: '运动',
recommendation: `${last14.length} 天日均步数约 ${Math.round(avgSteps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。`,
priority: 'medium',
basedOn: ['steps'],
});
}
if (avgSleep > 0 && avgSleep < 7) {
recs.push({
id: 'sleep',
category: '睡眠',
recommendation: `日均睡眠约 ${avgSleep.toFixed(1)} 小时,偏少。建议固定就寝时间,目标 7-8 小时。`,
priority: 'high',
basedOn: ['sleep_duration'],
});
}
if (avgStress > 0 && avgStress > 50) {
recs.push({
id: 'stress',
category: '压力',
recommendation: `平均压力指数 ${Math.round(avgStress)} 偏高,建议安排放松活动(冥想/散步)。`,
priority: 'high',
basedOn: ['stress'],
});
}
if (avgRestingHr > 0 && avgRestingHr > 65) {
recs.push({
id: 'rhr',
category: '心肺',
recommendation: `静息心率约 ${Math.round(avgRestingHr)} bpm 偏高,规律有氧运动有助于改善心肺功能。`,
priority: 'medium',
basedOn: ['heart_rate'],
});
}
if (avgHrv > 0 && avgHrv < 40) {
recs.push({
id: 'hrv',
category: '恢复',
recommendation: `心率变异性HRV${Math.round(avgHrv)} ms 偏低,注意恢复与休息,避免过度训练。`,
priority: 'low',
basedOn: ['heart_rate_variability'],
});
}
if (recs.length === 0) {
recs.push({
id: 'good',
category: '状态',
recommendation: '近期各项指标良好,保持当前作息与运动习惯即可。',
priority: 'low',
basedOn: [],
});
}
// Sort by priority
const order = { high: 0, medium: 1, low: 2 } as const;
recs.sort((a, b) => order[a.priority] - order[b.priority]);
return recs;
}

View File

@@ -0,0 +1,89 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { runAsync, getAsync } from '../utils/database';
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me';
const TOKEN_EXPIRY = '7d';
export class AuthError extends Error {
constructor(public code: string, message: string) {
super(message);
this.name = 'AuthError';
}
}
function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16).toString('hex');
crypto.scrypt(password, salt, 64, (err, derived) => {
if (err) reject(err);
else resolve(`${salt}:${derived.toString('hex')}`);
});
});
}
function verifyPassword(password: string, stored: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const [salt, hash] = stored.split(':');
if (!salt || !hash) return resolve(false);
crypto.scrypt(password, salt, 64, (err, derived) => {
if (err) reject(err);
else resolve(derived.toString('hex') === hash);
});
});
}
function signToken(userId: string): string {
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
}
export async function register(input: {
email: string;
garminEmail: string;
garminPassword: string;
}) {
const existing = await getAsync('SELECT id FROM users WHERE email = ?', [input.email]);
if (existing) {
throw new AuthError('EMAIL_TAKEN', '该邮箱已注册');
}
const id = crypto.randomUUID();
const garminPasswordHash = await hashPassword(input.garminPassword);
const token = signToken(id);
await runAsync(
`INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token)
VALUES (?, ?, ?, ?, ?)`,
[id, input.email, input.garminEmail, garminPasswordHash, token]
);
return { id, email: input.email, token };
}
export async function login(email: string, password: string) {
const user = await getAsync('SELECT * FROM users WHERE email = ?', [email]);
if (!user) {
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
}
const ok = await verifyPassword(password, user.garmin_password_hash);
if (!ok) {
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
}
const token = signToken(user.id);
await runAsync('UPDATE users SET jwt_token = ? WHERE id = ?', [token, user.id]);
return { id: user.id, email: user.email, token };
}
export async function logout(userId: string) {
await runAsync('UPDATE users SET jwt_token = NULL WHERE id = ?', [userId]);
}
export async function getUserById(userId: string) {
const user = await getAsync(
'SELECT id, email, garmin_email, created_at FROM users WHERE id = ?',
[userId]
);
return user || null;
}
export function verifyToken(token: string): { userId: string } {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
return { userId: payload.sub };
}

View File

@@ -0,0 +1,121 @@
import { getAsync, runAsync } from '../utils/database';
import { upsertHealthDaily, insertActivity } from './HealthService';
export interface SyncResult {
status: 'success' | 'error';
recordsSynced: number;
message: string;
lastSyncTime: string;
}
export async function syncData(
userId: string,
creds: { garminEmail: string; garminPassword: string }
): Promise<SyncResult> {
const now = new Date().toISOString();
await runAsync(
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
VALUES (?, 'syncing', ?, 0)
ON DUPLICATE KEY UPDATE status='syncing', last_sync_time=?, records_synced=0`,
[userId, now, now]
);
try {
let GarminConnect: any;
try {
GarminConnect = require('garmin-connect');
} catch {
throw new Error('GARMIN_LIB_MISSING: 请先运行 `npm install garmin-connect` 以启用同步');
}
const Client = GarminConnect.default || GarminConnect.GarminConnect || GarminConnect;
const client = new Client({ username: creds.garminEmail, password: creds.garminPassword });
await client.login();
let recordsSynced = 0;
for (let i = 0; i < 7; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().slice(0, 10);
try {
const daily =
(await client.getUserDailySummary?.(dateStr)) ||
(await client.getDailySummary?.(dateStr)) ||
(await client.getStats?.(dateStr));
if (daily) {
const sleepSec = daily.sleep?.sleepingSeconds ?? daily.sleepingSeconds;
await upsertHealthDaily(userId, {
date: dateStr,
steps: daily.steps ?? null,
heartRate: daily.restingHeartRate ?? daily.averageHeartRate ?? null,
heartRateVariability: daily.hrv ?? daily.heartRateVariability ?? null,
sleepDuration: sleepSec ? Math.round(sleepSec / 3600 * 10) / 10 : null,
sleepQuality: daily.sleep?.sleepQuality ?? null,
stress: daily.stress?.average ?? daily.averageStress ?? null,
caloriesBurned: daily.calories?.total ?? daily.totalCalories ?? null,
});
recordsSynced++;
}
const activities =
(await client.getActivities?.(dateStr)) ||
(await client.getActivitiesByDate?.(dateStr)) ||
[];
for (const a of activities || []) {
const start = a.startTimeLocal || a.startTime;
const startMs = start ? new Date(start).getTime() : NaN;
await insertActivity(userId, {
activityType: a.activityType?.typeKey || a.type || 'unknown',
startTime: start,
endTime:
isNaN(startMs) || !a.duration ? start : new Date(startMs + (a.duration || 0) * 1000).toISOString(),
duration: a.duration ?? null,
distance: a.distance ?? null,
calories: a.calories ?? null,
heartRateAverage: a.averageHR ?? null,
heartRateMax: a.maxHR ?? null,
});
}
} catch {
// skip a single bad day and continue
continue;
}
}
await runAsync(
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
VALUES (?, 'idle', ?, ?)
ON DUPLICATE KEY UPDATE status='idle', last_sync_time=?, records_synced=?`,
[userId, now, recordsSynced, now, recordsSynced]
);
return {
status: 'success',
recordsSynced,
message: `同步完成,新增/更新 ${recordsSynced} 天数据`,
lastSyncTime: now,
};
} catch (err: any) {
const message = err?.message || String(err);
await runAsync(
`INSERT INTO sync_status (user_id, status, last_sync_time, last_error, records_synced)
VALUES (?, 'error', ?, ?, 0)
ON DUPLICATE KEY UPDATE status='error', last_sync_time=?, last_error=?, records_synced=0`,
[userId, now, message, now, message]
);
return { status: 'error', recordsSynced: 0, message, lastSyncTime: now };
}
}
export async function getSyncStatus(userId: string) {
const row = await getAsync('SELECT * FROM sync_status WHERE user_id = ?', [userId]);
if (!row) return { status: 'idle', lastSyncTime: null, recordsSynced: 0, lastError: null };
return {
status: row.status,
lastSyncTime: row.last_sync_time,
recordsSynced: row.records_synced,
lastError: row.last_error,
};
}

View File

@@ -0,0 +1,134 @@
import crypto from 'crypto';
import { allAsync, runAsync } from '../utils/database';
interface DateRange {
startDate?: string;
endDate?: string;
}
function rangeParams(userId: string, range?: DateRange): { sql: string; params: any[] } {
const params: any[] = [userId];
let sql = 'WHERE user_id = ?';
if (range?.startDate) {
sql += ' AND date >= ?';
params.push(range.startDate);
}
if (range?.endDate) {
sql += ' AND date <= ?';
params.push(range.endDate);
}
return { sql, params };
}
export async function getSummary(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, steps, heart_rate, heart_rate_variability,
sleep_duration, sleep_quality, stress, calories_burned
FROM health_data ${sql} ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({
date: r.date,
steps: r.steps,
heartRate: r.heart_rate,
heartRateVariability: r.heart_rate_variability,
sleep: r.sleep_duration != null ? { duration: r.sleep_duration, quality: r.sleep_quality } : null,
stress: r.stress,
caloriesBurned: r.calories_burned,
}));
}
export async function getSteps(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, steps FROM health_data ${sql} AND steps IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({ date: r.date, steps: r.steps }));
}
export async function getHeartRate(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, heart_rate, heart_rate_variability FROM health_data ${sql}
AND heart_rate IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({
date: r.date,
heartRate: r.heart_rate,
heartRateVariability: r.heart_rate_variability,
}));
}
export async function getSleep(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, sleep_duration, sleep_quality FROM health_data ${sql}
AND sleep_duration IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({
date: r.date,
duration: r.sleep_duration,
quality: r.sleep_quality,
}));
}
export async function getActivities(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT id, activity_type, start_time, end_time, duration, distance,
calories, heart_rate_average, heart_rate_max
FROM activities ${sql} ORDER BY start_time DESC`,
params
);
return rows;
}
// Used by GarminService to persist daily health records (upsert by user+date).
export async function upsertHealthDaily(userId: string, record: any) {
const id = `${userId}-${record.date}`;
await runAsync(
`INSERT INTO health_data
(id, user_id, date, steps, heart_rate, heart_rate_variability,
blood_pressure_systolic, blood_pressure_diastolic, sleep_duration,
sleep_quality, stress, calories_burned)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
steps = VALUES(steps),
heart_rate = VALUES(heart_rate),
heart_rate_variability = VALUES(heart_rate_variability),
blood_pressure_systolic = VALUES(blood_pressure_systolic),
blood_pressure_diastolic = VALUES(blood_pressure_diastolic),
sleep_duration = VALUES(sleep_duration),
sleep_quality = VALUES(sleep_quality),
stress = VALUES(stress),
calories_burned = VALUES(calories_burned),
updated_at = CURRENT_TIMESTAMP`,
[
id, userId, record.date, record.steps ?? null, record.heartRate ?? null,
record.heartRateVariability ?? null, record.bloodPressureSystolic ?? null,
record.bloodPressureDiastolic ?? null, record.sleepDuration ?? null,
record.sleepQuality ?? null, record.stress ?? null, record.caloriesBurned ?? null,
]
);
return id;
}
export async function insertActivity(userId: string, activity: any) {
const id = crypto.randomUUID();
await runAsync(
`INSERT INTO activities
(id, user_id, activity_type, start_time, end_time, duration, distance,
calories, heart_rate_average, heart_rate_max)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id, userId, activity.activityType, activity.startTime, activity.endTime,
activity.duration ?? null, activity.distance ?? null, activity.calories ?? null,
activity.heartRateAverage ?? null, activity.heartRateMax ?? null,
]
);
return id;
}

View File

@@ -1,110 +1,224 @@
import sqlite3 from 'sqlite3';
import mysql from 'mysql2/promise';
import path from 'path';
import fs from 'fs';
const dbPath = process.env.DATABASE_PATH || './data/health.db';
const DB_TYPE = (process.env.DB_TYPE || 'sqlite').toLowerCase();
// Ensure data directory exists
const dataDir = path.dirname(dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
// ---------------------------------------------------------------------------
// SQLite (local / development)
// Loaded lazily so the MariaDB build never depends on the native sqlite3 module.
// ---------------------------------------------------------------------------
let sqliteDb: any = null;
const SQLITE_SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
garmin_email TEXT NOT NULL,
garmin_password_hash TEXT NOT NULL,
jwt_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS health_data (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
date DATE NOT NULL,
steps INTEGER,
heart_rate INTEGER,
heart_rate_variability REAL,
blood_pressure_systolic INTEGER,
blood_pressure_diastolic INTEGER,
sleep_duration INTEGER,
sleep_quality REAL,
stress INTEGER,
calories_burned REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, date)
);
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
activity_type TEXT NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
duration INTEGER,
distance REAL,
calories REAL,
heart_rate_average INTEGER,
heart_rate_max INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sync_status (
user_id TEXT PRIMARY KEY,
last_sync_time DATETIME,
status TEXT DEFAULT 'idle',
last_error TEXT,
records_synced INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`;
// ---------------------------------------------------------------------------
// MariaDB (production, runs on the NAS)
// NOTE: TEXT cannot be a PRIMARY KEY in MariaDB, so ids use VARCHAR(64).
// ---------------------------------------------------------------------------
let mariadbPool: mysql.Pool | null = null;
const MARIADB_SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
garmin_email VARCHAR(255) NOT NULL,
garmin_password_hash TEXT NOT NULL,
jwt_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS health_data (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
date DATE NOT NULL,
steps INT,
heart_rate INT,
heart_rate_variability DOUBLE,
blood_pressure_systolic INT,
blood_pressure_diastolic INT,
sleep_duration INT,
sleep_quality DOUBLE,
stress INT,
calories_burned DOUBLE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, date),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS activities (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
activity_type VARCHAR(255) NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
duration INT,
distance DOUBLE,
calories DOUBLE,
heart_rate_average INT,
heart_rate_max INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sync_status (
user_id VARCHAR(64) PRIMARY KEY,
last_sync_time DATETIME,
status VARCHAR(32) DEFAULT 'idle',
last_error TEXT,
records_synced INT DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`;
function getMariadbConfig(): mysql.PoolOptions {
const config: mysql.PoolOptions = {
user: process.env.MARIADB_USER || 'root',
password: process.env.MARIADB_PASSWORD || '',
database: process.env.MARIADB_DATABASE || 'garmin_health_lab',
connectionLimit: 10,
waitForConnections: true,
};
if (process.env.MARIADB_SOCKET) {
config.socketPath = process.env.MARIADB_SOCKET;
} else {
config.host = process.env.MARIADB_HOST || '127.0.0.1';
config.port = process.env.MARIADB_PORT ? Number(process.env.MARIADB_PORT) : 3306;
}
return config;
}
export const db = new sqlite3.Database(dbPath);
export function initializeDatabase() {
db.serialize(() => {
// Users table
db.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
garmin_email TEXT NOT NULL,
garmin_password_hash TEXT NOT NULL,
jwt_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Health data table
db.run(`
CREATE TABLE IF NOT EXISTS health_data (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
date DATE NOT NULL,
steps INTEGER,
heart_rate INTEGER,
heart_rate_variability REAL,
blood_pressure_systolic INTEGER,
blood_pressure_diastolic INTEGER,
sleep_duration INTEGER,
sleep_quality REAL,
stress INTEGER,
calories_burned REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, date)
)
`);
// Activities table
db.run(`
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
activity_type TEXT NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
duration INTEGER,
distance REAL,
calories REAL,
heart_rate_average INTEGER,
heart_rate_max INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Sync status table
db.run(`
CREATE TABLE IF NOT EXISTS sync_status (
user_id TEXT PRIMARY KEY,
last_sync_time DATETIME,
status TEXT DEFAULT 'idle',
last_error TEXT,
records_synced INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
console.log('Database initialized successfully');
});
export async function initializeDatabase(): Promise<void> {
if (DB_TYPE === 'mariadb') {
mariadbPool = mysql.createPool(getMariadbConfig());
const conn = await mariadbPool.getConnection();
try {
const statements = MARIADB_SCHEMA
.split(';')
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
await conn.query(stmt);
}
} finally {
conn.release();
}
console.log('[db] MariaDB database initialized successfully');
} else {
const sqlitePath = process.env.DATABASE_PATH || './data/health.db';
const dataDir = path.dirname(sqlitePath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sqlite3 = await import('sqlite3');
const SqliteDb = (sqlite3 as any).default?.Database || (sqlite3 as any).Database;
sqliteDb = new SqliteDb(sqlitePath);
await new Promise<void>((resolve, reject) => {
sqliteDb.serialize(() => {
sqliteDb.exec(SQLITE_SCHEMA, (err: Error | null) => {
if (err) reject(err);
else resolve();
});
});
});
console.log('[db] SQLite database initialized successfully');
}
}
export function runAsync(sql: string, params: any[] = []): Promise<any> {
// Unified query helpers — same signatures for both backends.
// `?` placeholders are supported by both sqlite3 and mysql2.
export async function runAsync(sql: string, params: any[] = []): Promise<any> {
if (DB_TYPE === 'mariadb') {
const [result] = await mariadbPool!.execute(sql, params);
const r = result as any;
return { id: r.insertId, changes: r.affectedRows };
}
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
sqliteDb.run(sql, params, function (this: any, err: Error | null) {
if (err) reject(err);
else resolve({ id: this.lastID, changes: this.changes });
});
});
}
export function getAsync(sql: string, params: any[] = []): Promise<any> {
export async function getAsync(sql: string, params: any[] = []): Promise<any> {
if (DB_TYPE === 'mariadb') {
const [rows] = await mariadbPool!.execute(sql, params);
return Array.isArray(rows) ? (rows as any[])[0] : undefined;
}
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
sqliteDb.get(sql, params, (err: Error | null, row: any) => {
if (err) reject(err);
else resolve(row);
});
});
}
export function allAsync(sql: string, params: any[] = []): Promise<any[]> {
export async function allAsync(sql: string, params: any[] = []): Promise<any[]> {
if (DB_TYPE === 'mariadb') {
const [rows] = await mariadbPool!.execute(sql, params);
return (rows as any[]) || [];
}
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
sqliteDb.all(sql, params, (err: Error | null, rows: any[]) => {
if (err) reject(err);
else resolve(rows || []);
});