refactor(v2): extract session DB operations into src/db/session-db.ts
Move all raw SQL out of session-manager, delivery, and host-sweep into
a dedicated DB module. Make session schemas idempotent (IF NOT EXISTS)
so initSessionFolder always applies them. Revert the markdown
plain-text retry from 4c477ac.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
288
src/db/session-db.ts
Normal file
288
src/db/session-db.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* SQL operations on per-session inbound/outbound DBs.
|
||||
*
|
||||
* These are NOT the central app DB — they're the cross-mount SQLite files
|
||||
* shared between host and container. Callers own the connection lifecycle
|
||||
* (open-write-close per op). See session-manager.ts header for invariants.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './schema.js';
|
||||
|
||||
/** Apply the inbound or outbound schema to a DB file. Idempotent. */
|
||||
export function ensureSchema(dbPath: string, schema: 'inbound' | 'outbound'): void {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = DELETE');
|
||||
db.exec(schema === 'inbound' ? INBOUND_SCHEMA : OUTBOUND_SCHEMA);
|
||||
db.close();
|
||||
}
|
||||
|
||||
/** Open the inbound DB for a session (host reads/writes). */
|
||||
export function openInboundDb(dbPath: string): Database.Database {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = DELETE');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Open the outbound DB for a session (host reads only). */
|
||||
export function openOutboundDb(dbPath: string): Database.Database {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
db.pragma('busy_timeout = 5000');
|
||||
return db;
|
||||
}
|
||||
|
||||
export function upsertSessionRouting(
|
||||
db: Database.Database,
|
||||
routing: { channel_type: string | null; platform_id: string | null; thread_id: string | null },
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO session_routing (id, channel_type, platform_id, thread_id)
|
||||
VALUES (1, @channel_type, @platform_id, @thread_id)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
channel_type = excluded.channel_type,
|
||||
platform_id = excluded.platform_id,
|
||||
thread_id = excluded.thread_id`,
|
||||
).run(routing);
|
||||
}
|
||||
|
||||
export interface DestinationRow {
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
type: 'channel' | 'agent';
|
||||
channel_type: string | null;
|
||||
platform_id: string | null;
|
||||
agent_group_id: string | null;
|
||||
}
|
||||
|
||||
export function replaceDestinations(db: Database.Database, entries: DestinationRow[]): void {
|
||||
const tx = db.transaction((rows: DestinationRow[]) => {
|
||||
db.prepare('DELETE FROM destinations').run();
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES (@name, @display_name, @type, @channel_type, @platform_id, @agent_group_id)`,
|
||||
);
|
||||
for (const row of rows) stmt.run(row);
|
||||
});
|
||||
tx(entries);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// messages_in
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Next even seq number for host-owned inbound.db. */
|
||||
function nextEvenSeq(db: Database.Database): number {
|
||||
const maxSeq = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
|
||||
return maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2);
|
||||
}
|
||||
|
||||
export function insertMessage(
|
||||
db: Database.Database,
|
||||
message: {
|
||||
id: string;
|
||||
kind: string;
|
||||
timestamp: string;
|
||||
platformId: string | null;
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
content: string;
|
||||
processAfter: string | null;
|
||||
recurrence: string | null;
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence)
|
||||
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence)`,
|
||||
).run({
|
||||
...message,
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
|
||||
export function insertTask(
|
||||
db: Database.Database,
|
||||
task: {
|
||||
id: string;
|
||||
processAfter: string;
|
||||
recurrence: string | null;
|
||||
platformId: string | null;
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
content: string;
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES (@id, @seq, datetime('now'), 'pending', 0, @processAfter, @recurrence, 'task', @platformId, @channelType, @threadId, @content)`,
|
||||
).run({
|
||||
...task,
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'completed' WHERE id = ? AND kind = 'task' AND status IN ('pending', 'paused')",
|
||||
).run(taskId);
|
||||
}
|
||||
|
||||
export function pauseTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare("UPDATE messages_in SET status = 'paused' WHERE id = ? AND kind = 'task' AND status = 'pending'").run(
|
||||
taskId,
|
||||
);
|
||||
}
|
||||
|
||||
export function resumeTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare("UPDATE messages_in SET status = 'pending' WHERE id = ? AND kind = 'task' AND status = 'paused'").run(
|
||||
taskId,
|
||||
);
|
||||
}
|
||||
|
||||
export function countDueMessages(db: Database.Database): number {
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND (process_after IS NULL OR process_after <= datetime('now'))`,
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count;
|
||||
}
|
||||
|
||||
export function markMessageFailed(db: Database.Database, messageId: string): void {
|
||||
db.prepare("UPDATE messages_in SET status = 'failed' WHERE id = ?").run(messageId);
|
||||
}
|
||||
|
||||
export function retryWithBackoff(db: Database.Database, messageId: string, backoffSec: number): void {
|
||||
db.prepare(
|
||||
`UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`,
|
||||
).run(messageId);
|
||||
}
|
||||
|
||||
export function getMessageForRetry(
|
||||
db: Database.Database,
|
||||
messageId: string,
|
||||
status: string,
|
||||
): { id: string; tries: number } | undefined {
|
||||
return db.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?').get(messageId, status) as
|
||||
| { id: string; tries: number }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface RecurringMessage {
|
||||
id: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
recurrence: string;
|
||||
process_after: string | null;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
}
|
||||
|
||||
export function getCompletedRecurring(db: Database.Database): RecurringMessage[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL")
|
||||
.all() as RecurringMessage[];
|
||||
}
|
||||
|
||||
export function insertRecurrence(
|
||||
db: Database.Database,
|
||||
msg: RecurringMessage,
|
||||
newId: string,
|
||||
nextRun: string | null,
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content)
|
||||
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`,
|
||||
).run(newId, nextEvenSeq(db), msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content);
|
||||
}
|
||||
|
||||
export function clearRecurrence(db: Database.Database, messageId: string): void {
|
||||
db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(messageId);
|
||||
}
|
||||
|
||||
export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
|
||||
const completed = outDb
|
||||
.prepare("SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed')")
|
||||
.all() as Array<{ message_id: string }>;
|
||||
|
||||
if (completed.length === 0) return;
|
||||
|
||||
const updateStmt = inDb.prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'");
|
||||
inDb.transaction(() => {
|
||||
for (const { message_id } of completed) {
|
||||
updateStmt.run(message_id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function getStuckProcessingIds(outDb: Database.Database): string[] {
|
||||
return (
|
||||
outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{
|
||||
message_id: string;
|
||||
}>
|
||||
).map((r) => r.message_id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// messages_out (read-only from host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OutboundMessage {
|
||||
id: string;
|
||||
kind: string;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM messages_out
|
||||
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
|
||||
ORDER BY timestamp ASC`,
|
||||
)
|
||||
.all() as OutboundMessage[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delivered
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getDeliveredIds(db: Database.Database): Set<string> {
|
||||
return new Set(
|
||||
(db.prepare('SELECT message_out_id FROM delivered').all() as Array<{ message_out_id: string }>).map(
|
||||
(r) => r.message_out_id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function markDelivered(db: Database.Database, messageOutId: string, platformMessageId: string | null): void {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))",
|
||||
).run(messageOutId, platformMessageId ?? null);
|
||||
}
|
||||
|
||||
export function markDeliveryFailed(db: Database.Database, messageOutId: string): void {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', datetime('now'))",
|
||||
).run(messageOutId);
|
||||
}
|
||||
|
||||
/** Ensure the delivered table has columns added after initial schema. */
|
||||
export function migrateDeliveredTable(db: Database.Database): void {
|
||||
const cols = new Set(
|
||||
(db.prepare("PRAGMA table_info('delivered')").all() as Array<{ name: string }>).map((c) => c.name),
|
||||
);
|
||||
if (!cols.has('platform_message_id')) {
|
||||
db.prepare('ALTER TABLE delivered ADD COLUMN platform_message_id TEXT').run();
|
||||
}
|
||||
if (!cols.has('status')) {
|
||||
db.prepare("ALTER TABLE delivered ADD COLUMN status TEXT NOT NULL DEFAULT 'delivered'").run();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user