txt-game: become a nox mesh module
The mesh builds the game from this repository and a commit (novox/hq ADR 0069); registry-api.novox.be, where HAL's image came from, is retired, so the running container can no longer be re-pulled. The HAL module.yml, docker-compose.yml and migrations/ beside this manifest retire with HAL. The score database is a postgres-database grant: host, port, login and database come from the binding (in a file the container reads as its environment), the password from a 0600 file named by TXT_GAME_DB_PASSWORD_FILE (ADR 0086). TXT_GAME_DB_PASSWORD still works, so HAL's deployment keeps running if it is rebuilt from main. The app now creates its schema when the database has none. HAL's provisioning migrations did that as the admin and then granted the app's login; under the mesh the app's own login owns its database, so the app makes its tables itself. A database that already has both tables - HAL's or one restored from it - is left exactly as it is. The base is node 22.23.2-alpine3.24 by digest, the image the running one was built on (its base layers match). Verified: the manifest parses on mesh-controller main and #149 and renders for a zurag.be node. Built from this commit, against throwaway postgres: on an empty granted database it creates both tables (owned by the granted login) and records a game; on a HAL-shaped database (tables owned by postgres, grants to the old login, password in the environment) it plays and creates nothing; and a pg_dump/pg_restore --no-owner copy of that database into a fresh granted one keeps every row (counts and md5 identical), is owned by the grant, and keeps taking games.
This commit is contained in:
+79
-4
@@ -7,6 +7,7 @@
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import pg from "pg";
|
||||
|
||||
const PORT = 3000;
|
||||
@@ -14,14 +15,33 @@ const MAX_ATTEMPTS = 7;
|
||||
|
||||
// ─── DB Pool ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Where the database is comes from the environment; the password comes from a file when
|
||||
// TXT_GAME_DB_PASSWORD_FILE names one (how the mesh delivers a secret — novox/hq ADR 0086: a
|
||||
// secret in the environment is readable by anything that can inspect the container), and
|
||||
// from TXT_GAME_DB_PASSWORD otherwise, which is how HAL still delivers it.
|
||||
const DB_ENV_KEYS = [
|
||||
"TXT_GAME_DB_HOST",
|
||||
"TXT_GAME_DB_PORT",
|
||||
"TXT_GAME_DB_USER",
|
||||
"TXT_GAME_DB_PASSWORD",
|
||||
"TXT_GAME_DB_NAME",
|
||||
];
|
||||
const hasDb = DB_ENV_KEYS.every((k) => process.env[k]);
|
||||
|
||||
/** @returns {string | undefined} */
|
||||
function dbPassword() {
|
||||
const file = process.env.TXT_GAME_DB_PASSWORD_FILE;
|
||||
if (file) {
|
||||
try {
|
||||
return readFileSync(file, "utf8").replace(/\r?\n$/, "") || undefined;
|
||||
} catch (err) {
|
||||
console.error(`[txt-game] cannot read TXT_GAME_DB_PASSWORD_FILE: ${err.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return process.env.TXT_GAME_DB_PASSWORD || undefined;
|
||||
}
|
||||
|
||||
const password = dbPassword();
|
||||
const hasDb = DB_ENV_KEYS.every((k) => process.env[k]) && password !== undefined;
|
||||
|
||||
/** @type {pg.Pool | null} */
|
||||
let pool = null;
|
||||
@@ -31,7 +51,7 @@ if (hasDb) {
|
||||
host: process.env.TXT_GAME_DB_HOST,
|
||||
port: parseInt(process.env.TXT_GAME_DB_PORT ?? "5432", 10),
|
||||
user: process.env.TXT_GAME_DB_USER,
|
||||
password: process.env.TXT_GAME_DB_PASSWORD,
|
||||
password,
|
||||
database: process.env.TXT_GAME_DB_NAME,
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30000,
|
||||
@@ -44,10 +64,63 @@ if (hasDb) {
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[txt-game] DB env vars missing — running in degraded mode (scores disabled)"
|
||||
"[txt-game] DB settings missing — running in degraded mode (scores disabled)"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the score tables when the database has none — what HAL's provisioning migrations
|
||||
* (migrations/provision/postgres) did as the admin before the app first started. Under the mesh
|
||||
* the app's own login owns its database, so the app makes its schema itself. A database that
|
||||
* already has both tables (HAL's, or one restored from it) is left exactly as it is: nothing
|
||||
* here alters or grants on existing tables.
|
||||
*/
|
||||
async function ensureSchema() {
|
||||
if (!pool) return;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT to_regclass('public.players') IS NOT NULL AS players, to_regclass('public.games') IS NOT NULL AS games"
|
||||
);
|
||||
if (rows[0].players && rows[0].games) return;
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id TEXT PRIMARY KEY,
|
||||
games_played INT NOT NULL DEFAULT 0,
|
||||
games_won INT NOT NULL DEFAULT 0,
|
||||
games_lost INT NOT NULL DEFAULT 0,
|
||||
best_attempts INT,
|
||||
current_streak INT NOT NULL DEFAULT 0,
|
||||
best_streak INT NOT NULL DEFAULT 0,
|
||||
last_played_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
target INT NOT NULL,
|
||||
attempts INT NOT NULL,
|
||||
won BOOLEAN NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_players_scoreboard
|
||||
ON players (best_attempts NULLS LAST, last_played_at)
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_games_player_history
|
||||
ON games (player_id, finished_at DESC)
|
||||
`);
|
||||
console.log("[txt-game] score schema created");
|
||||
} catch (err) {
|
||||
console.error("[txt-game] could not ensure the score schema:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session Store ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -542,6 +615,8 @@ const server = createServer(async (req, res) => {
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
await ensureSchema();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`txt-game listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user