This commit is contained in:
+298
@@ -0,0 +1,298 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user