feat: support concurrent Reddit accounts

This commit is contained in:
jjbrunton committed 2026-08-08 19:00:38 +01:00
1 parent d819a0f1e6
commit 16a31335d0
51 files changed
+3241 -250

No files matched your search

+264 -4
View File
@@ -3,6 +3,8 @@ import type { Config } from '../config/index.js';
import path from 'path';
import fs from 'fs';
export const LEGACY_UNASSIGNED_ACCOUNT_ID = '__legacy_unassigned__';
export function createDatabase(config: Pick<Config, 'database'>): Database.Database {
const dbPath = config.database.path === ':memory:'
? ':memory:'
@@ -198,6 +200,7 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_sent_messages_image ON sent_messages(got_image);
`);
},
() => migrateAccountOwnership(db),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
@@ -207,14 +210,271 @@ export function runMigrations(db: Database.Database): void {
for (let index = currentVersion; index < migrations.length; index += 1) {
const version = index + 1;
db.transaction(() => {
migrations[index]();
db.pragma(`user_version = ${version}`);
})();
// SQLite cannot rebuild a referenced table while FK enforcement is on.
// Migration 11 replaces the account-produced table graph atomically, then
// validates it before foreign-key enforcement is restored.
const rebuildsForeignKeyGraph = version === 11;
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
try {
db.transaction(() => {
migrations[index]();
if (rebuildsForeignKeyGraph) {
const violations = db.pragma('foreign_key_check') as unknown[];
if (violations.length > 0) {
throw new Error(`Account ownership migration left ${violations.length} foreign-key violation(s)`);
}
}
db.pragma(`user_version = ${version}`);
})();
} finally {
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = ON');
}
currentVersion = version;
}
}
function migrateAccountOwnership(db: Database.Database): void {
const legacy = LEGACY_UNASSIGNED_ACCOUNT_ID.replaceAll("'", "''");
db.exec(`
CREATE TABLE reddit_accounts (
account_id TEXT PRIMARY KEY,
reddit_username TEXT NOT NULL,
matrix_user_id TEXT UNIQUE,
status TEXT NOT NULL CHECK(status IN ('verified', 'login-required', 'disabled', 'legacy-unassigned')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)),
cookies_json TEXT NOT NULL,
matrix_credentials_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_verified_at INTEGER,
last_imported_at INTEGER
);
CREATE INDEX idx_reddit_accounts_username ON reddit_accounts(reddit_username COLLATE NOCASE);
CREATE INDEX idx_reddit_accounts_active ON reddit_accounts(is_active, status);
INSERT INTO reddit_accounts (
account_id, reddit_username, matrix_user_id, status, is_active,
cookies_json, matrix_credentials_json, created_at, updated_at
) VALUES (
'${legacy}', 'Legacy / unassigned', NULL, 'legacy-unassigned', 0,
'[]', '{}', 0, 0
);
CREATE TABLE processed_posts_account_scoped (
account_id TEXT NOT NULL DEFAULT '${legacy}',
id TEXT NOT NULL,
subreddit TEXT NOT NULL,
author TEXT NOT NULL,
title TEXT NOT NULL,
image_url TEXT,
image_analysis TEXT,
generated_message TEXT,
processed_at INTEGER NOT NULL,
message_sent INTEGER DEFAULT 0,
error TEXT,
post_url TEXT,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id)
);
INSERT INTO processed_posts_account_scoped (
account_id, id, subreddit, author, title, image_url, image_analysis,
generated_message, processed_at, message_sent, error, post_url
) SELECT '${legacy}', id, subreddit, author, title, image_url, image_analysis,
generated_message, processed_at, message_sent, error, post_url
FROM processed_posts;
CREATE TABLE sent_messages_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
post_id TEXT,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
sent_at INTEGER NOT NULL,
got_response INTEGER DEFAULT 0,
got_image INTEGER NOT NULL DEFAULT 0,
response_checked_at INTEGER,
strategy_id TEXT,
chat_room_id TEXT,
UNIQUE (account_id, id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, post_id) REFERENCES processed_posts_account_scoped(account_id, id)
);
INSERT INTO sent_messages_account_scoped (
id, account_id, post_id, recipient, subject, message, sent_at,
got_response, got_image, response_checked_at, strategy_id, chat_room_id
) SELECT id, '${legacy}', post_id, recipient, subject, message, sent_at,
got_response, got_image, response_checked_at, strategy_id, chat_room_id
FROM sent_messages;
CREATE TABLE successful_messages_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
sent_message_id INTEGER,
original_message TEXT NOT NULL,
image_analysis TEXT,
post_title TEXT,
response_snippet TEXT,
created_at INTEGER NOT NULL,
subreddit TEXT,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, sent_message_id) REFERENCES sent_messages_account_scoped(account_id, id)
);
INSERT INTO successful_messages_account_scoped (
id, account_id, sent_message_id, original_message, image_analysis,
post_title, response_snippet, created_at, subreddit
) SELECT id, '${legacy}', sent_message_id, original_message, image_analysis,
post_title, response_snippet, created_at, subreddit
FROM successful_messages;
CREATE TABLE activity_log_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
timestamp INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
recipient TEXT,
image_url TEXT,
post_title TEXT,
post_url TEXT,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id)
);
INSERT INTO activity_log_account_scoped (
id, account_id, timestamp, type, message, recipient, image_url, post_title, post_url
) SELECT id, '${legacy}', timestamp, type, message, recipient, image_url, post_title, post_url
FROM activity_log;
CREATE TABLE delivery_outbox_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
post_id TEXT NOT NULL,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
strategy_id TEXT,
idempotency_key TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('discovered', 'prepared', 'sending', 'sent', 'failed')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
lease_owner TEXT,
lease_expires_at INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
sent_message_id INTEGER,
UNIQUE (account_id, post_id),
UNIQUE (account_id, idempotency_key),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, post_id) REFERENCES processed_posts_account_scoped(account_id, id),
FOREIGN KEY (account_id, sent_message_id) REFERENCES sent_messages_account_scoped(account_id, id)
);
INSERT INTO delivery_outbox_account_scoped (
id, account_id, post_id, recipient, subject, message, strategy_id,
idempotency_key, status, created_at, updated_at, lease_owner,
lease_expires_at, attempt_count, last_error, sent_message_id
) SELECT id, '${legacy}', post_id, recipient, subject, message, strategy_id,
idempotency_key, status, created_at, updated_at, lease_owner,
lease_expires_at, attempt_count, last_error, sent_message_id
FROM delivery_outbox;
CREATE TABLE conversation_states_account_scoped (
account_id TEXT NOT NULL DEFAULT '${legacy}',
conversation_id TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, conversation_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id)
);
INSERT INTO conversation_states_account_scoped (account_id, conversation_id, state_json, updated_at)
SELECT '${legacy}', conversation_id, state_json, updated_at FROM conversation_states;
CREATE TABLE conversation_outcomes_account_scoped (
account_id TEXT NOT NULL DEFAULT '${legacy}',
sent_message_id INTEGER NOT NULL,
outcome TEXT NOT NULL CHECK(outcome IN ('engaged', 'declined', 'boundary', 'upsell', 'unknown')),
response_snippet TEXT,
recorded_at INTEGER NOT NULL,
PRIMARY KEY (account_id, sent_message_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, sent_message_id) REFERENCES sent_messages_account_scoped(account_id, id)
);
INSERT INTO conversation_outcomes_account_scoped (
account_id, sent_message_id, outcome, response_snippet, recorded_at
) SELECT '${legacy}', sent_message_id, outcome, response_snippet, recorded_at
FROM conversation_outcomes;
DROP TABLE conversation_outcomes;
DROP TABLE conversation_states;
DROP TABLE delivery_outbox;
DROP TABLE successful_messages;
DROP TABLE sent_messages;
DROP TABLE processed_posts;
DROP TABLE activity_log;
ALTER TABLE processed_posts_account_scoped RENAME TO processed_posts;
ALTER TABLE sent_messages_account_scoped RENAME TO sent_messages;
ALTER TABLE successful_messages_account_scoped RENAME TO successful_messages;
ALTER TABLE activity_log_account_scoped RENAME TO activity_log;
ALTER TABLE delivery_outbox_account_scoped RENAME TO delivery_outbox;
ALTER TABLE conversation_states_account_scoped RENAME TO conversation_states;
ALTER TABLE conversation_outcomes_account_scoped RENAME TO conversation_outcomes;
CREATE INDEX idx_posts_subreddit ON processed_posts(account_id, subreddit);
CREATE INDEX idx_posts_processed_at ON processed_posts(account_id, processed_at);
CREATE INDEX idx_posts_message_sent ON processed_posts(account_id, message_sent);
CREATE INDEX idx_posts_author_history
ON processed_posts(author COLLATE NOCASE, processed_at DESC, id DESC);
CREATE INDEX idx_sent_messages_response ON sent_messages(account_id, got_response);
CREATE INDEX idx_sent_messages_image ON sent_messages(account_id, got_image);
CREATE INDEX idx_sent_messages_recipient ON sent_messages(account_id, recipient);
CREATE INDEX idx_sent_messages_chat_room ON sent_messages(account_id, chat_room_id);
CREATE INDEX idx_activity_log_timestamp ON activity_log(account_id, timestamp);
CREATE INDEX idx_delivery_outbox_claim ON delivery_outbox(account_id, status, lease_expires_at, created_at);
CREATE TABLE reddit_conversations (
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
username TEXT NOT NULL,
room_name TEXT,
payload_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, conversation_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX idx_reddit_conversations_username
ON reddit_conversations(account_id, username COLLATE NOCASE);
CREATE INDEX idx_reddit_conversations_updated
ON reddit_conversations(account_id, updated_at DESC);
CREATE TABLE reddit_account_sync_state (
account_id TEXT PRIMARY KEY,
next_batch TEXT,
last_full_sync_at INTEGER,
last_delta_sync_at INTEGER,
cooldown_json TEXT,
updated_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE TABLE reddit_media (
account_id TEXT NOT NULL,
media_id TEXT NOT NULL,
chat_username TEXT,
chat_room_id TEXT,
sender TEXT NOT NULL,
timestamp INTEGER NOT NULL,
original_url TEXT NOT NULL,
file_name TEXT NOT NULL,
relative_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (account_id, media_id),
UNIQUE (account_id, relative_path),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX idx_reddit_media_timestamp ON reddit_media(account_id, timestamp DESC);
CREATE INDEX idx_reddit_media_room ON reddit_media(account_id, chat_room_id);
`);
}
function addColumnIfNotExists(db: Database.Database, table: string, column: string, definition: string): void {
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
const exists = columns.some((c) => c.name === column);