feat(state): replace in-memory Map with SQLite-backed stateService (#59)
- Create src/services/stateService.js with get/set/delete/has API - Create migration 004_user_states.js (chat_id PK, state_data JSON, updated_at) - TTL of 24 hours — expired states auto-deleted - Cleanup job runs every hour (setInterval) - Replace src/context/userStates.js Map with async stateService proxy - Add await to all 45 userStates.get/set/delete/has calls across 13 files - Add initStates() call in index.js startup sequence - All state survives bot restarts now 18 files changed, 172 insertions, 46 deletions
This commit is contained in:
103
src/services/stateService.js
Normal file
103
src/services/stateService.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import db from '../config/database.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export async function initStates() {
|
||||
if (initialized) return;
|
||||
|
||||
await db.runAsync(`
|
||||
CREATE TABLE IF NOT EXISTS user_states (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
state_data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
initialized = true;
|
||||
logger.info('user_states table initialized');
|
||||
|
||||
setInterval(cleanExpired, CLEANUP_INTERVAL_MS);
|
||||
cleanExpired();
|
||||
}
|
||||
|
||||
function serialize(value) {
|
||||
if (value === undefined) return null;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function deserialize(json) {
|
||||
if (!json) return undefined;
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(chatId) {
|
||||
const row = await db.getAsync(
|
||||
'SELECT state_data, updated_at FROM user_states WHERE chat_id = ?',
|
||||
[String(chatId)]
|
||||
);
|
||||
|
||||
if (!row) return undefined;
|
||||
|
||||
if (Date.now() - row.updated_at > TTL_MS) {
|
||||
await db.runAsync('DELETE FROM user_states WHERE chat_id = ?', [String(chatId)]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return deserialize(row.state_data);
|
||||
}
|
||||
|
||||
export async function set(chatId, value) {
|
||||
const data = serialize(value);
|
||||
const now = Date.now();
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT INTO user_states (chat_id, state_data, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(chat_id) DO UPDATE SET state_data = ?, updated_at = ?`,
|
||||
[String(chatId), data, now, data, now]
|
||||
);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function del(chatId) {
|
||||
await db.runAsync('DELETE FROM user_states WHERE chat_id = ?', [String(chatId)]);
|
||||
}
|
||||
|
||||
export async function has(chatId) {
|
||||
const row = await db.getAsync(
|
||||
'SELECT updated_at FROM user_states WHERE chat_id = ?',
|
||||
[String(chatId)]
|
||||
);
|
||||
|
||||
if (!row) return false;
|
||||
|
||||
if (Date.now() - row.updated_at > TTL_MS) {
|
||||
await db.runAsync('DELETE FROM user_states WHERE chat_id = ?', [String(chatId)]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function cleanExpired() {
|
||||
const cutoff = Date.now() - TTL_MS;
|
||||
const result = await db.runAsync(
|
||||
'DELETE FROM user_states WHERE updated_at < ?',
|
||||
[cutoff]
|
||||
);
|
||||
if (result.changes > 0) {
|
||||
logger.info({ expiredCount: result.changes }, 'Cleaned expired user states');
|
||||
}
|
||||
}
|
||||
|
||||
const userStates = { get, set, delete: del, has, initStates, cleanExpired };
|
||||
export default userStates;
|
||||
Reference in New Issue
Block a user