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>
110 lines
4.2 KiB
JavaScript
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 }
|