2026-07-19 00:29:30 +08:00
import Database from "better-sqlite3" ;
import { mkdirSync } from "node:fs" ;
import { dirname } from "node:path" ;
const path = process . env . HUB_BUILD === "1" ? ":memory:" : ( process . env . DATABASE_PATH || "./data/hub.db" );
if ( path !== ":memory:" ) mkdirSync ( dirname ( path ), { recursive : true });
export const db = new Database ( path );
db . pragma ( "journal_mode = WAL" );
db . pragma ( "foreign_keys = ON" );
db . pragma ( "busy_timeout = 5000" );
db . exec ( `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, disabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, base_url TEXT NOT NULL, token_encrypted TEXT NOT NULL, remote_user TEXT,
webhook_supported INTEGER NOT NULL DEFAULT 0, sync_status TEXT NOT NULL DEFAULT 'pending', last_synced_at TEXT, last_error TEXT,
2026-07-19 01:12:16 +08:00
is_enabled INTEGER NOT NULL DEFAULT 1, disabled_at TEXT,
2026-07-19 03:30:19 +08:00
sync_tags_json TEXT NOT NULL DEFAULT '[]', sync_from TEXT, sync_to TEXT, sync_attachment_mode TEXT NOT NULL DEFAULT 'all',
remote_display_name TEXT, remote_avatar_url TEXT, last_connection_at TEXT, last_connection_error TEXT,
2026-07-19 00:29:30 +08:00
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, base_url)
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY, source_id INTEGER REFERENCES sources(id) ON DELETE SET NULL,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_memo_name TEXT, content TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'PUBLIC', tags_json TEXT NOT NULL DEFAULT '[]', attachments_json TEXT NOT NULL DEFAULT '[]',
origin TEXT NOT NULL DEFAULT 'memos', remote_created_at TEXT, remote_updated_at TEXT, sync_status TEXT NOT NULL DEFAULT 'synced',
2026-07-19 03:30:19 +08:00
remote_url TEXT,
2026-07-19 00:29:30 +08:00
hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source_id, remote_memo_name)
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, hidden INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS reactions (
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(post_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, reporter_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
reason TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, resolved INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sync_jobs (
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
kind TEXT NOT NULL, payload_json TEXT, status TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
2026-07-19 01:12:16 +08:00
trigger TEXT NOT NULL DEFAULT 'manual', last_error TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TEXT, finished_at TEXT, run_after TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-07-19 00:29:30 +08:00
);
CREATE TABLE IF NOT EXISTS source_members (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(source_id, user_id)
);
2026-07-19 03:16:18 +08:00
CREATE TABLE IF NOT EXISTS bookmarks (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'saved', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, post_id)
);
CREATE TABLE IF NOT EXISTS reading_history (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
last_read_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, post_id)
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
type TEXT NOT NULL, message TEXT NOT NULL, read_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
2026-07-19 00:29:30 +08:00
CREATE INDEX IF NOT EXISTS posts_public_idx ON posts(visibility, hidden, created_at DESC);
CREATE INDEX IF NOT EXISTS sync_jobs_idx ON sync_jobs(status, run_after);
2026-07-19 03:16:18 +08:00
CREATE INDEX IF NOT EXISTS notifications_user_idx ON notifications(user_id, read_at, created_at DESC);
2026-07-19 00:29:30 +08:00
CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_url, remote_user) WHERE remote_user IS NOT NULL;
2026-07-19 01:12:16 +08:00
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
2026-07-19 03:58:03 +08:00
CREATE TABLE IF NOT EXISTS rate_limits (
bucket TEXT PRIMARY KEY, count INTEGER NOT NULL, reset_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS error_events (
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, message TEXT NOT NULL, context_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
2026-07-19 05:45:42 +08:00
CREATE TABLE IF NOT EXISTS source_invites (
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
token_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', expires_at TEXT NOT NULL,
used_at TEXT, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
2026-07-19 04:15:28 +08:00
CREATE TABLE IF NOT EXISTS tag_aliases (
alias TEXT PRIMARY KEY COLLATE NOCASE, canonical TEXT NOT NULL COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
2026-07-19 00:29:30 +08:00
` );
db . exec ( "INSERT OR IGNORE INTO source_members(source_id,user_id,role) SELECT id,user_id,'owner' FROM sources" );
2026-07-19 01:12:16 +08:00
function applyColumnMigration ( version : number , table : string , column : string , sql : string ) {
const columns = db . prepare ( `PRAGMA table_info( ${ table } )` ). all () as { name : string }[];
if ( ! columns . some (( item ) => item . name === column )) db . exec ( sql );
db . prepare ( "INSERT OR IGNORE INTO schema_migrations(version) VALUES(?)" ). run ( version );
2026-07-19 00:29:30 +08:00
}
2026-07-19 01:12:16 +08:00
applyColumnMigration ( 1 , "sources" , "webhook_secret_hash" , "ALTER TABLE sources ADD COLUMN webhook_secret_hash TEXT" );
applyColumnMigration ( 2 , "sources" , "last_webhook_at" , "ALTER TABLE sources ADD COLUMN last_webhook_at TEXT" );
applyColumnMigration ( 3 , "sources" , "is_enabled" , "ALTER TABLE sources ADD COLUMN is_enabled INTEGER NOT NULL DEFAULT 1" );
applyColumnMigration ( 4 , "sources" , "disabled_at" , "ALTER TABLE sources ADD COLUMN disabled_at TEXT" );
applyColumnMigration ( 5 , "sync_jobs" , "trigger" , "ALTER TABLE sync_jobs ADD COLUMN trigger TEXT NOT NULL DEFAULT 'manual'" );
applyColumnMigration ( 6 , "sync_jobs" , "started_at" , "ALTER TABLE sync_jobs ADD COLUMN started_at TEXT" );
applyColumnMigration ( 7 , "sync_jobs" , "finished_at" , "ALTER TABLE sync_jobs ADD COLUMN finished_at TEXT" );
2026-07-19 03:16:18 +08:00
db . prepare ( "INSERT OR IGNORE INTO schema_migrations(version) VALUES(8)" ). run ();
2026-07-19 03:30:19 +08:00
applyColumnMigration ( 9 , "sources" , "sync_tags_json" , "ALTER TABLE sources ADD COLUMN sync_tags_json TEXT NOT NULL DEFAULT '[]'" );
applyColumnMigration ( 10 , "sources" , "sync_from" , "ALTER TABLE sources ADD COLUMN sync_from TEXT" );
applyColumnMigration ( 11 , "sources" , "sync_to" , "ALTER TABLE sources ADD COLUMN sync_to TEXT" );
applyColumnMigration ( 12 , "sources" , "sync_attachment_mode" , "ALTER TABLE sources ADD COLUMN sync_attachment_mode TEXT NOT NULL DEFAULT 'all'" );
applyColumnMigration ( 13 , "sources" , "remote_display_name" , "ALTER TABLE sources ADD COLUMN remote_display_name TEXT" );
applyColumnMigration ( 14 , "sources" , "remote_avatar_url" , "ALTER TABLE sources ADD COLUMN remote_avatar_url TEXT" );
applyColumnMigration ( 15 , "sources" , "last_connection_at" , "ALTER TABLE sources ADD COLUMN last_connection_at TEXT" );
applyColumnMigration ( 16 , "sources" , "last_connection_error" , "ALTER TABLE sources ADD COLUMN last_connection_error TEXT" );
applyColumnMigration ( 17 , "posts" , "remote_url" , "ALTER TABLE posts ADD COLUMN remote_url TEXT" );
2026-07-19 03:58:03 +08:00
db . prepare ( "INSERT OR IGNORE INTO schema_migrations(version) VALUES(18)" ). run ();
db . prepare ( "INSERT OR IGNORE INTO schema_migrations(version) VALUES(19)" ). run ();
2026-07-19 04:15:28 +08:00
applyColumnMigration ( 20 , "sources" , "attachment_storage_mode" , "ALTER TABLE sources ADD COLUMN attachment_storage_mode TEXT NOT NULL DEFAULT 'remote'" );
applyColumnMigration ( 21 , "sources" , "attachment_cache_limit_bytes" , "ALTER TABLE sources ADD COLUMN attachment_cache_limit_bytes INTEGER NOT NULL DEFAULT 104857600" );
applyColumnMigration ( 22 , "sources" , "attachment_cache_error" , "ALTER TABLE sources ADD COLUMN attachment_cache_error TEXT" );
db . prepare ( "INSERT OR IGNORE INTO schema_migrations(version) VALUES(23)" ). run ();
2026-07-19 05:14:01 +08:00
applyColumnMigration ( 24 , "sources" , "webhook_mode" , "ALTER TABLE sources ADD COLUMN webhook_mode TEXT NOT NULL DEFAULT 'manual'" );
applyColumnMigration ( 25 , "sources" , "webhook_remote_name" , "ALTER TABLE sources ADD COLUMN webhook_remote_name TEXT" );
applyColumnMigration ( 26 , "sources" , "webhook_signing_secret_encrypted" , "ALTER TABLE sources ADD COLUMN webhook_signing_secret_encrypted TEXT" );
2026-07-19 05:27:46 +08:00
applyColumnMigration ( 27 , "sources" , "integration_type" , "ALTER TABLE sources ADD COLUMN integration_type TEXT NOT NULL DEFAULT 'memos'" );
applyColumnMigration ( 28 , "sources" , "rss_feed_url" , "ALTER TABLE sources ADD COLUMN rss_feed_url TEXT" );
2026-07-19 05:43:03 +08:00
applyColumnMigration ( 29 , "sources" , "sync_batch_size" , "ALTER TABLE sources ADD COLUMN sync_batch_size INTEGER NOT NULL DEFAULT 100" );
applyColumnMigration ( 30 , "sources" , "sync_max_posts" , "ALTER TABLE sources ADD COLUMN sync_max_posts INTEGER" );
applyColumnMigration ( 31 , "sources" , "sync_cursor" , "ALTER TABLE sources ADD COLUMN sync_cursor TEXT" );
applyColumnMigration ( 32 , "sources" , "sync_imported_count" , "ALTER TABLE sources ADD COLUMN sync_imported_count INTEGER NOT NULL DEFAULT 0" );
applyColumnMigration ( 33 , "sources" , "sync_run_id" , "ALTER TABLE sources ADD COLUMN sync_run_id TEXT" );
applyColumnMigration ( 34 , "posts" , "last_seen_sync_run" , "ALTER TABLE posts ADD COLUMN last_seen_sync_run TEXT" );
2026-07-19 05:45:42 +08:00
db . prepare ( "INSERT OR IGNORE INTO schema_migrations(version) VALUES(35)" ). run ();
db . prepare ( "UPDATE source_members SET role='editor' WHERE role='member'" ). run ();
db . prepare ( "INSERT OR IGNORE INTO schema_migrations(version) VALUES(36)" ). run ();
2026-07-19 01:12:16 +08:00
2026-07-19 00:29:30 +08:00
const admin = process . env . ADMIN_USERNAME ;
const adminPassword = process . env . ADMIN_PASSWORD ;
if ( admin && adminPassword ) {
// Keep bootstrap safe when Next preloads multiple route modules concurrently.
const bcrypt = require ( "bcryptjs" );
db . prepare ( "INSERT OR IGNORE INTO users(username, password_hash, role) VALUES (?, ?, 'admin')" ). run ( admin , bcrypt . hashSync ( adminPassword , 12 ));
}
const seedUrl = process . env . SEED_MEMOS_URL ? . replace ( /\/$/ , "" );
const seedToken = process . env . SEED_MEMOS_TOKEN ;
if ( admin && seedUrl && seedToken ) {
const user = db . prepare ( "SELECT id FROM users WHERE username=?" ). get ( admin ) as { id : number } | undefined ;
const source = db . prepare ( "SELECT id FROM sources WHERE user_id=? AND base_url=?" ). get ( user ? . id , seedUrl ) as { id : number } | undefined ;
if ( user && ! source ) {
const { encrypt } = require ( "./crypto" ) as typeof import ( "./crypto" );
db . prepare ( "INSERT OR IGNORE INTO sources(user_id,name,base_url,token_encrypted,sync_status) VALUES(?,?,?,?, 'queued')" ). run ( user . id , process . env . SEED_MEMOS_NAME || "Initial Memos" , seedUrl , encrypt ( seedToken ));
const inserted = db . prepare ( "SELECT id FROM sources WHERE user_id=? AND base_url=?" ). get ( user . id , seedUrl ) as { id : number };
db . prepare ( "INSERT INTO sync_jobs(source_id,kind) SELECT ?, 'pull' WHERE NOT EXISTS (SELECT 1 FROM sync_jobs WHERE source_id=? AND kind='pull' AND status IN ('queued','running'))" ). run ( inserted . id , inserted . id );
}
}