Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -26,8 +26,10 @@ export function migrationStep<T>(name: string, work: () => T): T {
|
||||
console.info(`[database] migration starting: ${name}`);
|
||||
try {
|
||||
const result = work();
|
||||
console.info(`[database] migration finished: ${name}`, { elapsedMs: Date.now() - started,
|
||||
rssMiB: Math.round(process.memoryUsage().rss / 1048576) });
|
||||
console.info(`[database] migration finished: ${name}`, {
|
||||
elapsedMs: Date.now() - started,
|
||||
rssMiB: Math.round(process.memoryUsage().rss / 1048576)
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[database] migration failed: ${name}`, error);
|
||||
|
||||
+101
-39
@@ -10,67 +10,103 @@ export function ensureFileBlobSchema(handle: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256);`);
|
||||
}
|
||||
|
||||
export function putFileBlob(content: Uint8Array | null, handle: Database.Database = db()): string | null {
|
||||
export function putFileBlob(
|
||||
content: Uint8Array | null,
|
||||
handle: Database.Database = db()
|
||||
): string | null {
|
||||
if (content === null) return null;
|
||||
const sha256 = createHash('sha256').update(content).digest('hex');
|
||||
handle.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)').run(sha256, content.byteLength, content);
|
||||
handle
|
||||
.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)')
|
||||
.run(sha256, content.byteLength, content);
|
||||
return sha256;
|
||||
}
|
||||
|
||||
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
|
||||
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as { content: Buffer } | undefined;
|
||||
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as
|
||||
{ content: Buffer } | undefined;
|
||||
if (!row) throw new Error('File content is unavailable or has expired.');
|
||||
return row.content;
|
||||
}
|
||||
|
||||
/** SQL fragments are for fixed schema identifiers, never user input. */
|
||||
export const blobContent = (column: string) => `(SELECT content FROM file_blobs WHERE sha256=${column})`;
|
||||
export const blobContent = (column: string) =>
|
||||
`(SELECT content FROM file_blobs WHERE sha256=${column})`;
|
||||
export const blobSize = (column: string) => `(SELECT size FROM file_blobs WHERE sha256=${column})`;
|
||||
|
||||
export function referenceBlobs(ownerType: string, ownerId: string, hashes: Iterable<string>, handle: Database.Database = db()): void {
|
||||
handle.prepare('DELETE FROM file_blob_references WHERE owner_type=? AND owner_id=?').run(ownerType, ownerId);
|
||||
const insert = handle.prepare('INSERT OR IGNORE INTO file_blob_references(owner_type,owner_id,sha256) VALUES(?,?,?)');
|
||||
export function referenceBlobs(
|
||||
ownerType: string,
|
||||
ownerId: string,
|
||||
hashes: Iterable<string>,
|
||||
handle: Database.Database = db()
|
||||
): void {
|
||||
handle
|
||||
.prepare('DELETE FROM file_blob_references WHERE owner_type=? AND owner_id=?')
|
||||
.run(ownerType, ownerId);
|
||||
const insert = handle.prepare(
|
||||
'INSERT OR IGNORE INTO file_blob_references(owner_type,owner_id,sha256) VALUES(?,?,?)'
|
||||
);
|
||||
for (const hash of hashes) insert.run(ownerType, ownerId, hash);
|
||||
}
|
||||
|
||||
/** Immutable file bytes in JSON are replaced with hash references, including before images. */
|
||||
export function packFileJson(value: unknown, handle: Database.Database = db(), hashes = new Set<string>()): { value: unknown; hashes: Set<string> } {
|
||||
if (Array.isArray(value)) return { value: value.map(item => packFileJson(item, handle, hashes).value), hashes };
|
||||
export function packFileJson(
|
||||
value: unknown,
|
||||
handle: Database.Database = db(),
|
||||
hashes = new Set<string>()
|
||||
): { value: unknown; hashes: Set<string> } {
|
||||
if (Array.isArray(value))
|
||||
return { value: value.map((item) => packFileJson(item, handle, hashes).value), hashes };
|
||||
if (!value || typeof value !== 'object') return { value, hashes };
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if ((key === 'contentBase64' || key === 'content' && 'path' in value && 'sha256' in value) && typeof item === 'string') {
|
||||
if (
|
||||
(key === 'contentBase64' ||
|
||||
(key === 'content' && 'path' in value && 'sha256' in value)) &&
|
||||
typeof item === 'string'
|
||||
) {
|
||||
const hash = putFileBlob(Buffer.from(item, 'base64'), handle)!;
|
||||
result[key === 'content' ? 'contentHash' : 'contentSha256'] = hash;
|
||||
hashes.add(hash);
|
||||
} else {
|
||||
result[key] = packFileJson(item, handle, hashes).value;
|
||||
if ((key === 'contentHash' || key === 'contentSha256') && typeof item === 'string') hashes.add(item);
|
||||
if ((key === 'contentHash' || key === 'contentSha256') && typeof item === 'string')
|
||||
hashes.add(item);
|
||||
}
|
||||
}
|
||||
return { value: result, hashes };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Unpacked JSON retains the caller-specific stored object shape.
|
||||
export function unpackFileJson(value: unknown, handle: Database.Database = db()): any {
|
||||
if (Array.isArray(value)) return value.map(item => unpackFileJson(item, handle));
|
||||
if (Array.isArray(value)) return value.map((item) => unpackFileJson(item, handle));
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if ((key === 'contentHash' || key === 'contentSha256') && typeof item === 'string') {
|
||||
result[key === 'contentHash' ? 'content' : 'contentBase64'] = getFileBlob(item, handle).toString('base64');
|
||||
result[key === 'contentHash' ? 'content' : 'contentBase64'] = getFileBlob(
|
||||
item,
|
||||
handle
|
||||
).toString('base64');
|
||||
} else result[key] = unpackFileJson(item, handle);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const blobColumns = [
|
||||
['script_versions', 'content'], ['staged_file_changes', 'content'],
|
||||
['deployment_files', 'content_before'], ['deployment_files', 'content_after'],
|
||||
['beta_file_snapshots', 'prior_content'], ['beta_revision_files', 'content']
|
||||
['script_versions', 'content'],
|
||||
['staged_file_changes', 'content'],
|
||||
['deployment_files', 'content_before'],
|
||||
['deployment_files', 'content_after'],
|
||||
['beta_file_snapshots', 'prior_content'],
|
||||
['beta_revision_files', 'content']
|
||||
] as const;
|
||||
|
||||
/** Finish each SELECT before writing; better-sqlite3 iterators hold an active cursor. */
|
||||
export function* migrationRows<T>(handle: Database.Database, sql: string): Generator<T & { rid: number }> {
|
||||
export function* migrationRows<T>(
|
||||
handle: Database.Database,
|
||||
sql: string
|
||||
): Generator<T & { rid: number }> {
|
||||
const select = handle.prepare(sql);
|
||||
let cursor = -Number.MAX_SAFE_INTEGER;
|
||||
for (;;) {
|
||||
@@ -84,31 +120,53 @@ export function* migrationRows<T>(handle: Database.Database, sql: string): Gener
|
||||
/** Backfill one row at a time so large libraries do not exhaust startup memory. */
|
||||
export function migrateFileBlobs(handle: Database.Database): void {
|
||||
for (const [table, column] of blobColumns) {
|
||||
if (!(handle.pragma(`table_info(${table})`) as { name: string }[]).some(item => item.name === column)) continue;
|
||||
migrationStep(`shared files: ${table}.${column}`, () => handle.transaction(() => {
|
||||
handle.exec(`ALTER TABLE ${table} ADD COLUMN ${column}_hash TEXT REFERENCES file_blobs(sha256)`);
|
||||
const update = handle.prepare(`UPDATE ${table} SET ${column}_hash=? WHERE rowid=?`);
|
||||
for (const row of migrationRows<{ content: Buffer | null }>(handle, `SELECT rowid AS rid,${column} AS content FROM ${table} WHERE rowid>? ORDER BY rowid LIMIT 1`)) {
|
||||
update.run(putFileBlob(row.content, handle), row.rid);
|
||||
}
|
||||
handle.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
||||
})());
|
||||
if (
|
||||
!(handle.pragma(`table_info(${table})`) as { name: string }[]).some(
|
||||
(item) => item.name === column
|
||||
)
|
||||
)
|
||||
continue;
|
||||
migrationStep(`shared files: ${table}.${column}`, () =>
|
||||
handle.transaction(() => {
|
||||
handle.exec(
|
||||
`ALTER TABLE ${table} ADD COLUMN ${column}_hash TEXT REFERENCES file_blobs(sha256)`
|
||||
);
|
||||
const update = handle.prepare(`UPDATE ${table} SET ${column}_hash=? WHERE rowid=?`);
|
||||
for (const row of migrationRows<{ content: Buffer | null }>(
|
||||
handle,
|
||||
`SELECT rowid AS rid,${column} AS content FROM ${table} WHERE rowid>? ORDER BY rowid LIMIT 1`
|
||||
)) {
|
||||
update.run(putFileBlob(row.content, handle), row.rid);
|
||||
}
|
||||
handle.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
||||
})()
|
||||
);
|
||||
}
|
||||
for (const [table, column] of blobColumns) handle.exec(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_hash ON ${table}(${column}_hash)`);
|
||||
for (const [table, column] of blobColumns)
|
||||
handle.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_hash ON ${table}(${column}_hash)`
|
||||
);
|
||||
if (!handle.prepare("SELECT 1 FROM settings WHERE key='recipe_blobs_v1'").get()) {
|
||||
migrationStep('shared files: launch recipes', () => handle.transaction(() => {
|
||||
for (const row of migrationRows<{ deployment_id: string; recipe: string }>(handle,
|
||||
'SELECT rowid AS rid,deployment_id,recipe FROM server_launch_recipes WHERE rowid>? ORDER BY rowid LIMIT 1')) {
|
||||
const packed = packFileJson(JSON.parse(row.recipe), handle);
|
||||
handle.prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(packed.value), row.deployment_id);
|
||||
referenceBlobs('recipe', row.deployment_id, packed.hashes, handle);
|
||||
}
|
||||
handle.exec("INSERT INTO settings(key,value) VALUES('recipe_blobs_v1','1')");
|
||||
})());
|
||||
migrationStep('shared files: launch recipes', () =>
|
||||
handle.transaction(() => {
|
||||
for (const row of migrationRows<{ deployment_id: string; recipe: string }>(
|
||||
handle,
|
||||
'SELECT rowid AS rid,deployment_id,recipe FROM server_launch_recipes WHERE rowid>? ORDER BY rowid LIMIT 1'
|
||||
)) {
|
||||
const packed = packFileJson(JSON.parse(row.recipe), handle);
|
||||
handle
|
||||
.prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?')
|
||||
.run(JSON.stringify(packed.value), row.deployment_id);
|
||||
referenceBlobs('recipe', row.deployment_id, packed.hashes, handle);
|
||||
}
|
||||
handle.exec("INSERT INTO settings(key,value) VALUES('recipe_blobs_v1','1')");
|
||||
})()
|
||||
);
|
||||
}
|
||||
handle.exec(`CREATE TRIGGER IF NOT EXISTS release_recipe_blobs AFTER DELETE ON server_launch_recipes BEGIN
|
||||
DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=OLD.deployment_id; END;`);
|
||||
for (const action of ['INSERT', 'UPDATE OF recipe']) handle.exec(`CREATE TRIGGER IF NOT EXISTS reference_recipe_blobs_${action.startsWith('UPDATE') ? 'update' : 'insert'}
|
||||
for (const action of ['INSERT', 'UPDATE OF recipe'])
|
||||
handle.exec(`CREATE TRIGGER IF NOT EXISTS reference_recipe_blobs_${action.startsWith('UPDATE') ? 'update' : 'insert'}
|
||||
AFTER ${action} ON server_launch_recipes BEGIN
|
||||
DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=NEW.deployment_id;
|
||||
INSERT OR IGNORE INTO file_blob_references(owner_type,owner_id,sha256)
|
||||
@@ -117,8 +175,12 @@ export function migrateFileBlobs(handle: Database.Database): void {
|
||||
}
|
||||
|
||||
export function pruneFileBlobs(limit = 100): number {
|
||||
return db().prepare(`DELETE FROM file_blobs WHERE sha256 IN (SELECT b.sha256 FROM file_blobs b
|
||||
return db()
|
||||
.prepare(
|
||||
`DELETE FROM file_blobs WHERE sha256 IN (SELECT b.sha256 FROM file_blobs b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM file_blob_references r WHERE r.sha256=b.sha256)
|
||||
${blobColumns.map(([table, column]) => `AND NOT EXISTS (SELECT 1 FROM ${table} WHERE ${column}_hash=b.sha256)`).join('\n')}
|
||||
LIMIT ?)`).run(limit).changes;
|
||||
LIMIT ?)`
|
||||
)
|
||||
.run(limit).changes;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { defaultFleetSettings, validateFleetSettings, type FleetSettings } from '../../fleet-settings.js';
|
||||
import {
|
||||
defaultFleetSettings,
|
||||
validateFleetSettings,
|
||||
type FleetSettings
|
||||
} from '../../fleet-settings.js';
|
||||
|
||||
export const FLEET_SETTINGS_KEY = 'fleet_settings';
|
||||
|
||||
/** Import legacy environment values once; persisted settings win on every later boot. */
|
||||
export function initializeFleetSettings(handle: Database.Database): void {
|
||||
const saved = handle.prepare('SELECT value FROM settings WHERE key = ?').get(FLEET_SETTINGS_KEY) as { value: string } | undefined;
|
||||
const saved = handle
|
||||
.prepare('SELECT value FROM settings WHERE key = ?')
|
||||
.get(FLEET_SETTINGS_KEY) as { value: string } | undefined;
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved.value);
|
||||
const upgraded = validateFleetSettings({ ...defaultFleetSettings, ...settings });
|
||||
handle.prepare('UPDATE settings SET value = ? WHERE key = ?').run(JSON.stringify(upgraded), FLEET_SETTINGS_KEY);
|
||||
handle
|
||||
.prepare('UPDATE settings SET value = ? WHERE key = ?')
|
||||
.run(JSON.stringify(upgraded), FLEET_SETTINGS_KEY);
|
||||
return;
|
||||
}
|
||||
const values = { ...defaultFleetSettings };
|
||||
const legacy: Partial<Record<keyof FleetSettings, string>> = {
|
||||
deployKeepDays: 'DEPLOY_KEEP_DAYS', restartConcurrency: 'DEPLOYMENT_RESTART_CONCURRENCY',
|
||||
deployKeepDays: 'DEPLOY_KEEP_DAYS',
|
||||
restartConcurrency: 'DEPLOYMENT_RESTART_CONCURRENCY',
|
||||
createConcurrency: 'DEPLOYMENT_CREATE_CONCURRENCY'
|
||||
};
|
||||
for (const key of Object.keys(legacy) as (keyof FleetSettings)[]) {
|
||||
const raw = process.env[legacy[key]!];
|
||||
if (raw) values[key] = Number(raw);
|
||||
}
|
||||
handle.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)')
|
||||
handle
|
||||
.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run(FLEET_SETTINGS_KEY, JSON.stringify(validateFleetSettings(values)));
|
||||
}
|
||||
|
||||
+274
-80
@@ -28,9 +28,15 @@ let optimizeTimer: NodeJS.Timeout | undefined;
|
||||
// IF NOT EXISTS in schema.sql is a no-op on existing tables, so it can't
|
||||
// backfill columns; this can.
|
||||
const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
{ name: 'container_checked_at', ddl: 'ALTER TABLE servers ADD COLUMN container_checked_at TEXT' },
|
||||
{
|
||||
name: 'container_checked_at',
|
||||
ddl: 'ALTER TABLE servers ADD COLUMN container_checked_at TEXT'
|
||||
},
|
||||
{ name: 'container_state', ddl: 'ALTER TABLE servers ADD COLUMN container_state TEXT' },
|
||||
{ name: 'desired_state', ddl: "ALTER TABLE servers ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'running' CHECK (desired_state IN ('running','stopped'))" },
|
||||
{
|
||||
name: 'desired_state',
|
||||
ddl: "ALTER TABLE servers ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'running' CHECK (desired_state IN ('running','stopped'))"
|
||||
},
|
||||
// Base columns that shipped in the very first schema stay here for parity
|
||||
// with fresh installs — no ALTER is issued if they're already present.
|
||||
{ name: 'game_port', ddl: 'ALTER TABLE servers ADD COLUMN game_port INTEGER' },
|
||||
@@ -43,7 +49,10 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
|
||||
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
|
||||
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' },
|
||||
{ name: 'rcon_failures', ddl: 'ALTER TABLE servers ADD COLUMN rcon_failures INTEGER NOT NULL DEFAULT 0' },
|
||||
{
|
||||
name: 'rcon_failures',
|
||||
ddl: 'ALTER TABLE servers ADD COLUMN rcon_failures INTEGER NOT NULL DEFAULT 0'
|
||||
},
|
||||
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
|
||||
// Script-scope columns — static per server, populated via server-edit UI.
|
||||
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
|
||||
@@ -78,7 +87,10 @@ function migrateServers(handle: Database.Database): void {
|
||||
const retired = new Set(['komodo_type', 'komodo_name', 'komodo_server']);
|
||||
handle.transaction(() => {
|
||||
for (const column of rows) {
|
||||
if (retired.has(column.name)) handle.exec(`ALTER TABLE servers DROP COLUMN "${column.name.replaceAll('"', '""')}"`);
|
||||
if (retired.has(column.name))
|
||||
handle.exec(
|
||||
`ALTER TABLE servers DROP COLUMN "${column.name.replaceAll('"', '""')}"`
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -98,8 +110,14 @@ const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
// the authoritative "promoted" signal), and any version already gated
|
||||
// through a ready-promote beta will be re-marked next time that state
|
||||
// machine fires.
|
||||
{ name: 'state', ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'" },
|
||||
{ name: 'kind', ddl: "ALTER TABLE script_versions ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" }
|
||||
{
|
||||
name: 'state',
|
||||
ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'"
|
||||
},
|
||||
{
|
||||
name: 'kind',
|
||||
ddl: "ALTER TABLE script_versions ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'"
|
||||
}
|
||||
];
|
||||
|
||||
const BETA_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
@@ -107,18 +125,40 @@ const BETA_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
];
|
||||
|
||||
const DEPLOYMENT_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
{ name: 'kind', ddl: "ALTER TABLE deployment_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" }
|
||||
{
|
||||
name: 'kind',
|
||||
ddl: "ALTER TABLE deployment_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'"
|
||||
}
|
||||
];
|
||||
|
||||
const DEPLOYMENTS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
{ name: 'script_id', ddl: 'ALTER TABLE deployments ADD COLUMN script_id INTEGER REFERENCES scripts(id)' },
|
||||
{ name: 'script_version_id', ddl: 'ALTER TABLE deployments ADD COLUMN script_version_id INTEGER REFERENCES script_versions(id)' },
|
||||
{ name: 'prior_version_id', ddl: 'ALTER TABLE deployments ADD COLUMN prior_version_id INTEGER REFERENCES script_versions(id)' },
|
||||
{ name: 'beta_id', ddl: 'ALTER TABLE deployments ADD COLUMN beta_id INTEGER REFERENCES betas(id)' },
|
||||
{ name: 'rolled_back_by', ddl: 'ALTER TABLE deployments ADD COLUMN rolled_back_by TEXT REFERENCES deployments(id)' }
|
||||
{
|
||||
name: 'script_id',
|
||||
ddl: 'ALTER TABLE deployments ADD COLUMN script_id INTEGER REFERENCES scripts(id)'
|
||||
},
|
||||
{
|
||||
name: 'script_version_id',
|
||||
ddl: 'ALTER TABLE deployments ADD COLUMN script_version_id INTEGER REFERENCES script_versions(id)'
|
||||
},
|
||||
{
|
||||
name: 'prior_version_id',
|
||||
ddl: 'ALTER TABLE deployments ADD COLUMN prior_version_id INTEGER REFERENCES script_versions(id)'
|
||||
},
|
||||
{
|
||||
name: 'beta_id',
|
||||
ddl: 'ALTER TABLE deployments ADD COLUMN beta_id INTEGER REFERENCES betas(id)'
|
||||
},
|
||||
{
|
||||
name: 'rolled_back_by',
|
||||
ddl: 'ALTER TABLE deployments ADD COLUMN rolled_back_by TEXT REFERENCES deployments(id)'
|
||||
}
|
||||
];
|
||||
|
||||
function migrateTable(handle: Database.Database, table: string, cols: Array<{ name: string; ddl: string }>): void {
|
||||
function migrateTable(
|
||||
handle: Database.Database,
|
||||
table: string,
|
||||
cols: Array<{ name: string; ddl: string }>
|
||||
): void {
|
||||
const rows = handle.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
const existing = new Set(rows.map((r) => r.name));
|
||||
for (const col of cols) {
|
||||
@@ -169,14 +209,16 @@ function backfillMultiScriptJoins(handle: Database.Database): void {
|
||||
// operator can promote/rollback in the old app before upgrading.
|
||||
function dropLegacyBetaTables(handle: Database.Database): void {
|
||||
const legacy = handle
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')")
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')"
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
if (legacy.length === 0) return;
|
||||
|
||||
const legacyNames = legacy.map((r) => r.name);
|
||||
// The old `betas` table had a `slug` column; the new one has `id` + `script_id`.
|
||||
// If we see `slug`, we know it's the old shape.
|
||||
const betasCols = handle.prepare("PRAGMA table_info(betas)").all() as Array<{ name: string }>;
|
||||
const betasCols = handle.prepare('PRAGMA table_info(betas)').all() as Array<{ name: string }>;
|
||||
const isLegacyShape = betasCols.some((c) => c.name === 'slug');
|
||||
if (!isLegacyShape) return;
|
||||
|
||||
@@ -205,8 +247,7 @@ function dropLegacyBetaTables(handle: Database.Database): void {
|
||||
// active betas aren't blocked.
|
||||
function assertSnapshotSchemeSafe(handle: Database.Database): void {
|
||||
const done = handle.prepare("SELECT value FROM settings WHERE key = 'snapshots_v1'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
{ value: string } | undefined;
|
||||
if (done) return;
|
||||
const active = handle
|
||||
.prepare("SELECT id FROM betas WHERE state IN ('running','ready-promote') LIMIT 1")
|
||||
@@ -227,19 +268,36 @@ function migrateGameTelemetry(handle: Database.Database): void {
|
||||
handle.transaction(() => {
|
||||
const convert = (raw: string): string => {
|
||||
const plan = JSON.parse(raw) as BetaPlan;
|
||||
plan.requirements = plan.requirements.map(r => {
|
||||
plan.requirements = plan.requirements.map((r) => {
|
||||
const { event: _event, ...rest } = r as BetaRequirement & { event?: string };
|
||||
return ['feature', 'restart-budget', 'clean-logs'].includes(r.kind)
|
||||
? { ...requirement('manual', r.id), label: `Review: ${r.label}`.slice(0, 200) } : rest;
|
||||
? { ...requirement('manual', r.id), label: `Review: ${r.label}`.slice(0, 200) }
|
||||
: rest;
|
||||
});
|
||||
return JSON.stringify(plan);
|
||||
};
|
||||
const plans = handle.prepare('SELECT beta_id, plan FROM beta_test_plans').all() as Array<{ beta_id: number; plan: string }>;
|
||||
for (const row of plans) handle.prepare('UPDATE beta_test_plans SET plan = ? WHERE beta_id = ?').run(convert(row.plan), row.beta_id);
|
||||
const presets = handle.prepare('SELECT name, plan FROM beta_test_presets').all() as Array<{ name: string; plan: string }>;
|
||||
for (const row of presets) handle.prepare('UPDATE beta_test_presets SET plan = ? WHERE name = ?').run(convert(row.plan), row.name);
|
||||
handle.prepare(`UPDATE beta_test_runs SET metrics = ?, cursors = '{}' WHERE retired_at IS NULL
|
||||
AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'))`).run(JSON.stringify(emptyMetrics()));
|
||||
const plans = handle.prepare('SELECT beta_id, plan FROM beta_test_plans').all() as Array<{
|
||||
beta_id: number;
|
||||
plan: string;
|
||||
}>;
|
||||
for (const row of plans)
|
||||
handle
|
||||
.prepare('UPDATE beta_test_plans SET plan = ? WHERE beta_id = ?')
|
||||
.run(convert(row.plan), row.beta_id);
|
||||
const presets = handle.prepare('SELECT name, plan FROM beta_test_presets').all() as Array<{
|
||||
name: string;
|
||||
plan: string;
|
||||
}>;
|
||||
for (const row of presets)
|
||||
handle
|
||||
.prepare('UPDATE beta_test_presets SET plan = ? WHERE name = ?')
|
||||
.run(convert(row.plan), row.name);
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE beta_test_runs SET metrics = ?, cursors = '{}' WHERE retired_at IS NULL
|
||||
AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'))`
|
||||
)
|
||||
.run(JSON.stringify(emptyMetrics()));
|
||||
handle.exec(`UPDATE beta_test_plans SET outcome = 'active', reason = 'Gameplay telemetry updated; collecting fresh script events'
|
||||
WHERE outcome IN ('active','passed') AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'));
|
||||
UPDATE script_versions SET state = 'untested' WHERE state = 'tested'
|
||||
@@ -254,54 +312,96 @@ function migrateGameTelemetry(handle: Database.Database): void {
|
||||
}
|
||||
|
||||
function migrateProvisioningActions(handle: Database.Database): void {
|
||||
const table = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'server_deployments'").get() as { sql: string };
|
||||
const table = handle
|
||||
.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'server_deployments'"
|
||||
)
|
||||
.get() as { sql: string };
|
||||
if (table.sql.includes("'rebuild'")) return;
|
||||
// Rebuild the CHECK constraint without cascading into events, recipes, or
|
||||
// collection members. Preserve rowids: they order same-server operations.
|
||||
handle.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
handle.transaction(() => {
|
||||
handle.exec(table.sql.replace('server_deployments', 'server_deployments_new')
|
||||
.replace("'create', 'destroy'", "'create', 'start', 'restart', 'rebuild', 'stop', 'destroy'"));
|
||||
const columns = (handle.prepare('PRAGMA table_info(server_deployments)').all() as { name: string }[])
|
||||
.map(column => `"${column.name.replaceAll('"', '""')}"`).join(', ');
|
||||
handle.exec(
|
||||
table.sql
|
||||
.replace('server_deployments', 'server_deployments_new')
|
||||
.replace(
|
||||
"'create', 'destroy'",
|
||||
"'create', 'start', 'restart', 'rebuild', 'stop', 'destroy'"
|
||||
)
|
||||
);
|
||||
const columns = (
|
||||
handle.prepare('PRAGMA table_info(server_deployments)').all() as { name: string }[]
|
||||
)
|
||||
.map((column) => `"${column.name.replaceAll('"', '""')}"`)
|
||||
.join(', ');
|
||||
handle.exec(`INSERT INTO server_deployments_new (rowid, ${columns})
|
||||
SELECT rowid, ${columns} FROM server_deployments;
|
||||
DROP TABLE server_deployments;
|
||||
ALTER TABLE server_deployments_new RENAME TO server_deployments;`);
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Provisioning migration found broken references');
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length)
|
||||
throw new Error('Provisioning migration found broken references');
|
||||
})();
|
||||
} finally { handle.pragma('foreign_keys = ON'); }
|
||||
} finally {
|
||||
handle.pragma('foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild constrained tables while preserving links, indexes, triggers, and row order. */
|
||||
function migrateWatchdogConstraints(handle: Database.Database): void {
|
||||
for (const name of ['provisioning_sources', 'deployment_servers']) {
|
||||
const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { sql: string };
|
||||
const replacement = name === 'provisioning_sources'
|
||||
? (sql.includes("'autoscaling'") ? sql : sql.replace(/'campaign','beta','maintenance'(?:,'watchdog')?/, "'campaign','beta','maintenance','watchdog','autoscaling'"))
|
||||
: sql.replace(/server\s+TEXT NOT NULL REFERENCES servers\(name\)/, 'server TEXT NOT NULL');
|
||||
const { sql } = handle
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name) as { sql: string };
|
||||
const replacement =
|
||||
name === 'provisioning_sources'
|
||||
? sql.includes("'autoscaling'")
|
||||
? sql
|
||||
: sql.replace(
|
||||
/'campaign','beta','maintenance'(?:,'watchdog')?/,
|
||||
"'campaign','beta','maintenance','watchdog','autoscaling'"
|
||||
)
|
||||
: sql.replace(
|
||||
/server\s+TEXT NOT NULL REFERENCES servers\(name\)/,
|
||||
'server TEXT NOT NULL'
|
||||
);
|
||||
if (sql === replacement) continue;
|
||||
const dependent = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL")
|
||||
const dependent = handle
|
||||
.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL"
|
||||
)
|
||||
.all(name) as { sql: string }[];
|
||||
handle.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
handle.transaction(() => {
|
||||
handle.exec(replacement.replace(name, name + '_watchdog_new'));
|
||||
handle.exec(`INSERT INTO ${name}_watchdog_new (rowid, ${
|
||||
(handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]).map(col => col.name).join(', ')
|
||||
}) SELECT rowid, ${
|
||||
(handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]).map(col => col.name).join(', ')
|
||||
} FROM ${name}; DROP TABLE ${name}; ALTER TABLE ${name}_watchdog_new RENAME TO ${name};`);
|
||||
handle.exec(
|
||||
`INSERT INTO ${name}_watchdog_new (rowid, ${(
|
||||
handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]
|
||||
)
|
||||
.map((col) => col.name)
|
||||
.join(', ')}) SELECT rowid, ${(
|
||||
handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]
|
||||
)
|
||||
.map((col) => col.name)
|
||||
.join(
|
||||
', '
|
||||
)} FROM ${name}; DROP TABLE ${name}; ALTER TABLE ${name}_watchdog_new RENAME TO ${name};`
|
||||
);
|
||||
for (const object of dependent) handle.exec(object.sql);
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Watchdog migration found broken references');
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length)
|
||||
throw new Error('Watchdog migration found broken references');
|
||||
})();
|
||||
} finally { handle.pragma('foreign_keys = ON'); }
|
||||
} finally {
|
||||
handle.pragma('foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function backfillDesiredState(handle: Database.Database): void {
|
||||
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'watchdog_desired_state_v1'").get()) return;
|
||||
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'watchdog_desired_state_v1'").get())
|
||||
return;
|
||||
handle.transaction(() => {
|
||||
// A failed stop may have reached the host agent. Preserve that explicit intent.
|
||||
handle.exec(`UPDATE servers SET desired_state = 'stopped' WHERE 'stop' = (
|
||||
@@ -331,35 +431,80 @@ export function db(): Database.Database {
|
||||
migrateProvisioningActions(handle);
|
||||
migrateWatchdogConstraints(handle);
|
||||
migrateTable(handle, 'deployment_files', [
|
||||
{ name: 'snapshot_pending', ddl: 'ALTER TABLE deployment_files ADD COLUMN snapshot_pending INTEGER NOT NULL DEFAULT 0' }
|
||||
{
|
||||
name: 'snapshot_pending',
|
||||
ddl: 'ALTER TABLE deployment_files ADD COLUMN snapshot_pending INTEGER NOT NULL DEFAULT 0'
|
||||
}
|
||||
]);
|
||||
migrateTable(handle, 'restart_rollout', [
|
||||
{
|
||||
name: 'operation_id',
|
||||
ddl: 'ALTER TABLE restart_rollout ADD COLUMN operation_id TEXT'
|
||||
}
|
||||
]);
|
||||
migrateTable(handle, 'restart_rollout', [{ name: 'operation_id', ddl: 'ALTER TABLE restart_rollout ADD COLUMN operation_id TEXT' }]);
|
||||
migrateTable(handle, 'server_deployments', [
|
||||
{ name: 'requested_by', ddl: 'ALTER TABLE server_deployments ADD COLUMN requested_by TEXT' },
|
||||
{ name: 'submitted_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN submitted_at TEXT' },
|
||||
{ name: 'checked_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN checked_at TEXT' },
|
||||
{
|
||||
name: 'requested_by',
|
||||
ddl: 'ALTER TABLE server_deployments ADD COLUMN requested_by TEXT'
|
||||
},
|
||||
{
|
||||
name: 'submitted_at',
|
||||
ddl: 'ALTER TABLE server_deployments ADD COLUMN submitted_at TEXT'
|
||||
},
|
||||
{
|
||||
name: 'checked_at',
|
||||
ddl: 'ALTER TABLE server_deployments ADD COLUMN checked_at TEXT'
|
||||
},
|
||||
{ name: 'players', ddl: 'ALTER TABLE server_deployments ADD COLUMN players INTEGER' },
|
||||
{ name: 'phase_key', ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_key TEXT' },
|
||||
{ name: 'phase_started_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_started_at TEXT' },
|
||||
{ name: 'observed_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN observed_at TEXT' },
|
||||
{ name: 'observation_json', ddl: 'ALTER TABLE server_deployments ADD COLUMN observation_json TEXT' }
|
||||
{
|
||||
name: 'phase_started_at',
|
||||
ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_started_at TEXT'
|
||||
},
|
||||
{
|
||||
name: 'observed_at',
|
||||
ddl: 'ALTER TABLE server_deployments ADD COLUMN observed_at TEXT'
|
||||
},
|
||||
{
|
||||
name: 'observation_json',
|
||||
ddl: 'ALTER TABLE server_deployments ADD COLUMN observation_json TEXT'
|
||||
}
|
||||
]);
|
||||
migrateTable(handle, 'server_deployment_events', [
|
||||
{ name: 'kind', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN kind TEXT' },
|
||||
{ name: 'phase_key', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN phase_key TEXT' },
|
||||
{ name: 'details_json', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN details_json TEXT' }
|
||||
{
|
||||
name: 'phase_key',
|
||||
ddl: 'ALTER TABLE server_deployment_events ADD COLUMN phase_key TEXT'
|
||||
},
|
||||
{
|
||||
name: 'details_json',
|
||||
ddl: 'ALTER TABLE server_deployment_events ADD COLUMN details_json TEXT'
|
||||
}
|
||||
]);
|
||||
for (const table of ['deployment_servers', 'beta_servers', 'maintenance_jobs']) {
|
||||
migrateTable(handle, table, [{ name: 'provisioning_id', ddl: `ALTER TABLE ${table} ADD COLUMN provisioning_id TEXT` }]);
|
||||
migrateTable(handle, table, [
|
||||
{
|
||||
name: 'provisioning_id',
|
||||
ddl: `ALTER TABLE ${table} ADD COLUMN provisioning_id TEXT`
|
||||
}
|
||||
]);
|
||||
}
|
||||
migrateTable(handle, 'maintenance_jobs', [
|
||||
{ name: 'kind', ddl: "ALTER TABLE maintenance_jobs ADD COLUMN kind TEXT NOT NULL DEFAULT 'legacy'" },
|
||||
{ name: 'origin', ddl: "ALTER TABLE maintenance_jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'" }
|
||||
{
|
||||
name: 'kind',
|
||||
ddl: "ALTER TABLE maintenance_jobs ADD COLUMN kind TEXT NOT NULL DEFAULT 'legacy'"
|
||||
},
|
||||
{
|
||||
name: 'origin',
|
||||
ddl: "ALTER TABLE maintenance_jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'"
|
||||
}
|
||||
]);
|
||||
migrateServers(handle);
|
||||
// Old snippets may contain player addresses, including hex-encoded bytes.
|
||||
// Discard them once; newly retained diagnostics are sanitized failure replies only.
|
||||
if (!handle.prepare("SELECT 1 FROM settings WHERE key='rcon_private_diagnostics_v1'").get()) {
|
||||
if (
|
||||
!handle.prepare("SELECT 1 FROM settings WHERE key='rcon_private_diagnostics_v1'").get()
|
||||
) {
|
||||
handle.transaction(() => {
|
||||
handle.exec(`UPDATE servers SET last_reply_snippet=NULL;
|
||||
UPDATE servers SET last_error='bad-reply:unparseable print reply'
|
||||
@@ -374,25 +519,52 @@ export function db(): Database.Database {
|
||||
migrateTable(handle, 'scripts', SCRIPTS_COLUMNS);
|
||||
migrateTable(handle, 'script_versions', SCRIPT_VERSIONS_COLUMNS);
|
||||
migrateTable(handle, 'beta_scripts', BETA_SCRIPTS_COLUMNS);
|
||||
migrateTable(handle, 'beta_test_runs', [{ name: 'revision', ddl: 'ALTER TABLE beta_test_runs ADD COLUMN revision INTEGER NOT NULL DEFAULT 1' }]);
|
||||
migrateTable(handle, 'beta_test_runs', [
|
||||
{
|
||||
name: 'revision',
|
||||
ddl: 'ALTER TABLE beta_test_runs ADD COLUMN revision INTEGER NOT NULL DEFAULT 1'
|
||||
}
|
||||
]);
|
||||
migrateTable(handle, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS);
|
||||
migrateTable(handle, 'deployments', DEPLOYMENTS_COLUMNS);
|
||||
// Older databases gain rolled_back_by above, after schema.sql runs.
|
||||
handle.exec('CREATE INDEX IF NOT EXISTS idx_deployments_rolled_back_by ON deployments(rolled_back_by)');
|
||||
handle.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_deployments_rolled_back_by ON deployments(rolled_back_by)'
|
||||
);
|
||||
migrateTable(handle, 'server_launch_collections', [
|
||||
{ name: 'cancelled_at', ddl: 'ALTER TABLE server_launch_collections ADD COLUMN cancelled_at TEXT' },
|
||||
{ name: 'mode', ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
|
||||
{ name: 'retained_servers', ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'" }
|
||||
{
|
||||
name: 'cancelled_at',
|
||||
ddl: 'ALTER TABLE server_launch_collections ADD COLUMN cancelled_at TEXT'
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'"
|
||||
},
|
||||
{
|
||||
name: 'retained_servers',
|
||||
ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'"
|
||||
}
|
||||
]);
|
||||
migrateTable(handle, 'server_launch_recipes', [
|
||||
{ name: 'recipe_summary', ddl: 'ALTER TABLE server_launch_recipes ADD COLUMN recipe_summary TEXT' }
|
||||
{
|
||||
name: 'recipe_summary',
|
||||
ddl: 'ALTER TABLE server_launch_recipes ADD COLUMN recipe_summary TEXT'
|
||||
}
|
||||
]);
|
||||
migrateServerHistory(handle);
|
||||
migrateTable(handle, 'server_collection_previews', [
|
||||
{ name: 'mode', ddl: "ALTER TABLE server_collection_previews ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
|
||||
{ name: 'fleet_snapshot', ddl: 'ALTER TABLE server_collection_previews ADD COLUMN fleet_snapshot TEXT' }
|
||||
{
|
||||
name: 'mode',
|
||||
ddl: "ALTER TABLE server_collection_previews ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'"
|
||||
},
|
||||
{
|
||||
name: 'fleet_snapshot',
|
||||
ddl: 'ALTER TABLE server_collection_previews ADD COLUMN fleet_snapshot TEXT'
|
||||
}
|
||||
]);
|
||||
migrateTable(handle, 'server_telemetry', [
|
||||
{ name: 'payload', ddl: 'ALTER TABLE server_telemetry ADD COLUMN payload TEXT' }
|
||||
]);
|
||||
migrateTable(handle, 'server_telemetry', [{ name: 'payload', ddl: 'ALTER TABLE server_telemetry ADD COLUMN payload TEXT' }]);
|
||||
// Kind-referencing indexes: safe here because migrateTable above just
|
||||
// ALTER'd the column into existence if it was missing.
|
||||
handle.exec(
|
||||
@@ -403,14 +575,27 @@ export function db(): Database.Database {
|
||||
backfillMultiScriptJoins(handle);
|
||||
assertSnapshotSchemeSafe(handle);
|
||||
migrateGameTelemetry(handle);
|
||||
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]);
|
||||
migrateTable(handle, 'leaderboard_matches', [
|
||||
{
|
||||
name: 'start_players',
|
||||
ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER'
|
||||
}
|
||||
]);
|
||||
migrateTable(handle, 'leaderboard_match_players', [
|
||||
{ name: 'first_observed_round', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER' },
|
||||
{ name: 'present_at_start', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER' }
|
||||
{
|
||||
name: 'first_observed_round',
|
||||
ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER'
|
||||
},
|
||||
{
|
||||
name: 'present_at_start',
|
||||
ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER'
|
||||
}
|
||||
]);
|
||||
backfillLeaderboard(handle);
|
||||
initializeFleetSettings(handle);
|
||||
migrationStep('native instance schema and file plans', () => migrateNativeInstances(handle));
|
||||
migrationStep('native instance schema and file plans', () =>
|
||||
migrateNativeInstances(handle)
|
||||
);
|
||||
migrationStep('per-game server key pools', () => migrateServerKeys(handle));
|
||||
migrationStep('autoscaling schema', () => ensureAutoscalingSchema(handle));
|
||||
migrationStep('rebalancing schema', () => ensureRebalancingSchema(handle));
|
||||
@@ -436,12 +621,18 @@ export function db(): Database.Database {
|
||||
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
|
||||
installPerformanceIndexes(handle);
|
||||
installReportRevisions(handle);
|
||||
migrationStep('retention clocks and indexes', () => retentionClocks(handle, {
|
||||
deployments: ['started_at', 'finished_at'], server_deployments: ['started_at', 'finished_at'],
|
||||
maintenance_jobs: ['requested_at', 'finished_at'], watchdog_incidents: ['first_seen_at', 'finished_at'],
|
||||
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
|
||||
betas: ['opened_at', 'closed_at'], script_versions: ['uploaded_at'], retention_controls: ['resolved_at']
|
||||
}));
|
||||
migrationStep('retention clocks and indexes', () =>
|
||||
retentionClocks(handle, {
|
||||
deployments: ['started_at', 'finished_at'],
|
||||
server_deployments: ['started_at', 'finished_at'],
|
||||
maintenance_jobs: ['requested_at', 'finished_at'],
|
||||
watchdog_incidents: ['first_seen_at', 'finished_at'],
|
||||
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
|
||||
betas: ['opened_at', 'closed_at'],
|
||||
script_versions: ['uploaded_at'],
|
||||
retention_controls: ['resolved_at']
|
||||
})
|
||||
);
|
||||
// Existing databases require one rebuild to gain the incremental-vacuum page map.
|
||||
enableIncrementalVacuum(handle);
|
||||
handle.pragma('optimize = 0x10002');
|
||||
@@ -472,8 +663,11 @@ export function db(): Database.Database {
|
||||
|
||||
_db = handle;
|
||||
optimizeTimer = setInterval(() => {
|
||||
try { handle.pragma('optimize'); }
|
||||
catch (error) { console.error('[database] optimize failed:', error); }
|
||||
try {
|
||||
handle.pragma('optimize');
|
||||
} catch (error) {
|
||||
console.error('[database] optimize failed:', error);
|
||||
}
|
||||
}, 60 * 60_000);
|
||||
optimizeTimer.unref();
|
||||
return _db;
|
||||
|
||||
@@ -3,30 +3,59 @@ import { migrationStep, tuneMigrationConnection } from './connection.js';
|
||||
|
||||
/** Normalize age-policy clocks once, then compare indexed ISO UTC strings directly. */
|
||||
export function retentionClocks(handle: Database.Database, clocks: Record<string, string[]>): void {
|
||||
handle.exec('CREATE TABLE IF NOT EXISTS database_maintenance_migrations(name TEXT PRIMARY KEY)');
|
||||
handle.exec(
|
||||
'CREATE TABLE IF NOT EXISTS database_maintenance_migrations(name TEXT PRIMARY KEY)'
|
||||
);
|
||||
for (const [table, columns] of Object.entries(clocks)) {
|
||||
const marker = `iso-clocks:${table}:v1`;
|
||||
if (!handle.prepare('SELECT 1 FROM database_maintenance_migrations WHERE name=?').get(marker)) {
|
||||
if (
|
||||
!handle
|
||||
.prepare('SELECT 1 FROM database_maintenance_migrations WHERE name=?')
|
||||
.get(marker)
|
||||
) {
|
||||
handle.transaction(() => {
|
||||
for (const column of columns) handle.exec(`UPDATE ${table} SET ${column}=strftime('%Y-%m-%dT%H:%M:%fZ',${column})
|
||||
for (const column of columns)
|
||||
handle.exec(`UPDATE ${table} SET ${column}=strftime('%Y-%m-%dT%H:%M:%fZ',${column})
|
||||
WHERE julianday(${column}) IS NOT NULL AND ${column}!=strftime('%Y-%m-%dT%H:%M:%fZ',${column});`);
|
||||
handle.prepare('INSERT INTO database_maintenance_migrations(name) VALUES(?)').run(marker);
|
||||
handle
|
||||
.prepare('INSERT INTO database_maintenance_migrations(name) VALUES(?)')
|
||||
.run(marker);
|
||||
})();
|
||||
}
|
||||
const indexes = handle.prepare(`PRAGMA index_list(${table})`).all() as { name: string; partial: number }[];
|
||||
const indexed = new Set(indexes.filter(index => !index.partial).map(index =>
|
||||
(handle.prepare(`PRAGMA index_info("${index.name.replaceAll('"', '""')}")`).get() as { name: string }).name));
|
||||
for (const column of columns) if (!indexed.has(column)) {
|
||||
handle.exec(`CREATE INDEX IF NOT EXISTS idx_retention_${table}_${column} ON ${table}(${column})`);
|
||||
}
|
||||
const indexes = handle.prepare(`PRAGMA index_list(${table})`).all() as {
|
||||
name: string;
|
||||
partial: number;
|
||||
}[];
|
||||
const indexed = new Set(
|
||||
indexes
|
||||
.filter((index) => !index.partial)
|
||||
.map(
|
||||
(index) =>
|
||||
(
|
||||
handle
|
||||
.prepare(`PRAGMA index_info("${index.name.replaceAll('"', '""')}")`)
|
||||
.get() as { name: string }
|
||||
).name
|
||||
)
|
||||
);
|
||||
for (const column of columns)
|
||||
if (!indexed.has(column)) {
|
||||
handle.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_retention_${table}_${column} ON ${table}(${column})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** VACUUM can renumber implicit rowids. Keep host enable cutoffs tied to command IDs across crashes. */
|
||||
export function enableIncrementalVacuum(handle: Database.Database): void {
|
||||
if (handle.pragma('auto_vacuum', { simple: true }) !== 2) {
|
||||
handle.exec(`CREATE TABLE IF NOT EXISTS database_vacuum_boundaries(host_id TEXT PRIMARY KEY, operation_id TEXT)`);
|
||||
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'").get()) {
|
||||
handle.exec(
|
||||
`CREATE TABLE IF NOT EXISTS database_vacuum_boundaries(host_id TEXT PRIMARY KEY, operation_id TEXT)`
|
||||
);
|
||||
if (
|
||||
handle.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'").get()
|
||||
) {
|
||||
handle.exec(`INSERT OR IGNORE INTO database_vacuum_boundaries
|
||||
SELECT e.host_id,(SELECT o.id FROM ec_host_operations o WHERE o.host_id=e.host_id
|
||||
AND o.rowid<=e.operation_floor ORDER BY o.rowid DESC LIMIT 1) FROM ec_host_enable_requests e`);
|
||||
@@ -42,7 +71,9 @@ export function enableIncrementalVacuum(handle: Database.Database): void {
|
||||
};
|
||||
try {
|
||||
tuneMigrationConnection(handle);
|
||||
migrationStep('incremental vacuum page map (disk-backed rebuild)', () => handle.exec('VACUUM'));
|
||||
migrationStep('incremental vacuum page map (disk-backed rebuild)', () =>
|
||||
handle.exec('VACUUM')
|
||||
);
|
||||
} finally {
|
||||
handle.pragma('shrink_memory');
|
||||
handle.pragma(`temp_store = ${previous.temp}`);
|
||||
@@ -51,9 +82,16 @@ export function enableIncrementalVacuum(handle: Database.Database): void {
|
||||
handle.pragma(`journal_size_limit = ${previous.journal}`);
|
||||
}
|
||||
}
|
||||
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='database_vacuum_boundaries'").get()) {
|
||||
if (
|
||||
handle.prepare("SELECT 1 FROM sqlite_master WHERE name='database_vacuum_boundaries'").get()
|
||||
) {
|
||||
handle.transaction(() => {
|
||||
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'").get()) handle.exec(`
|
||||
if (
|
||||
handle
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'")
|
||||
.get()
|
||||
)
|
||||
handle.exec(`
|
||||
UPDATE ec_host_enable_requests AS e SET operation_floor=COALESCE((
|
||||
SELECT o.rowid FROM database_vacuum_boundaries b JOIN ec_host_operations o ON o.id=b.operation_id
|
||||
WHERE b.host_id=e.host_id),0) WHERE e.host_id IN (SELECT host_id FROM database_vacuum_boundaries)`);
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const historyTables = ['beta_servers', 'deployment_servers', 'server_deployments', 'leaderboard_matches', 'watchdog_incidents', 'maintenance_jobs'];
|
||||
const historyTables = [
|
||||
'beta_servers',
|
||||
'deployment_servers',
|
||||
'server_deployments',
|
||||
'leaderboard_matches',
|
||||
'watchdog_incidents',
|
||||
'maintenance_jobs'
|
||||
];
|
||||
const now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')";
|
||||
|
||||
/** Detach durable beta records without dropping their evidence, indexes, or activity triggers. */
|
||||
function detachBetaHistory(handle: Database.Database): void {
|
||||
for (const name of ['beta_servers', 'beta_test_runs', 'beta_server_scripts']) {
|
||||
const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { sql: string };
|
||||
const replacement = sql.replace(/server\s+TEXT NOT NULL REFERENCES servers\(name\)/, 'server TEXT NOT NULL');
|
||||
const { sql } = handle
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name) as { sql: string };
|
||||
const replacement = sql.replace(
|
||||
/server\s+TEXT NOT NULL REFERENCES servers\(name\)/,
|
||||
'server TEXT NOT NULL'
|
||||
);
|
||||
if (replacement === sql) continue;
|
||||
const dependents = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL")
|
||||
const dependents = handle
|
||||
.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL"
|
||||
)
|
||||
.all(name) as { sql: string }[];
|
||||
const columns = (handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]).map(col => col.name).join(', ');
|
||||
const columns = (handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[])
|
||||
.map((col) => col.name)
|
||||
.join(', ');
|
||||
handle.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
handle.transaction(() => {
|
||||
@@ -19,9 +36,12 @@ function detachBetaHistory(handle: Database.Database): void {
|
||||
handle.exec(`INSERT INTO ${name}_history_new (rowid, ${columns}) SELECT rowid, ${columns} FROM ${name};
|
||||
DROP TABLE ${name}; ALTER TABLE ${name}_history_new RENAME TO ${name};`);
|
||||
for (const dependent of dependents) handle.exec(dependent.sql);
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Server history migration found broken references');
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length)
|
||||
throw new Error('Server history migration found broken references');
|
||||
})();
|
||||
} finally { handle.pragma('foreign_keys = ON'); }
|
||||
} finally {
|
||||
handle.pragma('foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +60,19 @@ export function migrateServerHistory(handle: Database.Database): void {
|
||||
detachBetaHistory(handle);
|
||||
handle.transaction(() => {
|
||||
for (const table of historyTables) {
|
||||
const columns = handle.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
||||
if (!columns.some(column => column.name === 'server_deleted_at')) handle.exec(`ALTER TABLE ${table} ADD COLUMN server_deleted_at TEXT`);
|
||||
const columns = handle.prepare(`PRAGMA table_info(${table})`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!columns.some((column) => column.name === 'server_deleted_at'))
|
||||
handle.exec(`ALTER TABLE ${table} ADD COLUMN server_deleted_at TEXT`);
|
||||
}
|
||||
const collectionColumns = handle.prepare('PRAGMA table_info(server_launch_collections)').all() as { name: string }[];
|
||||
if (!collectionColumns.some(column => column.name === 'retained_server_deletions')) {
|
||||
handle.exec("ALTER TABLE server_launch_collections ADD COLUMN retained_server_deletions TEXT NOT NULL DEFAULT '{}'");
|
||||
const collectionColumns = handle
|
||||
.prepare('PRAGMA table_info(server_launch_collections)')
|
||||
.all() as { name: string }[];
|
||||
if (!collectionColumns.some((column) => column.name === 'retained_server_deletions')) {
|
||||
handle.exec(
|
||||
"ALTER TABLE server_launch_collections ADD COLUMN retained_server_deletions TEXT NOT NULL DEFAULT '{}'"
|
||||
);
|
||||
}
|
||||
// Historical unfinished work cannot claim the next server with this name.
|
||||
handle.exec(`DROP INDEX IF EXISTS idx_maintenance_pending;
|
||||
@@ -96,15 +123,17 @@ export function migrateServerHistory(handle: Database.Database): void {
|
||||
OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))
|
||||
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`;
|
||||
for (const table of historyTables) {
|
||||
const exception = table === 'server_deployments'
|
||||
? `AND (action != 'create' OR state IN ('done','warning') OR EXISTS (
|
||||
const exception =
|
||||
table === 'server_deployments'
|
||||
? `AND (action != 'create' OR state IN ('done','warning') OR EXISTS (
|
||||
SELECT 1 FROM server_deployments removal WHERE removal.server = server_deployments.server
|
||||
AND removal.rowid > server_deployments.rowid AND removal.action = 'destroy' AND removal.state IN ('done','warning')))
|
||||
AND id NOT IN (${liveRebuilds})`
|
||||
: table === 'deployment_servers'
|
||||
? `AND deployment_id NOT IN (SELECT campaign_id FROM watchdog_actions WHERE campaign_id IS NOT NULL AND operation_id IN (${liveRebuilds}))`
|
||||
: table === 'watchdog_incidents'
|
||||
? `AND id NOT IN (SELECT incident_id FROM watchdog_actions WHERE operation_id IN (${liveRebuilds}))` : '';
|
||||
: table === 'deployment_servers'
|
||||
? `AND deployment_id NOT IN (SELECT campaign_id FROM watchdog_actions WHERE campaign_id IS NOT NULL AND operation_id IN (${liveRebuilds}))`
|
||||
: table === 'watchdog_incidents'
|
||||
? `AND id NOT IN (SELECT incident_id FROM watchdog_actions WHERE operation_id IN (${liveRebuilds}))`
|
||||
: '';
|
||||
handle.exec(`UPDATE ${table} SET server_deleted_at = ${now} WHERE server_deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM servers WHERE name = ${table}.server AND destroyed_at IS NULL) ${exception}`);
|
||||
}
|
||||
|
||||
@@ -22,79 +22,131 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_server_key_leases_current ON server_key_le
|
||||
|
||||
/** Preserve every occupied legacy assignment, even duplicated credentials. */
|
||||
export function migrateServerKeys(handle: Database.Database): void {
|
||||
handle.transaction(() => {
|
||||
const legacy = (handle.prepare('PRAGMA table_info(server_keys)').all() as { name: string }[])
|
||||
.some(column => column.name === 'server_id');
|
||||
if (legacy) handle.exec('ALTER TABLE server_keys RENAME TO legacy_server_keys');
|
||||
handle.exec(keySchema + leaseSchema);
|
||||
if (!legacy) return;
|
||||
const rows = handle.prepare(`SELECT server_id, pluto_key, updated_at FROM legacy_server_keys ORDER BY server_id`).all() as
|
||||
{ server_id: string; pluto_key: string; updated_at: string }[];
|
||||
const occupied = handle.prepare(`SELECT 1 WHERE
|
||||
handle
|
||||
.transaction(() => {
|
||||
const legacy = (
|
||||
handle.prepare('PRAGMA table_info(server_keys)').all() as { name: string }[]
|
||||
).some((column) => column.name === 'server_id');
|
||||
if (legacy) handle.exec('ALTER TABLE server_keys RENAME TO legacy_server_keys');
|
||||
handle.exec(keySchema + leaseSchema);
|
||||
if (!legacy) return;
|
||||
const rows = handle
|
||||
.prepare(
|
||||
`SELECT server_id, pluto_key, updated_at FROM legacy_server_keys ORDER BY server_id`
|
||||
)
|
||||
.all() as { server_id: string; pluto_key: string; updated_at: string }[];
|
||||
const occupied = handle.prepare(`SELECT 1 WHERE
|
||||
EXISTS (SELECT 1 FROM servers WHERE name=@server AND destroyed_at IS NULL)
|
||||
OR EXISTS (SELECT 1 FROM native_instances WHERE server=@server AND retired_at IS NULL)
|
||||
OR EXISTS (SELECT 1 FROM server_deployments WHERE server=@server AND server_deleted_at IS NULL
|
||||
AND action IN ('create','rebuild') AND state NOT IN ('done','warning','cancelled'))`);
|
||||
const find = handle.prepare('SELECT id FROM server_keys WHERE game=? AND pluto_key=?');
|
||||
const serverGame = handle.prepare("SELECT game FROM servers WHERE name=? AND game IN ('t5','t6')");
|
||||
const insert = handle.prepare('INSERT INTO server_keys(id,game,label,pluto_key,updated_at) VALUES(?,?,?,?,?)');
|
||||
const lease = handle.prepare('INSERT INTO server_key_leases(server_id,key_id,is_current) VALUES(?,?,?)');
|
||||
const assignments = new Map<string, string[]>();
|
||||
function save(game: string, credential: string, label: string, updatedAt: string): string {
|
||||
const existing = (find.get(game, credential) as { id: string } | undefined)?.id;
|
||||
if (existing) return existing;
|
||||
const id = randomUUID();
|
||||
insert.run(id, game, label, credential, updatedAt);
|
||||
return id;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const game = /^(t5|t6)_/.exec(row.server_id)?.[1]
|
||||
?? (serverGame.get(row.server_id) as { game: string } | undefined)?.game;
|
||||
if (!game) throw new Error(`Cannot determine the game for saved server key ${row.server_id}`);
|
||||
const id = save(game, row.pluto_key, row.server_id, row.updated_at);
|
||||
if (occupied.get({ server: row.server_id })) assignments.set(row.server_id, [id]);
|
||||
}
|
||||
// The old form could overwrite a saved key without changing the running
|
||||
// container. Durable create/rebuild payloads are stronger evidence of
|
||||
// its actual credential. Keep ambiguous delivered attempts as additional
|
||||
// holds until the instance is destroyed; never free a possibly live key.
|
||||
const hasTable = (name: string) => !!handle.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
||||
if (hasTable('ec_host_operations')) {
|
||||
const payload = hasTable('ec_host_operation_payloads')
|
||||
? 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)' : 'o.payload';
|
||||
const command = `CASE WHEN json_valid(${payload}) THEN ${payload} ELSE '{}' END`;
|
||||
const launched = handle.prepare(`SELECT n.server,o.state,o.updated_at,
|
||||
const find = handle.prepare('SELECT id FROM server_keys WHERE game=? AND pluto_key=?');
|
||||
const serverGame = handle.prepare(
|
||||
"SELECT game FROM servers WHERE name=? AND game IN ('t5','t6')"
|
||||
);
|
||||
const insert = handle.prepare(
|
||||
'INSERT INTO server_keys(id,game,label,pluto_key,updated_at) VALUES(?,?,?,?,?)'
|
||||
);
|
||||
const lease = handle.prepare(
|
||||
'INSERT INTO server_key_leases(server_id,key_id,is_current) VALUES(?,?,?)'
|
||||
);
|
||||
const assignments = new Map<string, string[]>();
|
||||
function save(
|
||||
game: string,
|
||||
credential: string,
|
||||
label: string,
|
||||
updatedAt: string
|
||||
): string {
|
||||
const existing = (find.get(game, credential) as { id: string } | undefined)?.id;
|
||||
if (existing) return existing;
|
||||
const id = randomUUID();
|
||||
insert.run(id, game, label, credential, updatedAt);
|
||||
return id;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const game =
|
||||
/^(t5|t6)_/.exec(row.server_id)?.[1] ??
|
||||
(serverGame.get(row.server_id) as { game: string } | undefined)?.game;
|
||||
if (!game)
|
||||
throw new Error(
|
||||
`Cannot determine the game for saved server key ${row.server_id}`
|
||||
);
|
||||
const id = save(game, row.pluto_key, row.server_id, row.updated_at);
|
||||
if (occupied.get({ server: row.server_id })) assignments.set(row.server_id, [id]);
|
||||
}
|
||||
// The old form could overwrite a saved key without changing the running
|
||||
// container. Durable create/rebuild payloads are stronger evidence of
|
||||
// its actual credential. Keep ambiguous delivered attempts as additional
|
||||
// holds until the instance is destroyed; never free a possibly live key.
|
||||
const hasTable = (name: string) =>
|
||||
!!handle
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
||||
.get(name);
|
||||
if (hasTable('ec_host_operations')) {
|
||||
const payload = hasTable('ec_host_operation_payloads')
|
||||
? 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)'
|
||||
: 'o.payload';
|
||||
const command = `CASE WHEN json_valid(${payload}) THEN ${payload} ELSE '{}' END`;
|
||||
const launched = handle
|
||||
.prepare(
|
||||
`SELECT n.server,o.state,o.updated_at,
|
||||
json_extract(${command},'$.env.PLUTO_SERVER_KEY') AS credential,
|
||||
json_extract(${command},'$.game') AS game,
|
||||
json_extract(${command},'$.env.PLUTAINER_GAME') AS game_id
|
||||
FROM native_instances n JOIN ec_host_operations o ON o.instance_id=n.id AND o.host_id=n.host_id
|
||||
WHERE n.retired_at IS NULL AND o.kind IN ('instance.create','instance.rebuild') AND o.state!='failed'
|
||||
AND (o.generation=n.generation OR json_extract(${command},'$.nextGeneration')=n.generation)
|
||||
ORDER BY o.rowid DESC`).all() as
|
||||
{ server: string; state: string; updated_at: string; credential: unknown; game: unknown; game_id: unknown }[];
|
||||
const recovered = new Map<string, string[]>();
|
||||
const complete = new Set<string>();
|
||||
for (const row of launched) {
|
||||
if (complete.has(row.server) || typeof row.credential !== 'string' || !row.credential) continue;
|
||||
const game = row.game === 't5' || row.game === 't6' ? row.game
|
||||
: row.game_id === 't5sp' ? 't5' : row.game_id === 't6zm' ? 't6'
|
||||
: /^(t5|t6)_/.exec(row.server)?.[1]
|
||||
?? (serverGame.get(row.server) as { game: string } | undefined)?.game;
|
||||
if (!game) throw new Error(`Cannot determine the game for launched server ${row.server}`);
|
||||
const ids = recovered.get(row.server) ?? [];
|
||||
const id = save(game, row.credential, row.server, row.updated_at);
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
recovered.set(row.server, ids);
|
||||
if (row.state === 'completed') complete.add(row.server);
|
||||
ORDER BY o.rowid DESC`
|
||||
)
|
||||
.all() as {
|
||||
server: string;
|
||||
state: string;
|
||||
updated_at: string;
|
||||
credential: unknown;
|
||||
game: unknown;
|
||||
game_id: unknown;
|
||||
}[];
|
||||
const recovered = new Map<string, string[]>();
|
||||
const complete = new Set<string>();
|
||||
for (const row of launched) {
|
||||
if (
|
||||
complete.has(row.server) ||
|
||||
typeof row.credential !== 'string' ||
|
||||
!row.credential
|
||||
)
|
||||
continue;
|
||||
const game =
|
||||
row.game === 't5' || row.game === 't6'
|
||||
? row.game
|
||||
: row.game_id === 't5sp'
|
||||
? 't5'
|
||||
: row.game_id === 't6zm'
|
||||
? 't6'
|
||||
: (/^(t5|t6)_/.exec(row.server)?.[1] ??
|
||||
(serverGame.get(row.server) as { game: string } | undefined)
|
||||
?.game);
|
||||
if (!game)
|
||||
throw new Error(
|
||||
`Cannot determine the game for launched server ${row.server}`
|
||||
);
|
||||
const ids = recovered.get(row.server) ?? [];
|
||||
const id = save(game, row.credential, row.server, row.updated_at);
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
recovered.set(row.server, ids);
|
||||
if (row.state === 'completed') complete.add(row.server);
|
||||
}
|
||||
for (const [server, ids] of recovered) {
|
||||
// If no completed launch receipt survives, its saved assignment
|
||||
// remains a possible prior credential while the attempt resolves.
|
||||
if (!complete.has(server))
|
||||
for (const id of assignments.get(server) ?? [])
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
assignments.set(server, ids);
|
||||
}
|
||||
}
|
||||
for (const [server, ids] of recovered) {
|
||||
// If no completed launch receipt survives, its saved assignment
|
||||
// remains a possible prior credential while the attempt resolves.
|
||||
if (!complete.has(server)) for (const id of assignments.get(server) ?? []) if (!ids.includes(id)) ids.push(id);
|
||||
assignments.set(server, ids);
|
||||
}
|
||||
}
|
||||
for (const [server, ids] of assignments) ids.forEach((id, index) => lease.run(server, id, index === 0 ? 1 : 0));
|
||||
handle.exec('DROP TABLE legacy_server_keys');
|
||||
}).immediate();
|
||||
for (const [server, ids] of assignments)
|
||||
ids.forEach((id, index) => lease.run(server, id, index === 0 ? 1 : 0));
|
||||
handle.exec('DROP TABLE legacy_server_keys');
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/** Log query shapes, never bound values or SQL string literals. Disabled by default. */
|
||||
export function timeStatement(statement: Database.Statement, sql: string, thresholdMs: number): Database.Statement {
|
||||
export function timeStatement(
|
||||
statement: Database.Statement,
|
||||
sql: string,
|
||||
thresholdMs: number
|
||||
): Database.Statement {
|
||||
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return statement;
|
||||
const shape = sql.replace(/'(?:''|[^'])*'/g, '?').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
const shape = sql
|
||||
.replace(/'(?:''|[^'])*'/g, '?')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
for (const method of ['run', 'get', 'all'] as const) {
|
||||
const original = statement[method].bind(statement) as (...args: unknown[]) => unknown;
|
||||
Object.defineProperty(statement, method, { value: (...args: unknown[]) => {
|
||||
const started = performance.now();
|
||||
try { return original(...args); }
|
||||
finally {
|
||||
const ms = performance.now() - started;
|
||||
if (ms >= thresholdMs) console.warn('[sql]', { ms: Math.round(ms), method, sql: shape });
|
||||
Object.defineProperty(statement, method, {
|
||||
value: (...args: unknown[]) => {
|
||||
const started = performance.now();
|
||||
try {
|
||||
return original(...args);
|
||||
} finally {
|
||||
const ms = performance.now() - started;
|
||||
if (ms >= thresholdMs)
|
||||
console.warn('[sql]', { ms: Math.round(ms), method, sql: shape });
|
||||
}
|
||||
}
|
||||
} });
|
||||
});
|
||||
}
|
||||
return statement;
|
||||
}
|
||||
|
||||
@@ -3,25 +3,43 @@ import type Database from 'better-sqlite3';
|
||||
/** Keep metadata ahead of overflow pages, preserving IDs and all references. */
|
||||
export function migrateVersionLayout(handle: Database.Database): void {
|
||||
const columns = handle.pragma('table_info(script_versions)') as { name: string }[];
|
||||
if (!columns.some(column => column.name === 'content') || columns.at(-1)?.name === 'content') return;
|
||||
const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE name='script_versions'").get() as { sql: string };
|
||||
if (!columns.some((column) => column.name === 'content') || columns.at(-1)?.name === 'content')
|
||||
return;
|
||||
const { sql } = handle
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE name='script_versions'")
|
||||
.get() as { sql: string };
|
||||
const content = /\bcontent\s+BLOB\s+NOT\s+NULL\s*,/i;
|
||||
if (!content.test(sql)) throw new Error('Unrecognized script version content layout');
|
||||
const replacement = sql.replace(content, '').replace(/,\s*UNIQUE\s*\(/i, ', content BLOB NOT NULL, UNIQUE(')
|
||||
const replacement = sql
|
||||
.replace(content, '')
|
||||
.replace(/,\s*UNIQUE\s*\(/i, ', content BLOB NOT NULL, UNIQUE(')
|
||||
.replace('script_versions', 'script_versions_metadata');
|
||||
const dependents = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name='script_versions' AND type IN ('index','trigger') AND sql IS NOT NULL")
|
||||
const dependents = handle
|
||||
.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE tbl_name='script_versions' AND type IN ('index','trigger') AND sql IS NOT NULL"
|
||||
)
|
||||
.all() as { sql: string }[];
|
||||
const sequence = handle.prepare("SELECT seq FROM sqlite_sequence WHERE name='script_versions'").get() as { seq: number } | undefined;
|
||||
const sequence = handle
|
||||
.prepare("SELECT seq FROM sqlite_sequence WHERE name='script_versions'")
|
||||
.get() as { seq: number } | undefined;
|
||||
handle.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
handle.transaction(() => {
|
||||
handle.exec(replacement);
|
||||
const names = columns.map(column => `"${column.name}"`).join(',');
|
||||
const names = columns.map((column) => `"${column.name}"`).join(',');
|
||||
handle.exec(`INSERT INTO script_versions_metadata (${names}) SELECT ${names} FROM script_versions;
|
||||
DROP TABLE script_versions; ALTER TABLE script_versions_metadata RENAME TO script_versions;`);
|
||||
if (sequence) handle.prepare("UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='script_versions'").run(sequence.seq);
|
||||
if (sequence)
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='script_versions'"
|
||||
)
|
||||
.run(sequence.seq);
|
||||
for (const dependent of dependents) handle.exec(dependent.sql);
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Version layout migration found broken references');
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length)
|
||||
throw new Error('Version layout migration found broken references');
|
||||
})();
|
||||
} finally { handle.pragma('foreign_keys = ON'); }
|
||||
} finally {
|
||||
handle.pragma('foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user