Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -24,33 +24,51 @@ const allowed = new Set(['--scenario', '--runs', '--baseline-ref', '--json']);
|
||||
for (let index = 0; index < arguments_.length; index += 2) {
|
||||
const key = arguments_[index];
|
||||
const value = arguments_[index + 1];
|
||||
if (!allowed.has(key) || !value) throw new Error('Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH');
|
||||
if (!allowed.has(key) || !value)
|
||||
throw new Error(
|
||||
'Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH'
|
||||
);
|
||||
options[key.slice(2)] = value;
|
||||
}
|
||||
options.runs = Number(options.runs);
|
||||
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20) throw new Error('--runs must be an integer from 1 to 20');
|
||||
if (!['small', 'large', 'all'].includes(options.scenario)) throw new Error('--scenario must be small, large, or all');
|
||||
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20)
|
||||
throw new Error('--runs must be an integer from 1 to 20');
|
||||
if (!['small', 'large', 'all'].includes(options.scenario))
|
||||
throw new Error('--scenario must be small, large, or all');
|
||||
const baseline = options['baseline-ref']
|
||||
? execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`], { cwd: repo, encoding: 'utf8' }).trim()
|
||||
? execFileSync(
|
||||
'git',
|
||||
['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`],
|
||||
{ cwd: repo, encoding: 'utf8' }
|
||||
).trim()
|
||||
: null;
|
||||
const definitions = [
|
||||
{ name: 'small', matches: 2000, players: 500 },
|
||||
{ name: 'large', matches: 50000, players: 10000 }
|
||||
].filter(item => options.scenario === 'all' || item.name === options.scenario);
|
||||
].filter((item) => options.scenario === 'all' || item.name === options.scenario);
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'cherry-leaderboard-benchmark-'));
|
||||
const source = (file, ref) => ref
|
||||
? execFileSync('git', ['show', `${ref}:${file}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })
|
||||
: readFileSync(join(repo, file), 'utf8');
|
||||
const percentile = (values, fraction) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
|
||||
const source = (file, ref) =>
|
||||
ref
|
||||
? execFileSync('git', ['show', `${ref}:${file}`], {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
})
|
||||
: readFileSync(join(repo, file), 'utf8');
|
||||
const percentile = (values, fraction) =>
|
||||
[...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
|
||||
|
||||
function seed(handle, definition) {
|
||||
handle.exec(source('src/lib/server/db/schema.sql'));
|
||||
handle.exec("CREATE TABLE report_revisions(topic TEXT PRIMARY KEY, revision INTEGER NOT NULL); INSERT INTO report_revisions VALUES ('leaderboard',0)");
|
||||
handle.exec(
|
||||
"CREATE TABLE report_revisions(topic TEXT PRIMARY KEY, revision INTEGER NOT NULL); INSERT INTO report_revisions VALUES ('leaderboard',0)"
|
||||
);
|
||||
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at,
|
||||
status, start_observed, start_players, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 4, ?, ?, ?)`);
|
||||
const identity = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
const identity =
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
|
||||
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
|
||||
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
||||
@@ -60,19 +78,38 @@ function seed(handle, definition) {
|
||||
first_observed_round, present_at_start)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
let state = 20260918;
|
||||
const random = () => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state / 4294967296; };
|
||||
const random = () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 4294967296;
|
||||
};
|
||||
handle.transaction(() => {
|
||||
for (let id = 1; id <= definition.matches; id++) {
|
||||
const mapIndex = Math.floor(random() * 12);
|
||||
const game = mapIndex < 6 ? 't5' : 't6';
|
||||
const map = `map_${mapIndex}`;
|
||||
const status = id % 20 === 0 ? 'active' : id % 20 === 1 ? 'interrupted' : 'completed';
|
||||
const started = new Date(Date.UTC(2023, 0, 1) + Math.floor(id / definition.matches * 3 * 365 * 86400000)).toISOString();
|
||||
const ended = new Date(Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000).toISOString();
|
||||
const started = new Date(
|
||||
Date.UTC(2023, 0, 1) + Math.floor((id / definition.matches) * 3 * 365 * 86400000)
|
||||
).toISOString();
|
||||
const ended = new Date(
|
||||
Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000
|
||||
).toISOString();
|
||||
const round = id % 101 === 0 ? 1 : 2 + Math.floor(random() * 60);
|
||||
match.run(id, `server_${id % 24}`, game, map, `${game}:${map}`, `Map ${mapIndex}`,
|
||||
started, status === 'completed' ? ended : null, ended, status, round,
|
||||
status === 'completed' ? 'game_end' : 'heartbeat', `event_${id}`);
|
||||
match.run(
|
||||
id,
|
||||
`server_${id % 24}`,
|
||||
game,
|
||||
map,
|
||||
`${game}:${map}`,
|
||||
`Map ${mapIndex}`,
|
||||
started,
|
||||
status === 'completed' ? ended : null,
|
||||
ended,
|
||||
status,
|
||||
round,
|
||||
status === 'completed' ? 'game_end' : 'heartbeat',
|
||||
`event_${id}`
|
||||
);
|
||||
const roster = new Set();
|
||||
while (roster.size < 4) roster.add(Math.floor(random() ** 2 * definition.players));
|
||||
// Reserve one completed participation for an explicitly ineligible player.
|
||||
@@ -84,16 +121,47 @@ function seed(handle, definition) {
|
||||
const kills = Math.floor(round * 18 + random() * 800);
|
||||
const presentAtStart = random() > 0.1;
|
||||
const firstRound = presentAtStart ? 0 : Math.max(1, Math.floor(random() * round));
|
||||
participation.run(id, guid, round, Math.max(0, round - Math.max(1, firstRound)), kills,
|
||||
Math.floor(kills * random()), Math.floor(random() * 8), Math.floor(random() * 12),
|
||||
Math.floor(random() * 100000), Math.max(0, round - 1), Number(status === 'active'),
|
||||
Number(random() > 0.01), Number(status === 'completed' && random() > 0.15),
|
||||
started, ended, firstRound, Number(presentAtStart));
|
||||
participation.run(
|
||||
id,
|
||||
guid,
|
||||
round,
|
||||
Math.max(0, round - Math.max(1, firstRound)),
|
||||
kills,
|
||||
Math.floor(kills * random()),
|
||||
Math.floor(random() * 8),
|
||||
Math.floor(random() * 12),
|
||||
Math.floor(random() * 100000),
|
||||
Math.max(0, round - 1),
|
||||
Number(status === 'active'),
|
||||
Number(random() > 0.01),
|
||||
Number(status === 'completed' && random() > 0.15),
|
||||
started,
|
||||
ended,
|
||||
firstRound,
|
||||
Number(presentAtStart)
|
||||
);
|
||||
}
|
||||
if (id === definition.matches - 2) {
|
||||
identity.run(game, 'benchmark-low-sample', 'Low sample', started, ended);
|
||||
participation.run(id, 'benchmark-low-sample', 2, 1, 20, 5, 0, 0, 1000,
|
||||
1, 0, 1, 1, started, ended, 0, 1);
|
||||
participation.run(
|
||||
id,
|
||||
'benchmark-low-sample',
|
||||
2,
|
||||
1,
|
||||
20,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
1000,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
started,
|
||||
ended,
|
||||
0,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -104,15 +172,27 @@ function loadImplementation(handle, ref) {
|
||||
const modules = new Map();
|
||||
const hashes = {};
|
||||
let recorded = null;
|
||||
const database = { prepare(sql) {
|
||||
const statement = handle.prepare(sql);
|
||||
return Object.fromEntries(['all', 'get'].map(method => [method, (...parameters) => {
|
||||
const start = performance.now();
|
||||
const result = statement[method](...parameters);
|
||||
if (recorded) recorded.push({ sql, parameters, milliseconds: performance.now() - start });
|
||||
return result;
|
||||
}]));
|
||||
} };
|
||||
const database = {
|
||||
prepare(sql) {
|
||||
const statement = handle.prepare(sql);
|
||||
return Object.fromEntries(
|
||||
['all', 'get'].map((method) => [
|
||||
method,
|
||||
(...parameters) => {
|
||||
const start = performance.now();
|
||||
const result = statement[method](...parameters);
|
||||
if (recorded)
|
||||
recorded.push({
|
||||
sql,
|
||||
parameters,
|
||||
milliseconds: performance.now() - start
|
||||
});
|
||||
return result;
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
};
|
||||
function load(file) {
|
||||
if (file === 'src/lib/server/db/index.ts') return { db: () => database };
|
||||
if (modules.has(file)) return modules.get(file);
|
||||
@@ -123,10 +203,14 @@ function loadImplementation(handle, ref) {
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
modules.set(file, exports);
|
||||
const localRequire = specifier => {
|
||||
const localRequire = (specifier) => {
|
||||
if (!specifier.startsWith('.')) return require(specifier);
|
||||
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(/\.js$/, '.ts');
|
||||
if (!target.startsWith('src/lib/')) throw new Error(`Unexpected benchmark dependency: ${target}`);
|
||||
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(
|
||||
/\.js$/,
|
||||
'.ts'
|
||||
);
|
||||
if (!target.startsWith('src/lib/'))
|
||||
throw new Error(`Unexpected benchmark dependency: ${target}`);
|
||||
return load(target);
|
||||
};
|
||||
new Function('require', 'exports', compiled)(localRequire, exports);
|
||||
@@ -134,10 +218,14 @@ function loadImplementation(handle, ref) {
|
||||
}
|
||||
const api = load('src/lib/server/fleet/leaderboard.ts');
|
||||
return {
|
||||
name: ref ? 'baseline' : 'current', ref, hashes,
|
||||
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map(metric => metric.key),
|
||||
name: ref ? 'baseline' : 'current',
|
||||
ref,
|
||||
hashes,
|
||||
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map((metric) => metric.key),
|
||||
run(test) {
|
||||
return test.guid ? api.getPlayerLeaderboardEntry(test.guid, test.options) : api.getLeaderboard(test.options);
|
||||
return test.guid
|
||||
? api.getPlayerLeaderboardEntry(test.guid, test.options)
|
||||
: api.getLeaderboard(test.options);
|
||||
},
|
||||
measure(test) {
|
||||
recorded = [];
|
||||
@@ -152,8 +240,16 @@ function loadImplementation(handle, ref) {
|
||||
}
|
||||
|
||||
const report = {
|
||||
environment: { node: process.version, sqlite: null, platform: platform(), arch: arch(), cpu: cpus()[0]?.model },
|
||||
baselineRef: baseline, runs: options.runs, seed: 20260918,
|
||||
environment: {
|
||||
node: process.version,
|
||||
sqlite: null,
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
cpu: cpus()[0]?.model
|
||||
},
|
||||
baselineRef: baseline,
|
||||
runs: options.runs,
|
||||
seed: 20260918,
|
||||
notes: 'Synthetic file-backed SQLite, one warmup per case, rotating execution order, warm OS cache. Includes SQL preparation and source-record lookup; excludes HTTP and browser rendering. Shared local CPU; timings are not production latency. Schema and indexes are current for both implementations.',
|
||||
scenarios: []
|
||||
};
|
||||
@@ -161,77 +257,148 @@ try {
|
||||
for (const definition of definitions) {
|
||||
const handle = new Database(join(temporary, `${definition.name}.sqlite`));
|
||||
try {
|
||||
report.environment.sqlite = handle.prepare('SELECT sqlite_version() AS version').get().version;
|
||||
report.environment.sqlite = handle
|
||||
.prepare('SELECT sqlite_version() AS version')
|
||||
.get().version;
|
||||
seed(handle, definition);
|
||||
const implementations = [...(baseline ? [loadImplementation(handle, baseline)] : []), loadImplementation(handle)];
|
||||
const implementations = [
|
||||
...(baseline ? [loadImplementation(handle, baseline)] : []),
|
||||
loadImplementation(handle)
|
||||
];
|
||||
const cases = [
|
||||
{ name: 'unfiltered', options: {} },
|
||||
{ name: 'kills', options: { metric: 'kills' } },
|
||||
{ name: 'game', options: { game: 't5', metric: 'kills' } },
|
||||
{ name: 'map', options: { game: 't6', map: 't6:map_8', metric: 'kills' } },
|
||||
{ name: 'search', options: { q: '00042', metric: 'kills' } },
|
||||
{ name: 'deep-page', options: { metric: 'kills', offset: Math.floor(definition.players / 2) } },
|
||||
{
|
||||
name: 'deep-page',
|
||||
options: { metric: 'kills', offset: Math.floor(definition.players / 2) }
|
||||
},
|
||||
{ name: 'efficiency', options: { metric: 'headshotRate' } },
|
||||
{ name: 'exact-player', guid: '10000000000000042', options: { metric: 'kills' } },
|
||||
{ name: 'empty-search', options: { q: 'No such player exists' } },
|
||||
{ name: 'past-end', options: { offset: 999999 } },
|
||||
{ name: 'empty-map', options: { map: 't5:missing' } }
|
||||
];
|
||||
const counts = handle.prepare(`SELECT COUNT(*) AS participationRows,
|
||||
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`).get();
|
||||
const scenario = { ...definition, ...counts, maps: 12, servers: 24, completedShare: 0.9, results: [] };
|
||||
const counts = handle
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS participationRows,
|
||||
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`
|
||||
)
|
||||
.get();
|
||||
const scenario = {
|
||||
...definition,
|
||||
...counts,
|
||||
maps: 12,
|
||||
servers: 24,
|
||||
completedShare: 0.9,
|
||||
results: []
|
||||
};
|
||||
report.scenarios.push(scenario);
|
||||
// Broad parity is separate from timings so every ratio and edge case is
|
||||
// checked without multiplying the larger fixture's timed request matrix.
|
||||
if (baseline && definition.name === 'small') {
|
||||
const scopes = [{}, { game: 't5' }, { game: 't6', map: 't6:map_8' }, { q: '00042' },
|
||||
{ q: 'No such player exists' }, { offset: 999999 }, { map: 't5:missing' }];
|
||||
const scopes = [
|
||||
{},
|
||||
{ game: 't5' },
|
||||
{ game: 't6', map: 't6:map_8' },
|
||||
{ q: '00042' },
|
||||
{ q: 'No such player exists' },
|
||||
{ offset: 999999 },
|
||||
{ map: 't5:missing' }
|
||||
];
|
||||
let checked = 0;
|
||||
for (const metric of implementations[1].metrics) {
|
||||
for (const scope of scopes) {
|
||||
const test = { options: { ...scope, metric } };
|
||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`);
|
||||
assert.deepStrictEqual(
|
||||
implementations[1].run(test),
|
||||
implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`
|
||||
);
|
||||
checked++;
|
||||
}
|
||||
for (const guid of ['10000000000000042', 'benchmark-low-sample', 'benchmark-missing']) {
|
||||
for (const guid of [
|
||||
'10000000000000042',
|
||||
'benchmark-low-sample',
|
||||
'benchmark-missing'
|
||||
]) {
|
||||
const test = { guid, options: { metric } };
|
||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${guid}: exact-player parity`);
|
||||
assert.deepStrictEqual(
|
||||
implementations[1].run(test),
|
||||
implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${guid}: exact-player parity`
|
||||
);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
scenario.parityCases = checked;
|
||||
console.log(JSON.stringify({ scenario: definition.name, parityCases: checked, parity: true }));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
scenario: definition.name,
|
||||
parityCases: checked,
|
||||
parity: true
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const test of cases) {
|
||||
const expected = implementations[0].run(test);
|
||||
for (const implementation of implementations.slice(1)) {
|
||||
assert.deepStrictEqual(implementation.run(test), expected, `${definition.name}/${test.name}: baseline parity`);
|
||||
assert.deepStrictEqual(
|
||||
implementation.run(test),
|
||||
expected,
|
||||
`${definition.name}/${test.name}: baseline parity`
|
||||
);
|
||||
}
|
||||
const samples = Object.fromEntries(implementations.map(item => [item.name, []]));
|
||||
const samples = Object.fromEntries(implementations.map((item) => [item.name, []]));
|
||||
const latestStatements = {};
|
||||
for (let index = 0; index < options.runs; index++) {
|
||||
const rotation = index % implementations.length;
|
||||
for (const implementation of [...implementations.slice(rotation), ...implementations.slice(0, rotation)]) {
|
||||
for (const implementation of [
|
||||
...implementations.slice(rotation),
|
||||
...implementations.slice(0, rotation)
|
||||
]) {
|
||||
const measured = implementation.measure(test);
|
||||
samples[implementation.name].push(measured.milliseconds);
|
||||
latestStatements[implementation.name] = measured.statements;
|
||||
}
|
||||
}
|
||||
const result = { ...test, parity: baseline ? true : null, implementations: implementations.map(implementation => ({
|
||||
name: implementation.name, sourceHashes: implementation.hashes,
|
||||
medianMs: percentile(samples[implementation.name], 0.5),
|
||||
p95Ms: percentile(samples[implementation.name], 0.95), samplesMs: samples[implementation.name],
|
||||
statements: latestStatements[implementation.name].map(statement => ({ ...statement,
|
||||
plan: handle.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`).all(...statement.parameters)
|
||||
const result = {
|
||||
...test,
|
||||
parity: baseline ? true : null,
|
||||
implementations: implementations.map((implementation) => ({
|
||||
name: implementation.name,
|
||||
sourceHashes: implementation.hashes,
|
||||
medianMs: percentile(samples[implementation.name], 0.5),
|
||||
p95Ms: percentile(samples[implementation.name], 0.95),
|
||||
samplesMs: samples[implementation.name],
|
||||
statements: latestStatements[implementation.name].map((statement) => ({
|
||||
...statement,
|
||||
plan: handle
|
||||
.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`)
|
||||
.all(...statement.parameters)
|
||||
}))
|
||||
}))
|
||||
})) };
|
||||
};
|
||||
scenario.results.push(result);
|
||||
console.log(JSON.stringify({ scenario: definition.name, case: test.name, parity: result.parity,
|
||||
medianMs: Object.fromEntries(result.implementations.map(item => [item.name, Math.round(item.medianMs)])) }));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
scenario: definition.name,
|
||||
case: test.name,
|
||||
parity: result.parity,
|
||||
medianMs: Object.fromEntries(
|
||||
result.implementations.map((item) => [
|
||||
item.name,
|
||||
Math.round(item.medianMs)
|
||||
])
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally { handle.close(); }
|
||||
} finally {
|
||||
handle.close();
|
||||
}
|
||||
}
|
||||
if (options.json) {
|
||||
const target = resolve(options.json);
|
||||
@@ -239,4 +406,6 @@ try {
|
||||
writeFileSync(target, JSON.stringify(report, null, 2));
|
||||
console.log(`Detailed results: ${target}`);
|
||||
}
|
||||
} finally { rmSync(temporary, { recursive: true, force: true }); }
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -12,15 +12,21 @@ async function visit(directory) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
||||
const line = source.slice(0, match.index).split('\n').length;
|
||||
failures.push(`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}`);
|
||||
failures.push(
|
||||
`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root);
|
||||
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8');
|
||||
for (const match of tokens.matchAll(/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g)) {
|
||||
for (const match of tokens.matchAll(
|
||||
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g
|
||||
)) {
|
||||
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
|
||||
}
|
||||
if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; }
|
||||
else console.log('UI styles: shared font sizes and 12 px minimum verified.');
|
||||
if (failures.length) {
|
||||
console.error(failures.join('\n'));
|
||||
process.exitCode = 1;
|
||||
} else console.log('UI styles: shared font sizes and 12 px minimum verified.');
|
||||
|
||||
@@ -4,13 +4,30 @@ import { statSync } from 'node:fs';
|
||||
const path = process.argv[2] ?? process.env.DB_PATH ?? './data/cherry.db';
|
||||
const db = new Database(path, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
console.table(['', '-wal', '-shm'].map(suffix => {
|
||||
try { return { file: path + suffix, bytes: statSync(path + suffix).size }; }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; return { file: path + suffix, bytes: 0 }; }
|
||||
}));
|
||||
console.table(db.prepare(`SELECT name, ROUND(SUM(pgsize)/1048576.0, 2) AS mib
|
||||
FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC LIMIT 20`).all());
|
||||
console.table(['page_size', 'page_count', 'freelist_count', 'auto_vacuum'].map(pragma => ({
|
||||
pragma, value: db.pragma(pragma, { simple: true })
|
||||
})));
|
||||
} finally { db.close(); }
|
||||
console.table(
|
||||
['', '-wal', '-shm'].map((suffix) => {
|
||||
try {
|
||||
return { file: path + suffix, bytes: statSync(path + suffix).size };
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return { file: path + suffix, bytes: 0 };
|
||||
}
|
||||
})
|
||||
);
|
||||
console.table(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT name, ROUND(SUM(pgsize)/1048576.0, 2) AS mib
|
||||
FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC LIMIT 20`
|
||||
)
|
||||
.all()
|
||||
);
|
||||
console.table(
|
||||
['page_size', 'page_count', 'freelist_count', 'auto_vacuum'].map((pragma) => ({
|
||||
pragma,
|
||||
value: db.pragma(pragma, { simple: true })
|
||||
}))
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
+514
-124
@@ -9,7 +9,14 @@ const DAY = 24 * 60 * MINUTE;
|
||||
const GIB = 1024 ** 3;
|
||||
const image = 'fixture.invalid/electric-cherry/preview:local';
|
||||
|
||||
type Scenario = { game: 't5' | 't6'; slug: string; players: number; round: number; minutes: number; state: 'active' | 'idle' | 'stopped' | 'stale' | 'starting' | 'host-offline' };
|
||||
type Scenario = {
|
||||
game: 't5' | 't6';
|
||||
slug: string;
|
||||
players: number;
|
||||
round: number;
|
||||
minutes: number;
|
||||
state: 'active' | 'idle' | 'stopped' | 'stale' | 'starting' | 'host-offline';
|
||||
};
|
||||
const scenarios: Scenario[] = [
|
||||
{ game: 't5', slug: 'theater', players: 4, round: 27, minutes: 76, state: 'active' },
|
||||
{ game: 't5', slug: 'cosmodrome', players: 2, round: 16, minutes: 39, state: 'active' },
|
||||
@@ -25,211 +32,594 @@ const scenarios: Scenario[] = [
|
||||
{ game: 't6', slug: 'highrise', players: 0, round: 0, minutes: 0, state: 'host-offline' }
|
||||
];
|
||||
|
||||
export const fixtureServers = scenarios.map((scenario, index) => ({ ...scenario,
|
||||
name: `${scenario.game}_zombie_${scenario.slug}_1`, host: `fixture-host-${index === 11 ? 3 : index % 2 + 1}`,
|
||||
instance: `fixture-instance-${index + 1}`, port: 28960 + index,
|
||||
export const fixtureServers = scenarios.map((scenario, index) => ({
|
||||
...scenario,
|
||||
name: `${scenario.game}_zombie_${scenario.slug}_1`,
|
||||
host: `fixture-host-${index === 11 ? 3 : (index % 2) + 1}`,
|
||||
instance: `fixture-instance-${index + 1}`,
|
||||
port: 28960 + index,
|
||||
map: zombieMapBySlug(scenario.game, scenario.slug)!
|
||||
}));
|
||||
const names = ['Jamie Cross', 'Alex', 'Sam', 'Morgan', 'Riley', 'Taylor', 'Jordan', 'Casey', 'Quinn', 'Avery', 'Drew', 'Kai'];
|
||||
const names = [
|
||||
'Jamie Cross',
|
||||
'Alex',
|
||||
'Sam',
|
||||
'Morgan',
|
||||
'Riley',
|
||||
'Taylor',
|
||||
'Jordan',
|
||||
'Casey',
|
||||
'Quinn',
|
||||
'Avery',
|
||||
'Drew',
|
||||
'Kai'
|
||||
];
|
||||
const iso = (at: number) => new Date(at).toISOString();
|
||||
const player = (index: number) => ({ guid: index === 0 ? 'fixture-jamie' : `fixture-player-${index}`,
|
||||
name: index === 0 ? names[0] : `${names[index % names.length]} ${Math.floor(index / names.length) + 1}` });
|
||||
const rosterFor = (server: typeof fixtureServers[number], index: number) => Array.from({ length: server.players }, (_, slot) => ({ ...player(slot === 0 ? 0 : index * 4 + slot),
|
||||
ping: 29 + slot * 7, kills: (slot + 1) * 137, headshots: (slot + 1) * 41, downs: slot % 2, revives: slot, score: (slot + 1) * 4100 }));
|
||||
const seenAt = (server: Scenario, now: number) => iso(now - (['stopped', 'host-offline'].includes(server.state) ? 15 * MINUTE : server.state === 'stale' ? 105_000 : 0));
|
||||
const containerState = (server: Scenario) => server.state === 'stopped' ? 'exited' : server.state === 'starting' ? 'starting' : 'running';
|
||||
const instanceStates = (host: string, now: number) => fixtureServers.filter(server => server.host === host).map(server => ({
|
||||
instanceId: server.instance, generation: 1, state: containerState(server), healthy: !['stopped', 'starting'].includes(server.state),
|
||||
observedAt: iso(now - (host === 'fixture-host-3' ? 10 * MINUTE : 0))
|
||||
}));
|
||||
const player = (index: number) => ({
|
||||
guid: index === 0 ? 'fixture-jamie' : `fixture-player-${index}`,
|
||||
name:
|
||||
index === 0
|
||||
? names[0]
|
||||
: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`
|
||||
});
|
||||
const rosterFor = (server: (typeof fixtureServers)[number], index: number) =>
|
||||
Array.from({ length: server.players }, (_, slot) => ({
|
||||
...player(slot === 0 ? 0 : index * 4 + slot),
|
||||
ping: 29 + slot * 7,
|
||||
kills: (slot + 1) * 137,
|
||||
headshots: (slot + 1) * 41,
|
||||
downs: slot % 2,
|
||||
revives: slot,
|
||||
score: (slot + 1) * 4100
|
||||
}));
|
||||
const seenAt = (server: Scenario, now: number) =>
|
||||
iso(
|
||||
now -
|
||||
(['stopped', 'host-offline'].includes(server.state)
|
||||
? 15 * MINUTE
|
||||
: server.state === 'stale'
|
||||
? 105_000
|
||||
: 0)
|
||||
);
|
||||
const containerState = (server: Scenario) =>
|
||||
server.state === 'stopped' ? 'exited' : server.state === 'starting' ? 'starting' : 'running';
|
||||
const instanceStates = (host: string, now: number) =>
|
||||
fixtureServers
|
||||
.filter((server) => server.host === host)
|
||||
.map((server) => ({
|
||||
instanceId: server.instance,
|
||||
generation: 1,
|
||||
state: containerState(server),
|
||||
healthy: !['stopped', 'starting'].includes(server.state),
|
||||
observedAt: iso(now - (host === 'fixture-host-3' ? 10 * MINUTE : 0))
|
||||
}));
|
||||
|
||||
/** Uses only a caller-owned, fully migrated database. No host or worker code is imported. */
|
||||
export function seedFixtureData(handle: Database.Database, now: number): void {
|
||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
||||
const occupied = ['servers', 'leaderboard_matches', 'ec_hosts', 'auth_users', 'scripts', 'server_deployments', 'deployments', 'betas', 'game_file_libraries']
|
||||
.some(table => handle.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get());
|
||||
if (occupied || handle.prepare('SELECT 1 FROM settings WHERE key=?').get(FIXTURE_KEY)) throw new Error('Fixture seeding requires a new database.');
|
||||
const occupied = [
|
||||
'servers',
|
||||
'leaderboard_matches',
|
||||
'ec_hosts',
|
||||
'auth_users',
|
||||
'scripts',
|
||||
'server_deployments',
|
||||
'deployments',
|
||||
'betas',
|
||||
'game_file_libraries'
|
||||
].some((table) => handle.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get());
|
||||
if (occupied || handle.prepare('SELECT 1 FROM settings WHERE key=?').get(FIXTURE_KEY))
|
||||
throw new Error('Fixture seeding requires a new database.');
|
||||
const insert = (table: string, values: Record<string, unknown>) => {
|
||||
const keys = Object.keys(values);
|
||||
handle.prepare(`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...Object.values(values));
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map(() => '?').join(',')})`
|
||||
)
|
||||
.run(...Object.values(values));
|
||||
};
|
||||
const started = iso(now - 30 * DAY);
|
||||
const rememberPlayer = handle.prepare(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)
|
||||
const rememberPlayer =
|
||||
handle.prepare(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(game,guid) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||
handle.transaction(() => {
|
||||
insert('settings', { key: FIXTURE_KEY, value: FIXTURE_VERSION });
|
||||
// Preview observations must never opt into automatic lifecycle changes.
|
||||
for (const [key, value] of Object.entries({ maintenance_paused: '1', maintenance_health: '0', maintenance_drift: '0', maintenance_updates: '0', watchdog_enabled: '0', autoscaling_paused: '1' })) {
|
||||
handle.prepare('INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value);
|
||||
for (const [key, value] of Object.entries({
|
||||
maintenance_paused: '1',
|
||||
maintenance_health: '0',
|
||||
maintenance_drift: '0',
|
||||
maintenance_updates: '0',
|
||||
watchdog_enabled: '0',
|
||||
autoscaling_paused: '1'
|
||||
})) {
|
||||
handle
|
||||
.prepare(
|
||||
'INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value'
|
||||
)
|
||||
.run(key, value);
|
||||
}
|
||||
for (const [index, name] of ['Chicago · Primary', 'Dallas · Overflow', 'Frankfurt · Europe'].entries()) {
|
||||
for (const [index, name] of [
|
||||
'Chicago · Primary',
|
||||
'Dallas · Overflow',
|
||||
'Frankfurt · Europe'
|
||||
].entries()) {
|
||||
const id = `fixture-host-${index + 1}`;
|
||||
insert('ec_hosts', { id, name, hostname: `host-${index + 1}.fixture.invalid`, created_at: started, enrolled_at: started,
|
||||
last_heartbeat: iso(now - (index === 2 ? 10 * MINUTE : 0)), inventory: JSON.stringify({ protocolVersion: 1, agentVersion: '1.7.0', cpuCount: 16,
|
||||
cpuUsagePercent: 24 + index * 9, memoryTotal: 32 * GIB, memoryAvailable: (20 - index * 3) * GIB,
|
||||
storageTotal: 500 * GIB, storageAvailable: 240 * GIB, dockerRunning: true, directoriesWritable: true,
|
||||
runtimeImages: [image], libraries: [{ id: 'fixture-t5', state: 'ready' }, { id: 'fixture-t6', state: 'ready' }],
|
||||
capabilities: ['instance.heartbeat-state'], instanceStates: instanceStates(id, now) }) });
|
||||
insert('ec_hosts', {
|
||||
id,
|
||||
name,
|
||||
hostname: `host-${index + 1}.fixture.invalid`,
|
||||
created_at: started,
|
||||
enrolled_at: started,
|
||||
last_heartbeat: iso(now - (index === 2 ? 10 * MINUTE : 0)),
|
||||
inventory: JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
agentVersion: '1.7.0',
|
||||
cpuCount: 16,
|
||||
cpuUsagePercent: 24 + index * 9,
|
||||
memoryTotal: 32 * GIB,
|
||||
memoryAvailable: (20 - index * 3) * GIB,
|
||||
storageTotal: 500 * GIB,
|
||||
storageAvailable: 240 * GIB,
|
||||
dockerRunning: true,
|
||||
directoriesWritable: true,
|
||||
runtimeImages: [image],
|
||||
libraries: [
|
||||
{ id: 'fixture-t5', state: 'ready' },
|
||||
{ id: 'fixture-t6', state: 'ready' }
|
||||
],
|
||||
capabilities: ['instance.heartbeat-state'],
|
||||
instanceStates: instanceStates(id, now)
|
||||
})
|
||||
});
|
||||
// Deliberately not a token hash; no bearer token can authenticate as these agents.
|
||||
insert('ec_host_credentials', { id: `fixture-credential-${index + 1}`, host_id: id, token_hash: `invalid-fixture-${index + 1}`, created_at: started });
|
||||
insert('ec_host_credentials', {
|
||||
id: `fixture-credential-${index + 1}`,
|
||||
host_id: id,
|
||||
token_hash: `invalid-fixture-${index + 1}`,
|
||||
created_at: started
|
||||
});
|
||||
for (let minute = 0; minute < 7 * 1440; minute += 15) {
|
||||
insert('ec_host_resource_samples', { host_id: id, sampled_at: iso(now - minute * MINUTE), cpu_count: 16,
|
||||
cpu_usage_percent: 28 + index * 6 + 10 * Math.sin(minute / 95), memory_total: 32 * GIB,
|
||||
memory_available: Math.round((18 - index * 2 + Math.sin(minute / 110)) * GIB) });
|
||||
insert('ec_host_resource_samples', {
|
||||
host_id: id,
|
||||
sampled_at: iso(now - minute * MINUTE),
|
||||
cpu_count: 16,
|
||||
cpu_usage_percent: 28 + index * 6 + 10 * Math.sin(minute / 95),
|
||||
memory_total: 32 * GIB,
|
||||
memory_available: Math.round((18 - index * 2 + Math.sin(minute / 110)) * GIB)
|
||||
});
|
||||
}
|
||||
}
|
||||
let liveId = 495;
|
||||
for (const [index, server] of fixtureServers.entries()) {
|
||||
const beginning = iso(now - server.minutes * MINUTE - 12_000);
|
||||
const roster = rosterFor(server, index);
|
||||
insert('servers', { name: server.name, game: server.game, map: server.map.canonical, map_slug: server.slug, game_mode: 'zombie',
|
||||
game_port: server.port, game_id: server.game === 't5' ? 't5sp' : 't6zm', image,
|
||||
created_by: 'development fixture', created_at: started, last_players: server.players, last_map: server.map.canonical, last_gametype: 'zclassic',
|
||||
last_roster: JSON.stringify(roster), last_seen_at: seenAt(server, now),
|
||||
last_checked_at: iso(now), last_error: server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||
insert('servers', {
|
||||
name: server.name,
|
||||
game: server.game,
|
||||
map: server.map.canonical,
|
||||
map_slug: server.slug,
|
||||
game_mode: 'zombie',
|
||||
game_port: server.port,
|
||||
game_id: server.game === 't5' ? 't5sp' : 't6zm',
|
||||
image,
|
||||
created_by: 'development fixture',
|
||||
created_at: started,
|
||||
last_players: server.players,
|
||||
last_map: server.map.canonical,
|
||||
last_gametype: 'zclassic',
|
||||
last_roster: JSON.stringify(roster),
|
||||
last_seen_at: seenAt(server, now),
|
||||
last_checked_at: iso(now),
|
||||
last_error: server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||
rcon_failures: server.state === 'stale' ? 2 : 0,
|
||||
container_state: containerState(server),
|
||||
container_checked_at: iso(now), desired_state: server.state === 'stopped' ? 'stopped' : 'running',
|
||||
active_since: server.players ? beginning : null, inactive_since: server.players ? null : iso(now - 5 * MINUTE),
|
||||
game_started_at: server.players ? beginning : null });
|
||||
insert('native_instances', { id: server.instance, server: server.name, host_id: server.host,
|
||||
library_id: `fixture-${server.game}`, image, port: server.port, created_at: started });
|
||||
insert('plutonium_versions', { server: server.name, revision: 4878, checked_at: iso(now), error: null });
|
||||
container_checked_at: iso(now),
|
||||
desired_state: server.state === 'stopped' ? 'stopped' : 'running',
|
||||
active_since: server.players ? beginning : null,
|
||||
inactive_since: server.players ? null : iso(now - 5 * MINUTE),
|
||||
game_started_at: server.players ? beginning : null
|
||||
});
|
||||
insert('native_instances', {
|
||||
id: server.instance,
|
||||
server: server.name,
|
||||
host_id: server.host,
|
||||
library_id: `fixture-${server.game}`,
|
||||
image,
|
||||
port: server.port,
|
||||
created_at: started
|
||||
});
|
||||
insert('plutonium_versions', {
|
||||
server: server.name,
|
||||
revision: 4878,
|
||||
checked_at: iso(now),
|
||||
error: null
|
||||
});
|
||||
if (server.state === 'active' || server.state === 'idle') {
|
||||
const matchId = server.players ? liveId++ : null;
|
||||
const payload = { server: server.name, map: server.map.canonical, event: 'heartbeat', round: server.round, players: roster, receivedAt: iso(now),
|
||||
timing: { matchId, players: Object.fromEntries(roster.map(p => [p.guid, { serverJoinedAt: beginning, matchJoinedAt: beginning, roundStarted: 1 }])) } };
|
||||
insert('server_telemetry', { server: server.name, round: server.round, players: server.players, received_at: iso(now), payload: JSON.stringify(payload) });
|
||||
const payload = {
|
||||
server: server.name,
|
||||
map: server.map.canonical,
|
||||
event: 'heartbeat',
|
||||
round: server.round,
|
||||
players: roster,
|
||||
receivedAt: iso(now),
|
||||
timing: {
|
||||
matchId,
|
||||
players: Object.fromEntries(
|
||||
roster.map((p) => [
|
||||
p.guid,
|
||||
{
|
||||
serverJoinedAt: beginning,
|
||||
matchJoinedAt: beginning,
|
||||
roundStarted: 1
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
};
|
||||
insert('server_telemetry', {
|
||||
server: server.name,
|
||||
round: server.round,
|
||||
players: server.players,
|
||||
received_at: iso(now),
|
||||
payload: JSON.stringify(payload)
|
||||
});
|
||||
if (matchId !== null) {
|
||||
insert('leaderboard_matches', { id: matchId, server: server.name, game: server.game, map: server.map.canonical,
|
||||
map_key: server.slug, map_name: server.map.friendly, started_at: beginning, last_received_at: iso(now), status: 'active',
|
||||
start_observed: 1, start_players: server.players, highest_round: server.round, last_event: 'heartbeat', last_fingerprint: `fixture-live-${index}` });
|
||||
insert('leaderboard_matches', {
|
||||
id: matchId,
|
||||
server: server.name,
|
||||
game: server.game,
|
||||
map: server.map.canonical,
|
||||
map_key: server.slug,
|
||||
map_name: server.map.friendly,
|
||||
started_at: beginning,
|
||||
last_received_at: iso(now),
|
||||
status: 'active',
|
||||
start_observed: 1,
|
||||
start_players: server.players,
|
||||
highest_round: server.round,
|
||||
last_event: 'heartbeat',
|
||||
last_fingerprint: `fixture-live-${index}`
|
||||
});
|
||||
for (const p of roster) {
|
||||
rememberPlayer.run(server.game, p.guid, p.name, beginning, iso(now));
|
||||
insert('leaderboard_match_players', { match_id: matchId, guid: p.guid, highest_round: server.round, rounds_survived: server.round,
|
||||
kills: p.kills, headshots: p.headshots, downs: p.downs, revives: p.revives, best_score: p.score,
|
||||
last_round: server.round, present: 1, has_stats: 1, completed: 0, first_seen_at: beginning, last_seen_at: iso(now),
|
||||
first_observed_round: 1, present_at_start: 1 });
|
||||
insert('leaderboard_match_players', {
|
||||
match_id: matchId,
|
||||
guid: p.guid,
|
||||
highest_round: server.round,
|
||||
rounds_survived: server.round,
|
||||
kills: p.kills,
|
||||
headshots: p.headshots,
|
||||
downs: p.downs,
|
||||
revives: p.revives,
|
||||
best_score: p.score,
|
||||
last_round: server.round,
|
||||
present: 1,
|
||||
has_stats: 1,
|
||||
completed: 0,
|
||||
first_seen_at: beginning,
|
||||
last_seen_at: iso(now),
|
||||
first_observed_round: 1,
|
||||
present_at_start: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const phase of ['startup', 'running']) {
|
||||
for (let minute = 0; minute < 60; minute++) insert('ec_instance_resource_samples', {
|
||||
host_id: server.host, instance_id: server.instance, generation: 1, phase, bucket: iso(now - minute * MINUTE),
|
||||
cpu_cores: phase === 'startup' ? 0.7 + index / 40 : 0.25 + index / 50,
|
||||
memory_bytes: Math.round((phase === 'startup' ? 1.1 : 0.7) * GIB) });
|
||||
for (let minute = 0; minute < 60; minute++)
|
||||
insert('ec_instance_resource_samples', {
|
||||
host_id: server.host,
|
||||
instance_id: server.instance,
|
||||
generation: 1,
|
||||
phase,
|
||||
bucket: iso(now - minute * MINUTE),
|
||||
cpu_cores: phase === 'startup' ? 0.7 + index / 40 : 0.25 + index / 50,
|
||||
memory_bytes: Math.round((phase === 'startup' ? 1.1 : 0.7) * GIB)
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < 494; index++) {
|
||||
// A large Kino duo cohort exercises ranking and profile pagination.
|
||||
const server = fixtureServers[index < 64 ? 0 : index % fixtureServers.length];
|
||||
const squad = index < 64 ? 2 : Math.floor(index / 12) % 4 + 1;
|
||||
const squad = index < 64 ? 2 : (Math.floor(index / 12) % 4) + 1;
|
||||
const unknownStart = index >= 64 && index % 37 === 0;
|
||||
const interrupted = index >= 64 && index % 31 === 0;
|
||||
const end = now - (180 + index * 37) * MINUTE;
|
||||
const beginning = end - (30 + index % 100) * MINUTE;
|
||||
const round = 10 + index * 7 % 56;
|
||||
insert('leaderboard_matches', { id: index + 1, server: server.name, game: server.game, map: server.map.canonical,
|
||||
map_key: server.slug, map_name: server.map.friendly, started_at: iso(beginning), ended_at: iso(end), last_received_at: iso(end),
|
||||
status: interrupted ? 'interrupted' : 'completed', start_observed: unknownStart ? 0 : 1, start_players: unknownStart ? null : squad,
|
||||
highest_round: round, last_event: interrupted ? 'interrupted' : 'game_end', last_fingerprint: `fixture-match-${index + 1}` });
|
||||
const beginning = end - (30 + (index % 100)) * MINUTE;
|
||||
const round = 10 + ((index * 7) % 56);
|
||||
insert('leaderboard_matches', {
|
||||
id: index + 1,
|
||||
server: server.name,
|
||||
game: server.game,
|
||||
map: server.map.canonical,
|
||||
map_key: server.slug,
|
||||
map_name: server.map.friendly,
|
||||
started_at: iso(beginning),
|
||||
ended_at: iso(end),
|
||||
last_received_at: iso(end),
|
||||
status: interrupted ? 'interrupted' : 'completed',
|
||||
start_observed: unknownStart ? 0 : 1,
|
||||
start_players: unknownStart ? null : squad,
|
||||
highest_round: round,
|
||||
last_event: interrupted ? 'interrupted' : 'game_end',
|
||||
last_fingerprint: `fixture-match-${index + 1}`
|
||||
});
|
||||
for (let slot = 0; slot < squad; slot++) {
|
||||
const p = player(slot === 0 ? 0 : (index + slot * 19) % 80 + 1);
|
||||
const p = player(slot === 0 ? 0 : ((index + slot * 19) % 80) + 1);
|
||||
rememberPlayer.run(server.game, p.guid, p.name, iso(beginning), iso(end));
|
||||
insert('leaderboard_match_players', { match_id: index + 1, guid: p.guid, highest_round: round, rounds_survived: round - (unknownStart ? 4 : 0),
|
||||
kills: round * 35 + slot * 71, headshots: round * 9, downs: index % 4, revives: slot + index % 3,
|
||||
best_score: round * 2100 + slot * 1000, last_round: round, present: slot > 0 && index % 9 === 0 ? 0 : 1, has_stats: 1,
|
||||
completed: !interrupted && !(slot > 0 && index % 9 === 0) ? 1 : 0, first_seen_at: iso(beginning), last_seen_at: iso(end),
|
||||
first_observed_round: unknownStart ? 5 : 1, present_at_start: unknownStart ? null : 1 });
|
||||
insert('leaderboard_match_players', {
|
||||
match_id: index + 1,
|
||||
guid: p.guid,
|
||||
highest_round: round,
|
||||
rounds_survived: round - (unknownStart ? 4 : 0),
|
||||
kills: round * 35 + slot * 71,
|
||||
headshots: round * 9,
|
||||
downs: index % 4,
|
||||
revives: slot + (index % 3),
|
||||
best_score: round * 2100 + slot * 1000,
|
||||
last_round: round,
|
||||
present: slot > 0 && index % 9 === 0 ? 0 : 1,
|
||||
has_stats: 1,
|
||||
completed: !interrupted && !(slot > 0 && index % 9 === 0) ? 1 : 0,
|
||||
first_seen_at: iso(beginning),
|
||||
last_seen_at: iso(end),
|
||||
first_observed_round: unknownStart ? 5 : 1,
|
||||
present_at_start: unknownStart ? null : 1
|
||||
});
|
||||
}
|
||||
}
|
||||
const metric = handle.prepare('INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,4)');
|
||||
const metric = handle.prepare(
|
||||
'INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,4)'
|
||||
);
|
||||
for (let minute = 0; minute < 7 * 1440; minute++) {
|
||||
for (const [index, server] of fixtureServers.entries()) {
|
||||
const count = Math.floor((Math.sin(minute / 90 + index) + 1) * 2.49);
|
||||
metric.run(now - minute * MINUTE, server.name, server.game, server.map.friendly,
|
||||
minute % 720 < 8 && index === 5 ? null : count);
|
||||
metric.run(
|
||||
now - minute * MINUTE,
|
||||
server.name,
|
||||
server.game,
|
||||
server.map.friendly,
|
||||
minute % 720 < 8 && index === 5 ? null : count
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const scope of ['*', ...[1, 2, 3].map(index => `fixture-host-${index}`)]) for (const phase of ['startup', 'running']) {
|
||||
insert('ec_resource_allowances', { scope, phase, cpu_cores: phase === 'startup' ? 1 : 0.5,
|
||||
memory_bytes: Math.round((phase === 'startup' ? 1.2 : 0.8) * GIB), samples: 240,
|
||||
window_start: iso(now - DAY), observed_at: iso(now), computed_at: iso(now), usage_margin: 1 });
|
||||
}
|
||||
for (const scope of ['*', ...[1, 2, 3].map((index) => `fixture-host-${index}`)])
|
||||
for (const phase of ['startup', 'running']) {
|
||||
insert('ec_resource_allowances', {
|
||||
scope,
|
||||
phase,
|
||||
cpu_cores: phase === 'startup' ? 1 : 0.5,
|
||||
memory_bytes: Math.round((phase === 'startup' ? 1.2 : 0.8) * GIB),
|
||||
samples: 240,
|
||||
window_start: iso(now - DAY),
|
||||
observed_at: iso(now),
|
||||
computed_at: iso(now),
|
||||
usage_margin: 1
|
||||
});
|
||||
}
|
||||
// Real artifact blobs, so details and downloads work without game files.
|
||||
for (const [index, kind] of ['script', 'baseline', 'config'].entries()) {
|
||||
const content = Buffer.from(kind === 'script' ? '// Synthetic UI fixture; no gameplay behavior.\n' : '// Synthetic UI fixture configuration.\nset sv_maxclients 4\n');
|
||||
const content = Buffer.from(
|
||||
kind === 'script'
|
||||
? '// Synthetic UI fixture; no gameplay behavior.\n'
|
||||
: '// Synthetic UI fixture configuration.\nset sv_maxclients 4\n'
|
||||
);
|
||||
const hash = createHash('sha256').update(content).digest('hex');
|
||||
handle.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)').run(hash, content.length, content);
|
||||
insert('scripts', { id: index + 1, name: ['Fixture telemetry', 'Fixture Black Ops I baseline', 'Fixture lobby settings'][index],
|
||||
target_rel_path: index === 0 ? 'fixture.gsc' : index === 1 ? 'dedicated_sp.cfg' : 'fixture.cfg',
|
||||
scope_type: 'game', scope_value: 't5', current_version_id: index + 1, kind, created_at: started });
|
||||
insert('script_versions', { id: index + 1, script_id: index + 1, version: 1, sha256: hash, content_hash: hash,
|
||||
size: content.length, notes: 'Synthetic preview content.', state: 'tested', kind, uploaded_at: started });
|
||||
handle
|
||||
.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)')
|
||||
.run(hash, content.length, content);
|
||||
insert('scripts', {
|
||||
id: index + 1,
|
||||
name: [
|
||||
'Fixture telemetry',
|
||||
'Fixture Black Ops I baseline',
|
||||
'Fixture lobby settings'
|
||||
][index],
|
||||
target_rel_path:
|
||||
index === 0 ? 'fixture.gsc' : index === 1 ? 'dedicated_sp.cfg' : 'fixture.cfg',
|
||||
scope_type: 'game',
|
||||
scope_value: 't5',
|
||||
current_version_id: index + 1,
|
||||
kind,
|
||||
created_at: started
|
||||
});
|
||||
insert('script_versions', {
|
||||
id: index + 1,
|
||||
script_id: index + 1,
|
||||
version: 1,
|
||||
sha256: hash,
|
||||
content_hash: hash,
|
||||
size: content.length,
|
||||
notes: 'Synthetic preview content.',
|
||||
state: 'tested',
|
||||
kind,
|
||||
uploaded_at: started
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 36; index++) {
|
||||
const failed = index % 7 === 0;
|
||||
const server = fixtureServers[failed ? 5 : index % fixtureServers.length];
|
||||
const id = `fixture-operation-${index + 1}`;
|
||||
const state = failed ? 'failed' : index % 11 === 0 ? 'cancelled' : 'done';
|
||||
insert('server_deployments', { id, server: server.name, action: ['restart', 'start', 'rebuild', 'stop'][index % 4], state,
|
||||
stage: failed ? 'Verification failed' : state === 'done' ? 'Completed' : 'Cancelled before dispatch',
|
||||
started_at: iso(now - (60 + index * 2) * MINUTE), finished_at: iso(now - (59 + index * 2) * MINUTE), triggered_by: 'Fixture operator',
|
||||
error: failed ? 'Synthetic verification timeout. No host command was sent.' : null, warnings: '[]' });
|
||||
insert('native_provisioning', { deployment_id: id, instance_id: server.instance, generation: 1 });
|
||||
insert('server_deployment_events', { deployment_id: id, created_at: iso(now - (59 + index * 2) * MINUTE), message: 'Synthetic retained history; no command was sent.' });
|
||||
insert('server_deployments', {
|
||||
id,
|
||||
server: server.name,
|
||||
action: ['restart', 'start', 'rebuild', 'stop'][index % 4],
|
||||
state,
|
||||
stage: failed
|
||||
? 'Verification failed'
|
||||
: state === 'done'
|
||||
? 'Completed'
|
||||
: 'Cancelled before dispatch',
|
||||
started_at: iso(now - (60 + index * 2) * MINUTE),
|
||||
finished_at: iso(now - (59 + index * 2) * MINUTE),
|
||||
triggered_by: 'Fixture operator',
|
||||
error: failed ? 'Synthetic verification timeout. No host command was sent.' : null,
|
||||
warnings: '[]'
|
||||
});
|
||||
insert('native_provisioning', {
|
||||
deployment_id: id,
|
||||
instance_id: server.instance,
|
||||
generation: 1
|
||||
});
|
||||
insert('server_deployment_events', {
|
||||
deployment_id: id,
|
||||
created_at: iso(now - (59 + index * 2) * MINUTE),
|
||||
message: 'Synthetic retained history; no command was sent.'
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const server = fixtureServers[index % fixtureServers.length];
|
||||
insert('ec_host_operations', { id: `fixture-host-command-${index + 1}`, host_id: server.host, instance_id: server.instance,
|
||||
server_name: server.name, kind: index % 2 ? 'instance.inspect' : 'instance.restart', payload: '{}',
|
||||
state: index % 3 ? 'completed' : 'failed', result: '{}', error: index % 3 ? null : 'Synthetic command failure.',
|
||||
created_at: iso(now - (90 + index * 3) * MINUTE), updated_at: iso(now - (89 + index * 3) * MINUTE), source: 'Fixture operator' });
|
||||
insert('ec_host_operations', {
|
||||
id: `fixture-host-command-${index + 1}`,
|
||||
host_id: server.host,
|
||||
instance_id: server.instance,
|
||||
server_name: server.name,
|
||||
kind: index % 2 ? 'instance.inspect' : 'instance.restart',
|
||||
payload: '{}',
|
||||
state: index % 3 ? 'completed' : 'failed',
|
||||
result: '{}',
|
||||
error: index % 3 ? null : 'Synthetic command failure.',
|
||||
created_at: iso(now - (90 + index * 3) * MINUTE),
|
||||
updated_at: iso(now - (89 + index * 3) * MINUTE),
|
||||
source: 'Fixture operator'
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const server = fixtureServers[index === 0 ? 1 : index === 1 ? 5 : index];
|
||||
insert('maintenance_jobs', { id: index + 1, server: server.name, state: index === 1 ? 'failed' : 'done', target_revision: 4878,
|
||||
requested_at: iso(now - (120 + index * 4) * MINUTE), finished_at: iso(now - (119 + index * 4) * MINUTE),
|
||||
detail: index === 1 ? 'Synthetic update could not be verified.' : 'Synthetic scheduled restart completed.', kind: 'schedule', origin: 'manual',
|
||||
provisioning_id: index === 0 ? 'fixture-operation-2' : null });
|
||||
insert('watchdog_incidents', { id: `fixture-recovery-${index + 1}`, server: server.name, kind: 'health', state: 'resolved',
|
||||
summary: 'Synthetic recovery finding resolved.', details: '{}', first_seen_at: iso(now - (130 + index * 4) * MINUTE),
|
||||
last_seen_at: iso(now - (129 + index * 4) * MINUTE), finished_at: iso(now - (129 + index * 4) * MINUTE) });
|
||||
insert('deployments', { id: `fixture-campaign-${index + 1}`, action: 'drift', started_at: iso(now - (140 + index * 4) * MINUTE),
|
||||
finished_at: iso(now - (139 + index * 4) * MINUTE), triggered_by: 'Fixture operator' });
|
||||
insert('deployment_servers', { deployment_id: `fixture-campaign-${index + 1}`, server: server.name, files_changed: 1, restart_state: 'done', players: 0 });
|
||||
insert('maintenance_jobs', {
|
||||
id: index + 1,
|
||||
server: server.name,
|
||||
state: index === 1 ? 'failed' : 'done',
|
||||
target_revision: 4878,
|
||||
requested_at: iso(now - (120 + index * 4) * MINUTE),
|
||||
finished_at: iso(now - (119 + index * 4) * MINUTE),
|
||||
detail:
|
||||
index === 1
|
||||
? 'Synthetic update could not be verified.'
|
||||
: 'Synthetic scheduled restart completed.',
|
||||
kind: 'schedule',
|
||||
origin: 'manual',
|
||||
provisioning_id: index === 0 ? 'fixture-operation-2' : null
|
||||
});
|
||||
insert('watchdog_incidents', {
|
||||
id: `fixture-recovery-${index + 1}`,
|
||||
server: server.name,
|
||||
kind: 'health',
|
||||
state: 'resolved',
|
||||
summary: 'Synthetic recovery finding resolved.',
|
||||
details: '{}',
|
||||
first_seen_at: iso(now - (130 + index * 4) * MINUTE),
|
||||
last_seen_at: iso(now - (129 + index * 4) * MINUTE),
|
||||
finished_at: iso(now - (129 + index * 4) * MINUTE)
|
||||
});
|
||||
insert('deployments', {
|
||||
id: `fixture-campaign-${index + 1}`,
|
||||
action: 'drift',
|
||||
started_at: iso(now - (140 + index * 4) * MINUTE),
|
||||
finished_at: iso(now - (139 + index * 4) * MINUTE),
|
||||
triggered_by: 'Fixture operator'
|
||||
});
|
||||
insert('deployment_servers', {
|
||||
deployment_id: `fixture-campaign-${index + 1}`,
|
||||
server: server.name,
|
||||
files_changed: 1,
|
||||
restart_state: 'done',
|
||||
players: 0
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 2; index++) {
|
||||
insert('betas', { id: index + 1, script_id: 1, script_version_id: 1, trigger_kind: 'ui', state: index ? 'rolled-back' : 'promoted',
|
||||
opened_at: iso(now - (150 + index * 3) * MINUTE), closed_at: iso(now - (149 + index * 3) * MINUTE), notes: 'Synthetic completed beta.' });
|
||||
insert('beta_scripts', { beta_id: index + 1, script_id: 1, script_version_id: 1, kind: 'script' });
|
||||
insert('beta_servers', { beta_id: index + 1, server: fixtureServers[index].name, had_original: 0, stage: 'ready', updated_at: iso(now - (149 + index * 3) * MINUTE) });
|
||||
insert('betas', {
|
||||
id: index + 1,
|
||||
script_id: 1,
|
||||
script_version_id: 1,
|
||||
trigger_kind: 'ui',
|
||||
state: index ? 'rolled-back' : 'promoted',
|
||||
opened_at: iso(now - (150 + index * 3) * MINUTE),
|
||||
closed_at: iso(now - (149 + index * 3) * MINUTE),
|
||||
notes: 'Synthetic completed beta.'
|
||||
});
|
||||
insert('beta_scripts', {
|
||||
beta_id: index + 1,
|
||||
script_id: 1,
|
||||
script_version_id: 1,
|
||||
kind: 'script'
|
||||
});
|
||||
insert('beta_servers', {
|
||||
beta_id: index + 1,
|
||||
server: fixtureServers[index].name,
|
||||
had_original: 0,
|
||||
stage: 'ready',
|
||||
updated_at: iso(now - (149 + index * 3) * MINUTE)
|
||||
});
|
||||
}
|
||||
insert('provisioning_sources', { operation_id: 'fixture-operation-2', kind: 'maintenance', source_id: '1', server: fixtureServers[1].name });
|
||||
insert('provisioning_sources', { operation_id: 'fixture-operation-3', kind: 'autoscaling', source_id: 'fixture-paused-plan', server: fixtureServers[2].name });
|
||||
insert('provisioning_sources', {
|
||||
operation_id: 'fixture-operation-2',
|
||||
kind: 'maintenance',
|
||||
source_id: '1',
|
||||
server: fixtureServers[1].name
|
||||
});
|
||||
insert('provisioning_sources', {
|
||||
operation_id: 'fixture-operation-3',
|
||||
kind: 'autoscaling',
|
||||
source_id: 'fixture-paused-plan',
|
||||
server: fixtureServers[2].name
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
/** Refreshes existing synthetic observations only; never queues work or writes real files. */
|
||||
export function refreshFixtureData(handle: Database.Database, now: number): void {
|
||||
const marker = handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY) as { value: string } | undefined;
|
||||
if (marker?.value !== FIXTURE_VERSION) throw new Error('Refusing to refresh an unmarked database.');
|
||||
const marker = handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY) as
|
||||
{ value: string } | undefined;
|
||||
if (marker?.value !== FIXTURE_VERSION)
|
||||
throw new Error('Refusing to refresh an unmarked database.');
|
||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
||||
handle.transaction(() => {
|
||||
for (const [index, server] of fixtureServers.entries()) {
|
||||
handle.prepare(`UPDATE servers SET last_seen_at=?,last_checked_at=?,container_checked_at=?,container_state=?,
|
||||
last_players=?,last_map=?,last_roster=?,last_error=?,rcon_failures=?,last_reply_snippet=NULL WHERE name=?`)
|
||||
.run(seenAt(server, now), iso(now), iso(now), containerState(server), server.players, server.map.canonical,
|
||||
JSON.stringify(rosterFor(server, index)), server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||
server.state === 'stale' ? 2 : 0, server.name);
|
||||
handle.prepare(`UPDATE server_telemetry SET received_at=?,payload=json_set(payload,'$.receivedAt',?) WHERE server=?`).run(iso(now), iso(now), server.name);
|
||||
handle.prepare("UPDATE leaderboard_matches SET last_received_at=? WHERE server=? AND status='active' AND id BETWEEN 495 AND 500").run(iso(now), server.name);
|
||||
handle.prepare('UPDATE plutonium_versions SET checked_at=? WHERE server=?').run(iso(now), server.name);
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE servers SET last_seen_at=?,last_checked_at=?,container_checked_at=?,container_state=?,
|
||||
last_players=?,last_map=?,last_roster=?,last_error=?,rcon_failures=?,last_reply_snippet=NULL WHERE name=?`
|
||||
)
|
||||
.run(
|
||||
seenAt(server, now),
|
||||
iso(now),
|
||||
iso(now),
|
||||
containerState(server),
|
||||
server.players,
|
||||
server.map.canonical,
|
||||
JSON.stringify(rosterFor(server, index)),
|
||||
server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||
server.state === 'stale' ? 2 : 0,
|
||||
server.name
|
||||
);
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE server_telemetry SET received_at=?,payload=json_set(payload,'$.receivedAt',?) WHERE server=?`
|
||||
)
|
||||
.run(iso(now), iso(now), server.name);
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE leaderboard_matches SET last_received_at=? WHERE server=? AND status='active' AND id BETWEEN 495 AND 500"
|
||||
)
|
||||
.run(iso(now), server.name);
|
||||
handle
|
||||
.prepare('UPDATE plutonium_versions SET checked_at=? WHERE server=?')
|
||||
.run(iso(now), server.name);
|
||||
}
|
||||
for (let index = 1; index <= 3; index++) {
|
||||
const id = `fixture-host-${index}`;
|
||||
handle.prepare("UPDATE ec_hosts SET last_heartbeat=?,inventory=json_set(inventory,'$.instanceStates',json(?)) WHERE id=?")
|
||||
.run(iso(now - (index === 3 ? 10 * MINUTE : 0)), JSON.stringify(instanceStates(id, now)), id);
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE ec_hosts SET last_heartbeat=?,inventory=json_set(inventory,'$.instanceStates',json(?)) WHERE id=?"
|
||||
)
|
||||
.run(
|
||||
iso(now - (index === 3 ? 10 * MINUTE : 0)),
|
||||
JSON.stringify(instanceStates(id, now)),
|
||||
id
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
+36
-11
@@ -1,29 +1,49 @@
|
||||
import { mkdir, open, realpath, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
export interface FixtureOptions { dbPath: string; now: number; watch: boolean }
|
||||
export interface FixtureOptions {
|
||||
dbPath: string;
|
||||
now: number;
|
||||
watch: boolean;
|
||||
}
|
||||
|
||||
export function parseFixtureOptions(args: string[], clock = Date.now()): FixtureOptions {
|
||||
let dbPath = '', now = clock, watch = false, explicitTime = false;
|
||||
let dbPath = '',
|
||||
now = clock,
|
||||
watch = false,
|
||||
explicitTime = false;
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const option = args[index];
|
||||
if (seen.has(option)) throw new Error(`Duplicate option: ${option}`);
|
||||
seen.add(option);
|
||||
if (option === '--watch') { watch = true; continue; }
|
||||
if (option === '--watch') {
|
||||
watch = true;
|
||||
continue;
|
||||
}
|
||||
if (option !== '--db' && option !== '--now') throw new Error(`Unknown option: ${option}`);
|
||||
const value = args[++index];
|
||||
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}.`);
|
||||
if (option === '--db') dbPath = value;
|
||||
else {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
||||
|| !Number.isFinite(Date.parse(value))) throw new Error('--now must be an ISO timestamp with a timezone.');
|
||||
now = Date.parse(value); explicitTime = true;
|
||||
if (
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
||||
value
|
||||
) ||
|
||||
!Number.isFinite(Date.parse(value))
|
||||
)
|
||||
throw new Error('--now must be an ISO timestamp with a timezone.');
|
||||
now = Date.parse(value);
|
||||
explicitTime = true;
|
||||
}
|
||||
}
|
||||
if (!dbPath || !isAbsolute(dbPath)) throw new Error('Provide --db with an absolute path inside a new fixture directory. DB_PATH is never used.');
|
||||
if (!dbPath || !isAbsolute(dbPath))
|
||||
throw new Error(
|
||||
'Provide --db with an absolute path inside a new fixture directory. DB_PATH is never used.'
|
||||
);
|
||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture clock.');
|
||||
if (watch && explicitTime) throw new Error('Use --watch for live time or --now for a fixed snapshot, separately.');
|
||||
if (watch && explicitTime)
|
||||
throw new Error('Use --watch for live time or --now for a fixed snapshot, separately.');
|
||||
return { dbPath: resolve(dbPath), now, watch };
|
||||
}
|
||||
|
||||
@@ -36,9 +56,14 @@ export async function reserveFixture(dbPath: string): Promise<{ root: string; db
|
||||
// but never follow a pre-existing fixture directory or database symlink.
|
||||
const parent = await realpath(dirname(requestedRoot));
|
||||
const root = join(parent, basename(requestedRoot));
|
||||
try { await mkdir(root, { mode: 0o700 }); }
|
||||
catch (cause) {
|
||||
if ((cause as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Fixture directory already exists. Choose a new directory; existing data is never overwritten.');
|
||||
try {
|
||||
await mkdir(root, { mode: 0o700 });
|
||||
} catch (cause) {
|
||||
if ((cause as NodeJS.ErrnoException).code === 'EEXIST')
|
||||
throw new Error(
|
||||
'Fixture directory already exists. Choose a new directory; existing data is never overwritten.',
|
||||
{ cause }
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
const target = join(root, basename(requested));
|
||||
|
||||
+47
-18
@@ -18,38 +18,67 @@ function secret(prompt) {
|
||||
process.stdout.write('\n');
|
||||
};
|
||||
const listener = (text, key) => {
|
||||
if (key?.ctrl && key.name === 'c') { cleanup(); reject(new Error('Cancelled.')); }
|
||||
else if (key?.name === 'return' || key?.name === 'enter') { cleanup(); resolve(value); }
|
||||
else if (key?.name === 'backspace') value = [...value].slice(0,-1).join('');
|
||||
else if (text && !key?.ctrl && !key?.meta && !/[\x00-\x1f\x7f]/.test(text) && [...value].length < 128) value += text;
|
||||
if (key?.ctrl && key.name === 'c') {
|
||||
cleanup();
|
||||
reject(new Error('Cancelled.'));
|
||||
} else if (key?.name === 'return' || key?.name === 'enter') {
|
||||
cleanup();
|
||||
resolve(value);
|
||||
} else if (key?.name === 'backspace') value = [...value].slice(0, -1).join('');
|
||||
else if (
|
||||
text &&
|
||||
!key?.ctrl &&
|
||||
!key?.meta &&
|
||||
// eslint-disable-next-line no-control-regex -- Reject terminal control characters while collecting password input.
|
||||
!/[\x00-\x1f\x7f]/.test(text) &&
|
||||
[...value].length < 128
|
||||
)
|
||||
value += text;
|
||||
};
|
||||
process.stdin.on('keypress',listener);
|
||||
process.stdin.on('keypress', listener);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const username = process.argv[2]?.toLowerCase();
|
||||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||||
if (!process.stdin.isTTY) throw new Error('Run this command in an interactive terminal. Passwords are never accepted as command arguments.');
|
||||
const database = new Database(process.env.DB_PATH || './data/cherry.db',{fileMustExist:true});
|
||||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
|
||||
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||||
if (!process.stdin.isTTY)
|
||||
throw new Error(
|
||||
'Run this command in an interactive terminal. Passwords are never accepted as command arguments.'
|
||||
);
|
||||
const database = new Database(process.env.DB_PATH || './data/cherry.db', {
|
||||
fileMustExist: true
|
||||
});
|
||||
database.pragma('busy_timeout=5000');
|
||||
const user = database.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0").get(username);
|
||||
const user = database
|
||||
.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0")
|
||||
.get(username);
|
||||
if (!user) throw new Error('An enabled administrator with that username was not found.');
|
||||
const password = await secret('New administrator password (8–128 characters): ');
|
||||
const confirmation = await secret('Repeat password: ');
|
||||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||||
const passwordHash = await hashPassword(password);
|
||||
database.transaction(() => {
|
||||
const changed = database.prepare("UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0")
|
||||
.run(passwordHash,Date.now(),user.id);
|
||||
if (changed.changes !== 1) throw new Error('Administrator changed during recovery. Try again.');
|
||||
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
||||
database.prepare('INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)')
|
||||
.run('password.recovered',user.id,'{"source":"local-cli"}',Date.now());
|
||||
}).immediate();
|
||||
database
|
||||
.transaction(() => {
|
||||
const changed = database
|
||||
.prepare(
|
||||
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
|
||||
)
|
||||
.run(passwordHash, Date.now(), user.id);
|
||||
if (changed.changes !== 1)
|
||||
throw new Error('Administrator changed during recovery. Try again.');
|
||||
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
|
||||
)
|
||||
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
|
||||
})
|
||||
.immediate();
|
||||
database.close();
|
||||
console.log('Administrator password reset. Existing sessions have been revoked.');
|
||||
} catch (cause) {
|
||||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||||
process.exitCode=1;
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
+127
-34
@@ -5,7 +5,13 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { FIXTURE_KEY, FIXTURE_VERSION, fixtureServers, refreshFixtureData, seedFixtureData } from './fixture-data.js';
|
||||
import {
|
||||
FIXTURE_KEY,
|
||||
FIXTURE_VERSION,
|
||||
fixtureServers,
|
||||
refreshFixtureData,
|
||||
seedFixtureData
|
||||
} from './fixture-data.js';
|
||||
import { parseFixtureOptions, reserveFixture } from './fixture-options.js';
|
||||
|
||||
const help = `Create an isolated synthetic UI preview database.
|
||||
@@ -22,7 +28,10 @@ No web server, worker, game agent or network request is started by this command.
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(help); return; }
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
||||
console.log(help);
|
||||
return;
|
||||
}
|
||||
const options = parseFixtureOptions(args);
|
||||
process.umask(0o077);
|
||||
const destination = await reserveFixture(options.dbPath);
|
||||
@@ -30,70 +39,154 @@ async function main() {
|
||||
let closeDb: (() => void) | undefined;
|
||||
let complete = false;
|
||||
try {
|
||||
const paths = { DB_PATH: destination.dbPath, SERVERS_ROOT: join(destination.root, 't5'), T6_SERVERS_ROOT: join(destination.root, 't6'),
|
||||
BETA_ARCHIVE_DIR: join(destination.root, 'beta-archive'), GAME_FILES_ROOT: join(destination.root, 'game-files'), SQLITE_TMPDIR: join(destination.root, 'sqlite-tmp') };
|
||||
const paths = {
|
||||
DB_PATH: destination.dbPath,
|
||||
SERVERS_ROOT: join(destination.root, 't5'),
|
||||
T6_SERVERS_ROOT: join(destination.root, 't6'),
|
||||
BETA_ARCHIVE_DIR: join(destination.root, 'beta-archive'),
|
||||
GAME_FILES_ROOT: join(destination.root, 'game-files'),
|
||||
SQLITE_TMPDIR: join(destination.root, 'sqlite-tmp')
|
||||
};
|
||||
for (const [key, value] of Object.entries(paths)) {
|
||||
process.env[key] = value;
|
||||
if (key !== 'DB_PATH') await mkdir(value, { mode: 0o700 });
|
||||
}
|
||||
Object.assign(process.env, { RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
|
||||
DEPLOY_KEEP_DAYS: '7', DEPLOYMENT_RESTART_CONCURRENCY: '1', DEPLOYMENT_CREATE_CONCURRENCY: '1',
|
||||
EC_SQLITE_MMAP_MB: '0', PLUTAINER_IMAGE: 'fixture.invalid/electric-cherry/preview:local' });
|
||||
Object.assign(process.env, {
|
||||
RCON_PASSWORD: '',
|
||||
RCON_HOST: '127.0.0.1',
|
||||
EC_AUTH_ALLOW_HTTP: 'true',
|
||||
DEPLOY_KEEP_DAYS: '7',
|
||||
DEPLOYMENT_RESTART_CONCURRENCY: '1',
|
||||
DEPLOYMENT_CREATE_CONCURRENCY: '1',
|
||||
EC_SQLITE_MMAP_MB: '0',
|
||||
PLUTAINER_IMAGE: 'fixture.invalid/electric-cherry/preview:local'
|
||||
});
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
vite = await createServer({ root: repository, cacheDir: join(destination.root, 'vite-cache'), configFile: false, envFile: false, appType: 'custom',
|
||||
vite = await createServer({
|
||||
root: repository,
|
||||
cacheDir: join(destination.root, 'vite-cache'),
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
appType: 'custom',
|
||||
// Vite 5 otherwise opens its HMR WebSocket port even with hmr:false.
|
||||
// Attach it to an HTTP server that is deliberately never listened on.
|
||||
server: { middlewareMode: true, hmr: { server: createHttpServer() }, watch: null }, optimizeDeps: { noDiscovery: true },
|
||||
resolve: { alias: { $lib: join(repository, 'src/lib'), $ui: join(repository, 'src/lib/ui') } },
|
||||
logLevel: 'error' });
|
||||
server: { middlewareMode: true, hmr: { server: createHttpServer() }, watch: null },
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
resolve: {
|
||||
alias: { $lib: join(repository, 'src/lib'), $ui: join(repository, 'src/lib/ui') }
|
||||
},
|
||||
logLevel: 'error'
|
||||
});
|
||||
// Load the real migrations through Vite's ?raw support. Importing hooks
|
||||
// would start queues and agents; this script never loads the app router.
|
||||
const database = await vite.ssrLoadModule('/src/lib/server/db/index.ts') as { db: () => Database.Database; closeDb: () => void };
|
||||
const database = (await vite.ssrLoadModule('/src/lib/server/db/index.ts')) as {
|
||||
db: () => Database.Database;
|
||||
closeDb: () => void;
|
||||
};
|
||||
closeDb = database.closeDb;
|
||||
const handle = database.db();
|
||||
const hosts = await vite.ssrLoadModule('/src/lib/server/hosts/schema.ts');
|
||||
const auth = await vite.ssrLoadModule('/src/lib/server/auth/schema.ts');
|
||||
const files = await vite.ssrLoadModule('/src/lib/server/game-files/schema.ts');
|
||||
hosts.ensureHostSchema(handle); auth.ensureAuthSchema(handle); files.ensureGameFileSchema(handle);
|
||||
hosts.ensureHostSchema(handle);
|
||||
auth.ensureAuthSchema(handle);
|
||||
files.ensureGameFileSchema(handle);
|
||||
seedFixtureData(handle, options.now);
|
||||
const password = randomBytes(24).toString('base64url');
|
||||
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
|
||||
handle.prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||
VALUES('fixture-admin','demo','Demo operator',?,'admin',?,?)`).run(await hashPassword(password), options.now, options.now);
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length || handle.pragma('integrity_check', { simple: true }) !== 'ok') {
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||
VALUES('fixture-admin','demo','Demo operator',?,'admin',?,?)`
|
||||
)
|
||||
.run(await hashPassword(password), options.now, options.now);
|
||||
if (
|
||||
(handle.pragma('foreign_key_check') as unknown[]).length ||
|
||||
handle.pragma('integrity_check', { simple: true }) !== 'ok'
|
||||
) {
|
||||
throw new Error('Fixture integrity validation failed.');
|
||||
}
|
||||
const environment = { ...paths, RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
|
||||
PLUTAINER_IMAGE: process.env.PLUTAINER_IMAGE! };
|
||||
const environment = {
|
||||
...paths,
|
||||
RCON_PASSWORD: '',
|
||||
RCON_HOST: '127.0.0.1',
|
||||
EC_AUTH_ALLOW_HTTP: 'true',
|
||||
PLUTAINER_IMAGE: process.env.PLUTAINER_IMAGE!
|
||||
};
|
||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
await writeFile(join(destination.root, 'preview.env'), Object.entries(environment).map(([key, value]) => `export ${key}=${shellQuote(value)}`).join('\n') + '\n', { flag: 'wx', mode: 0o600 });
|
||||
await writeFile(join(destination.root, 'fixture.json'), JSON.stringify({ format: FIXTURE_VERSION, marker: FIXTURE_KEY,
|
||||
dbPath: destination.dbPath, createdAt: new Date(options.now).toISOString(), username: 'demo', password,
|
||||
hosts: 3, servers: fixtureServers.map(server => server.name), matches: 500,
|
||||
note: 'Synthetic local data only. Host credentials cannot authenticate; game images and library IDs are fictitious.' }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||
await writeFile(
|
||||
join(destination.root, 'preview.env'),
|
||||
Object.entries(environment)
|
||||
.map(([key, value]) => `export ${key}=${shellQuote(value)}`)
|
||||
.join('\n') + '\n',
|
||||
{ flag: 'wx', mode: 0o600 }
|
||||
);
|
||||
await writeFile(
|
||||
join(destination.root, 'fixture.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
format: FIXTURE_VERSION,
|
||||
marker: FIXTURE_KEY,
|
||||
dbPath: destination.dbPath,
|
||||
createdAt: new Date(options.now).toISOString(),
|
||||
username: 'demo',
|
||||
password,
|
||||
hosts: 3,
|
||||
servers: fixtureServers.map((server) => server.name),
|
||||
matches: 500,
|
||||
note: 'Synthetic local data only. Host credentials cannot authenticate; game images and library IDs are fictitious.'
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
{ flag: 'wx', mode: 0o600 }
|
||||
);
|
||||
handle.pragma('wal_checkpoint(TRUNCATE)');
|
||||
complete = true;
|
||||
console.log(`Created ${destination.dbPath}\n3 hosts · 12 servers · 500 matches · 7 days of metrics\nLocal sign-in details: ${join(destination.root, 'fixture.json')}\nPreview environment: ${join(destination.root, 'preview.env')}`);
|
||||
console.log(
|
||||
`Created ${destination.dbPath}\n3 hosts · 12 servers · 500 matches · 7 days of metrics\nLocal sign-in details: ${join(destination.root, 'fixture.json')}\nPreview environment: ${join(destination.root, 'preview.env')}`
|
||||
);
|
||||
if (options.watch) {
|
||||
refreshFixtureData(handle, Date.now());
|
||||
console.log('Refreshing synthetic observations every 15 seconds. Leave this terminal open; Ctrl+C stops the feeder.');
|
||||
console.log(
|
||||
'Refreshing synthetic observations every 15 seconds. Leave this terminal open; Ctrl+C stops the feeder.'
|
||||
);
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const finish = (error?: unknown) => { clearInterval(timer); process.off('SIGINT', stop); process.off('SIGTERM', stop); if (error) reject(error); else resolvePromise(); };
|
||||
const finish = (error?: unknown) => {
|
||||
clearInterval(timer);
|
||||
process.off('SIGINT', stop);
|
||||
process.off('SIGTERM', stop);
|
||||
if (error) reject(error);
|
||||
else resolvePromise();
|
||||
};
|
||||
const stop = () => finish();
|
||||
const timer = setInterval(() => { try { refreshFixtureData(handle, Date.now()); } catch (error) { finish(error); } }, 15_000);
|
||||
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
refreshFixtureData(handle, Date.now());
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
}, 15_000);
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try { closeDb?.(); }
|
||||
finally {
|
||||
try { await vite?.close(); }
|
||||
finally { if (!complete) await rm(destination.root, { recursive: true, force: true }); }
|
||||
try {
|
||||
closeDb?.();
|
||||
} finally {
|
||||
try {
|
||||
await vite?.close();
|
||||
} finally {
|
||||
if (!complete) await rm(destination.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(`Fixture creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`Fixture creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user