feat: support concurrent Reddit accounts
This commit is contained in:
1 parent
d819a0f1e6
commit
16a31335d0
51 files changed
+3241
-250
No files matched your search
+264
-4
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user