Files
website/server/src/utils/db.js
wtclaude 9b16f39a52
Some checks failed
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Failing after 4m23s
feat(modules): the declarative Docker path (phase 4, slice 3)
MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.

Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.

A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.

Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.

741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 07:57:08 -05:00

110 lines
4.2 KiB
JavaScript

const fs = require('fs')
const path = require('path')
const mariadb = require('mariadb')
require('dotenv').config()
const log = require('./logger')('db')
const { splitStatements } = require('./sqlStatements')
const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'runic_gateway',
connectionLimit: 5,
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
insertIdAsNumber: true,
bigIntAsNumber: true,
decimalAsNumber: true,
// The driver defaults to 'local' — silently serializing bound JS Date
// params using the HOST MACHINE's local offset instead of the DB session's
// timezone (discovered via the Discord bot's temp_roles.expires_at coming
// back hours off in dev). 'auto' negotiates the actual session timezone so
// Date round-trips correctly regardless of host TZ — affects any write of
// a JS Date param, e.g. botConfig.model.js's last_connected_at.
timezone: 'auto',
})
/**
* Run a parameterized query and release the connection.
* @param {string} sql
* @param {Array} [params]
*/
async function query(sql, params) {
const conn = await pool.getConnection()
try {
return await conn.query(sql, params)
} finally {
conn.release()
}
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
* Create tables if they do not exist. Idempotent. Retries while the DB is still
* coming up (important under docker-compose even with a healthcheck).
*
* Once core's schema is in place, every installed module's schema fragment is
* replayed after it (MODULE_API.md §2.6). That step is deliberately OUTSIDE the
* retry loop: a fragment that throws is that module's failure, not a signal the
* database is still coming up, and retrying core's whole schema nine more times
* because one module shipped bad SQL would turn a 503'd module into a two-minute
* boot. It is also why this file knows nothing about modules beyond the one call
* below — the discovery, splitting and per-module failure handling all live in
* modules/schema.js, required lazily so that requiring the pool never drags the
* loader in with it.
*
* `replayModules: false` is for a caller that has not scanned the volume YET and
* intends to. server.js is the one: since slice 3 it resolves the declared
* module set before requiring app.js, which puts core's schema *before* the scan
* — so it replays the fragments itself, in the one place that knows the scan has
* happened. Left true everywhere else, so the ordinary caller cannot get module
* tables by accident and lose them by refactor.
*/
async function ensureSchema({ retries = 10, delayMs = 2000, replayModules = true } = {}) {
await ensureCoreSchema({ retries, delayMs })
// eslint-disable-next-line global-require
if (replayModules) await require('../modules/schema').replayFragments()
}
/** Core's own schema.sql, with the wait-for-the-database retry. */
async function ensureCoreSchema({ retries, delayMs }) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const conn = await pool.getConnection()
try {
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
for (const statement of splitStatements(sql)) {
await conn.query(statement)
}
log.info('schema ensured')
return
} finally {
conn.release()
}
} catch (err) {
if (attempt === retries) throw err
log.warn(`database not ready, retrying (attempt ${attempt}/${retries})`, {
code: err.code || err.message,
})
await new Promise((r) => setTimeout(r, delayMs))
}
}
}
// Idempotent: `pool.end()` throws "pool is already closed" on a second call, and
// closing twice is normal rather than exceptional — a SIGINT followed by a
// SIGTERM reaches the shutdown handler twice, and the test harness closes the
// pool for every file on top of the suites that close it themselves. A teardown
// that fails because it had already succeeded is noise.
let closed = false
async function close() {
if (closed) return
closed = true
await pool.end()
}
module.exports = { pool, query, ensureSchema, close }