refactor(server): dedupe shard-state shaping, upsert builder, and config DB models #89

Merged
whitlocktech merged 1 commits from refactor/dedupe-shardstate-config-db into main 2026-07-21 17:49:33 +00:00
6 changed files with 75 additions and 155 deletions

View File

@@ -1,28 +1,7 @@
const { query } = require('../../utils/db')
const singletonConfigDb = require('../singletonConfigDb')
const COLS =
'id, guild_id, bot_token_enc, application_id, enabled, status, status_detail, last_connected_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM bot_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO bot_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
module.exports = singletonConfigDb('bot_config', COLS)

View File

@@ -1,28 +1,7 @@
const { query } = require('../../utils/db')
const singletonConfigDb = require('../singletonConfigDb')
const COLS =
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin connects Gmail for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM email_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (refresh token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO email_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
module.exports = singletonConfigDb('email_config', COLS)

View File

@@ -1,26 +1,37 @@
const { query } = require('../../utils/db')
// Shared upsert builder for the shard-state tables. Each is keyed on a single
// primary column (`pkCol` = pk); `fields` carries only the columns the model
// wants to write, so a partial refresh touches nothing else. `coalesce` keeps
// the prior column value when the incoming one is NULL (used by shard_online so a
// vitals frame that omits acct/name doesn't blank what mob.login set); otherwise
// the incoming value wins (VALUES()).
function upsertRow(table, pkCol, pk, fields, { coalesce = false } = {}) {
const cols = Object.keys(fields)
const allCols = [pkCol, ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const rhs = coalesce
? (c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`
: (c) => `\`${c}\` = VALUES(\`${c}\`)`
const updates = cols.map(rhs).join(', ')
return query(
`INSERT INTO ${table} (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[pk, ...cols.map((c) => fields[c])],
)
}
// ── Online players ─────────────────────────────────────────────────────────
const ONLINE_COLS =
'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at'
// Upsert one online player. `fields` already prepared by the model (only the
// columns it wants to write); serial is required and is the primary key.
async function upsertOnline(serial, fields) {
const cols = Object.keys(fields)
const allCols = ['serial', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
// Never overwrite an existing column with NULL on refresh (a char.vitals frame
// that omits acct/name shouldn't blank what mob.login set) — COALESCE keeps the
// prior value when the incoming one is NULL.
const updates = cols.map((c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_online (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[serial, ...cols.map((c) => fields[c])],
)
}
// COALESCE variant: a char.vitals frame that omits acct/name must not blank what
// mob.login set, so an incoming NULL keeps the prior column value.
const upsertOnline = (serial, fields) =>
upsertRow('shard_online', 'serial', serial, fields, { coalesce: true })
const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial])
const clearOnline = () => query('DELETE FROM shard_online')
@@ -89,18 +100,7 @@ async function latestEconomy() {
const HOUSE_COLS =
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
async function upsertHouse(serial, fields) {
const cols = Object.keys(fields)
const allCols = ['serial', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_houses (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[serial, ...cols.map((c) => fields[c])],
)
}
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
const listIdocHouses = () =>
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
@@ -132,18 +132,7 @@ const listRegistryHouses = () =>
const CHAMP_COLS =
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
async function upsertChamp(serial, fields) {
const cols = Object.keys(fields)
const allCols = ['serial', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_champs (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[serial, ...cols.map((c) => fields[c])],
)
}
const upsertChamp = (serial, fields) => upsertRow('shard_champs', 'serial', serial, fields)
const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
const clearChamps = () => query('DELETE FROM shard_champs')
@@ -176,18 +165,7 @@ const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sen
const GUILD_COLS =
'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at'
async function upsertGuild(id, fields) {
const cols = Object.keys(fields)
const allCols = ['id', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_guilds (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[id, ...cols.map((c) => fields[c])],
)
}
const upsertGuild = (id, fields) => upsertRow('shard_guilds', 'id', id, fields)
const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
@@ -219,18 +197,7 @@ const listGuildsLedByAccounts = (accounts) =>
const GOV_COLS =
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
async function upsertGovernor(city, fields) {
const cols = Object.keys(fields)
const allCols = ['city', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_governors (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[city, ...cols.map((c) => fields[c])],
)
}
const upsertGovernor = (city, fields) => upsertRow('shard_governors', 'city', city, fields)
const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`)

View File

@@ -86,26 +86,7 @@ async function listOnlineLinked() {
async function listOnline() {
const rows = await db.listOnline()
return rows.map((r) => ({
serial: r.serial,
name: r.name,
acct: r.acct,
webId: r.web_id,
map: r.map,
x: r.x,
y: r.y,
z: r.z,
hits: r.hits,
hitsMax: r.hits_max,
mana: r.mana,
manaMax: r.mana_max,
stam: r.stam,
stamMax: r.stam_max,
str: r.str,
dex: r.dex,
int: r.int,
updatedAt: r.updated_at,
}))
return rows.map(shapeOnline)
}
// Append a gold-supply sample (economy.supply).

View File

@@ -0,0 +1,35 @@
const { query } = require('../utils/db')
// Factory for the singleton config tables (bot_config, email_config,
// uo_link_config). Each is a one-row table keyed on id = 1: `get()` returns the
// row (or null before the admin first saves it), and `upsert()` writes only the
// columns the model prepared, leaving the rest untouched. The three tables share
// this shape exactly, so the DB layer is generated rather than copy-pasted —
// only the table name and column list differ.
//
// `fields` are already prepared by the model (secrets pre-encrypted); ordering
// and encryption stay a model-layer concern.
function singletonConfigDb(table, cols) {
async function get() {
const rows = await query(`SELECT ${cols} FROM ${table} WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
async function upsert(fields) {
const columns = Object.keys(fields)
const vals = columns.map((c) => fields[c])
const insertCols = ['id', ...columns].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...columns.map(() => '?')].join(', ')
const updates = columns.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO ${table} (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
return { get, upsert }
}
module.exports = singletonConfigDb

View File

@@ -1,28 +1,7 @@
const { query } = require('../../utils/db')
const singletonConfigDb = require('../singletonConfigDb')
const COLS =
'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM uo_link_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO uo_link_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
module.exports = singletonConfigDb('uo_link_config', COLS)