Files
backspace/packages/server/src/db/index.ts
T
Jannis Braun 72e07c1cc1 fix: persistent random Snowflake worker ID + clean reaction API
Two fixes addressing architectural review feedback:

1. Snowflake ID collisions: Replace process.pid-based worker ID with a
   cryptographically random value (0-1023) generated once at first boot
   and persisted to instance_settings.worker_id. Eliminates deterministic
   ID collisions between Docker instances that all run as PID 1.

2. Reaction API leak: Revert addReaction/removeReaction signatures to
   (messageId, emoji) — the store now resolves the channel internally by
   scanning its message cache, keeping routing logic out of the UI layer.
2026-03-03 00:03:29 +01:00

236 lines
7.4 KiB
TypeScript

import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { config } from '../config.js';
import * as schema from './schema.js';
import { runMigrations } from './migrate.js';
import { setWorkerId } from '../utils/snowflake.js';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
let sqlite: Database.Database;
function ensureDirectory(filePath: string): void {
const dir = dirname(filePath);
mkdirSync(dir, { recursive: true });
}
function createTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT,
password_hash TEXT NOT NULL,
avatar TEXT,
status TEXT DEFAULT 'offline',
custom_status TEXT,
home_instance TEXT,
replicated_instances TEXT DEFAULT '[]',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
owner_id TEXT NOT NULL REFERENCES users(id),
invite_code TEXT UNIQUE,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_members (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nickname TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (server_id, user_id)
);
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
topic TEXT,
position INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mimetype TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dm_channels (
id TEXT PRIMARY KEY,
owner_id TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dm_members (
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (dm_channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS dm_messages (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS friends (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, friend_id)
);
CREATE TABLE IF NOT EXISTS friend_requests (
id TEXT PRIMARY KEY,
from_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS reactions (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS dm_reactions (
id TEXT PRIMARY KEY,
dm_message_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(dm_message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#b9bbbe',
position INTEGER DEFAULT 0,
permissions TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS member_roles (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (server_id, user_id, role_id)
);
CREATE TABLE IF NOT EXISTS channel_overrides (
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
allow TEXT NOT NULL DEFAULT '0',
deny TEXT NOT NULL DEFAULT '0',
PRIMARY KEY (channel_id, target_type, target_id)
);
CREATE TABLE IF NOT EXISTS read_states (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id TEXT NOT NULL,
last_read_message_id TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, channel_id)
);
CREATE TABLE IF NOT EXISTS server_folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT,
color TEXT,
position INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_folder_members (
folder_id TEXT NOT NULL REFERENCES server_folders(id) ON DELETE CASCADE,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
PRIMARY KEY (folder_id, server_id)
);
CREATE TABLE IF NOT EXISTS instance_settings (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
instance_name TEXT DEFAULT 'Backspace',
worker_id INTEGER,
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
allowed_resolutions TEXT NOT NULL DEFAULT '540,720,1080',
allowed_framerates TEXT NOT NULL DEFAULT '30,45,60',
max_resolution INTEGER NOT NULL DEFAULT 1080,
max_framerate INTEGER NOT NULL DEFAULT 60,
updated_at INTEGER NOT NULL DEFAULT 0
);
`);
}
export function initDatabase() {
ensureDirectory(config.dbPath);
sqlite = new Database(config.dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
createTables(sqlite);
runMigrations(sqlite);
// Initialize Snowflake worker ID from persisted value (set by migration)
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
if (settings?.worker_id !== undefined && settings.worker_id !== null) {
setWorkerId(settings.worker_id);
console.log(`Snowflake worker ID: ${settings.worker_id}`);
} else {
throw new Error('Snowflake worker_id not found in instance_settings — migration failed');
}
console.log(`Database initialized at ${config.dbPath}`);
return drizzle(sqlite, { schema });
}
export type DB = ReturnType<typeof initDatabase>;
let db: DB;
export function getDb(): DB {
if (!db) {
db = initDatabase();
}
return db;
}
export function closeDatabase(): void {
if (sqlite) {
sqlite.close();
}
}
export { schema };