'use strict'; async function createStore(databaseUrl) { if (!databaseUrl) { throw new Error('DATABASE_URL is required. Run with docker compose so the PostgreSQL container is available.'); } const { Pool } = require('pg'); const pool = new Pool({ connectionString: databaseUrl }); // Bumped on every write that changes app_state.json, so the route layer can cache /api/state // and serve 304s until the data actually changes. let stateVersion = 1; const bumpState = () => { stateVersion++; }; await pool.query(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS app_state ( id INTEGER PRIMARY KEY CHECK (id = 1), schema INTEGER, json JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS collections ( subject_id TEXT PRIMARY KEY, json JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS runs ( id BIGSERIAL PRIMARY KEY, json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS history ( id BIGSERIAL PRIMARY KEY, subject_id TEXT, json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS history_subject_idx ON history(subject_id); `); // One-time migration: move the (huge) history array out of app_state.json into the history table, // so the main state row stays small and GET /api/state never pays to strip a 60+MB history blob. // Atomic: either history is fully moved and stripped, or nothing changes (no data loss). { const client = await pool.connect(); try { await client.query('BEGIN'); const probe = await client.query( `SELECT jsonb_typeof(json->'history') AS t, COALESCE(jsonb_array_length(json->'history'), 0) AS n FROM app_state WHERE id = 1 FOR UPDATE` ); const row = probe.rows[0]; if (row && row.t === 'array' && Number(row.n) > 0) { await client.query( `INSERT INTO history(subject_id, json, created_at) SELECT e->>'subject_id', e, COALESCE(NULLIF(e->>'created_at', '')::timestamptz, NOW()) FROM app_state, jsonb_array_elements(json->'history') e WHERE app_state.id = 1` ); await client.query(`UPDATE app_state SET json = json - 'history' WHERE id = 1`); console.log(`Migrated ${row.n} history entries from app_state.json into the history table.`); } await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } async function refreshCollectionsTable(client, collections) { await client.query('DELETE FROM collections'); for (const [id, row] of Object.entries(collections || {})) { await client.query( 'INSERT INTO collections(subject_id, json, updated_at) VALUES ($1, $2::jsonb, NOW()) ON CONFLICT(subject_id) DO UPDATE SET json = EXCLUDED.json, updated_at = EXCLUDED.updated_at', [String(id), JSON.stringify(row)] ); } } async function refreshRunsTable(client, runs) { await client.query('DELETE FROM runs'); for (const run of runs || []) { await client.query('INSERT INTO runs(json, created_at) VALUES ($1::jsonb, $2)', [ JSON.stringify(run), run.finished_at || run.started_at || new Date().toISOString() ]); } } return { kind: 'postgres', getStateVersion() { return stateVersion; }, async getSetting(key) { const result = await pool.query('SELECT value FROM settings WHERE key = $1', [key]); return result.rows[0]?.value || ''; }, async setSetting(key, value) { await pool.query( 'INSERT INTO settings(key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at', [key, String(value)] ); }, async getState() { const result = await pool.query('SELECT json FROM app_state WHERE id = 1'); return result.rows[0]?.json || null; }, // Client-facing state: history is in its own table (lazy-loaded per subject), and the heavy // per-collection blobs the frontend never reads (`subject_raw`, `infobox`) are stripped here in // Postgres so they never get serialized/gzipped/transferred on load. async getClientState() { // Cheap in Postgres: just drop history. Strip the unused heavy per-collection blobs in JS // (fast key deletes) — doing it via jsonb_each/jsonb_object_agg in SQL is far more CPU. const result = await pool.query(`SELECT json - 'history' AS json FROM app_state WHERE id = 1`); const j = result.rows[0]?.json; if (!j) return null; const cols = j.collections; if (cols && typeof cols === 'object') { for (const k in cols) { const c = cols[k]; if (c) { delete c.subject_raw; delete c.infobox; } } } return j; }, // History entries for one subject (detail drawer). async getSubjectHistory(subjectId) { const result = await pool.query( `SELECT coalesce(jsonb_agg(json ORDER BY id), '[]'::jsonb) AS history FROM history WHERE subject_id = $1`, [String(subjectId)] ); return result.rows[0]?.history || []; }, // Subject infobox for one collection (game type/engine/developer…). Stripped from /api/state to // keep that payload small, so the detail drawer lazy-loads it per subject like history does. async getSubjectInfobox(subjectId) { const result = await pool.query( `SELECT json -> 'collections' -> $1 -> 'infobox' AS infobox FROM app_state WHERE id = 1`, [String(subjectId)] ); const ib = result.rows[0]?.infobox; return Array.isArray(ib) ? ib : []; }, // All infoboxes as a { subject_id: infobox } map (one row de-TOAST). Lets the client prefetch every // infobox in the background after load, so opening any drawer shows 条目资料 instantly from cache. async getAllInfoboxes() { const result = await pool.query( `SELECT coalesce(jsonb_object_agg(e.key, e.value -> 'infobox') FILTER (WHERE jsonb_typeof(e.value -> 'infobox') = 'array' AND e.value -> 'infobox' <> '[]'::jsonb), '{}'::jsonb) AS infoboxes FROM app_state, jsonb_each(app_state.json -> 'collections') AS e WHERE app_state.id = 1` ); return result.rows[0]?.infoboxes || {}; }, // Full history array (backup export). async getAllHistory() { const result = await pool.query(`SELECT coalesce(jsonb_agg(json ORDER BY id), '[]'::jsonb) AS history FROM history`); return result.rows[0]?.history || []; }, // Full replace — backup import and in-app full saves. History now lives in its own table and is // NOT stored in app_state.json. The browser no longer holds history, so a historyless payload must // NOT wipe server history: only replace the history table when the payload carries a non-empty // history (backup import); otherwise leave the existing history table untouched. async persistState(state) { const incomingHasHistory = Array.isArray(state.history) && state.history.length > 0; const jsonNoHistory = { ...state }; delete jsonNoHistory.history; const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( 'INSERT INTO app_state(id, schema, json, updated_at) VALUES (1, $1, $2::jsonb, NOW()) ON CONFLICT(id) DO UPDATE SET schema = EXCLUDED.schema, json = EXCLUDED.json, updated_at = EXCLUDED.updated_at', [Number(state.schema || 0), JSON.stringify(jsonNoHistory)] ); if (incomingHasHistory) { await client.query('DELETE FROM history'); await client.query( `INSERT INTO history(subject_id, json, created_at) SELECT e->>'subject_id', e, COALESCE(NULLIF(e->>'created_at', '')::timestamptz, NOW()) FROM jsonb_array_elements($1::jsonb) e`, [JSON.stringify(state.history)] ); } await refreshCollectionsTable(client, state.collections); await refreshRunsTable(client, state.runs); await client.query('COMMIT'); bumpState(); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } }, // Merge a partial settings object into app_state.json.settings without touching collections. async setStateSettings(partial) { await pool.query( `INSERT INTO app_state(id, schema, json, updated_at) VALUES (1, 9, jsonb_build_object('schema', 9, 'settings', $1::jsonb, 'collections', '{}'::jsonb, 'runs', '[]'::jsonb), NOW()) ON CONFLICT(id) DO UPDATE SET json = jsonb_set(coalesce(app_state.json, '{}'::jsonb), '{settings}', coalesce(app_state.json->'settings', '{}'::jsonb) || $1::jsonb, true), updated_at = NOW()`, [JSON.stringify(partial || {})] ); bumpState(); }, // Update a single collection's fields (safety-box actions). Reflects into both app_state.json and the collections table. async patchCollection(subjectId, patch) { const id = String(subjectId); const cur = await pool.query(`SELECT json->'collections'->$1 AS row FROM app_state WHERE id = 1`, [id]); const existing = cur.rows[0]?.row; if (!existing) return null; const merged = { ...existing, ...(patch || {}) }; const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( `UPDATE app_state SET json = jsonb_set(json, ARRAY['collections', $1], $2::jsonb, true), updated_at = NOW() WHERE id = 1`, [id, JSON.stringify(merged)] ); await client.query( 'INSERT INTO collections(subject_id, json, updated_at) VALUES ($1, $2::jsonb, NOW()) ON CONFLICT(subject_id) DO UPDATE SET json = EXCLUDED.json, updated_at = EXCLUDED.updated_at', [id, JSON.stringify(merged)] ); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } bumpState(); return merged; }, // Sync end: replace collections/runs and settings.autoSyncLastRunDate in app_state.json (history is // NOT in json anymore), and APPEND the run's new history entries to the history table. // `history` here is the new entries produced by this run (runSync starts from an empty history). async persistSyncResult({ collections, history, runs, lastRunDate }) { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( `INSERT INTO app_state(id, schema, json, updated_at) VALUES (1, 9, jsonb_build_object('schema', 9, 'settings', jsonb_build_object('autoSyncLastRunDate', $3::text), 'collections', $1::jsonb, 'runs', $2::jsonb), NOW()) ON CONFLICT(id) DO UPDATE SET json = jsonb_set(jsonb_set(jsonb_set( app_state.json, '{collections}', $1::jsonb), '{runs}', $2::jsonb), '{settings,autoSyncLastRunDate}', to_jsonb($3::text), true), updated_at = NOW()`, [JSON.stringify(collections || {}), JSON.stringify(runs || []), String(lastRunDate || '')] ); const newHistory = Array.isArray(history) ? history : []; if (newHistory.length) { await client.query( `INSERT INTO history(subject_id, json, created_at) SELECT e->>'subject_id', e, COALESCE(NULLIF(e->>'created_at', '')::timestamptz, NOW()) FROM jsonb_array_elements($1::jsonb) e`, [JSON.stringify(newHistory)] ); } await refreshCollectionsTable(client, collections); await refreshRunsTable(client, runs); await client.query('COMMIT'); bumpState(); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } }; } module.exports = { createStore };