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:
NW
2026-06-22 10:02:57 +01:00
parent ce1b6003cb
commit a04e60d751
18 changed files with 172 additions and 46 deletions

View 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;