refactor: remove retired data format support

This commit is contained in:
JB
2026-07-26 21:01:36 -04:00
parent 6ea45c05fa
commit 8b80e55e6c
17 changed files with 385 additions and 158 deletions
+184 -9
View File
@@ -4,6 +4,80 @@ import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs';
const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value, keys) =>
isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const isRoll = (value) =>
hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value);
const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls)
&& Array.isArray(value.augmentSlots)
&& value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls);
const isChipSelection = (value) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
&& typeof value.setId === 'string'
&& (value.quality === 'Superior' || value.quality === 'High-end')
&& isRoll(value.primary)
&& isRollPair(value.secondary);
const isProtocolSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
&& typeof value.itemId === 'string'
&& isFiniteNumber(value.level)
&& isFiniteNumber(value.talentUpgrades);
const combatProfileKeys = ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'];
const isCombatProfile = (value) =>
hasExactKeys(value, combatProfileKeys) && combatProfileKeys.every((key) => isFiniteNumber(value[key]));
const isEffectStates = (value) =>
isRecord(value) && Object.values(value).every((entry) => {
if (!hasExactKeys(entry, entry?.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
return entry.overrides === undefined || (
isRecord(entry.overrides)
&& Object.entries(entry.overrides).every(([key, override]) =>
combatProfileKeys.includes(key) && isFiniteNumber(override)
)
);
});
const isCurrentLoadoutSnapshot = (raw) => {
let value;
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return false;
}
return hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
&& value.version === 2
&& typeof value.buildName === 'string'
&& hasExactKeys(value.gear, gearSlots)
&& gearSlots.every((slot) => isGearSelection(value.gear[slot]))
&& Array.isArray(value.weapons)
&& value.weapons.length === 2
&& value.weapons.every(isWeaponSelection)
&& Array.isArray(value.chips)
&& value.chips.length === 9
&& value.chips.every(isChipSelection)
&& isProtocolSelection(value.protocol)
&& Array.isArray(value.activeTalents)
&& value.activeTalents.every((talent) => typeof talent === 'string')
&& hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
&& typeof value.specialization.focusId === 'string'
&& isRecord(value.specialization.nodeRanks)
&& Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
&& isEffectStates(value.specialization.effectStates)
&& isCombatProfile(value.specialization.combatProfile);
};
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const schemaPath = resolve('./database/schema.sql');
let databaseAlreadyExisted = true;
@@ -106,15 +180,7 @@ const legacyProtocolEffects = await (await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'"
)).get();
if (legacyProtocolEffects) {
const legacyProtocolEffectCount = await (await database.prepare(
'SELECT COUNT(*) AS count FROM os_protocol_attribute_effects'
)).get();
if (Number(legacyProtocolEffectCount?.count ?? 0) > 0) {
throw new Error(
'Legacy OS Protocol attribute effects still contain rows. Export and reconcile them before removing the retired table.'
);
}
console.log('Removing the empty legacy OS Protocol attribute-effects table.');
console.log('Removing the retired OS Protocol attribute-effects table.');
await database.exec('DROP TABLE os_protocol_attribute_effects');
}
@@ -621,6 +687,58 @@ if (buildColumns.length && !buildColumns.some((column) => column.name === 'loado
}
if (buildColumns.length) {
await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'");
const builds = await (await database.prepare('SELECT id,loadout_json FROM builds')).all();
const obsoleteBuildIds = builds
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id));
if (obsoleteBuildIds.length) {
console.log(`Removing ${obsoleteBuildIds.length} build record(s) that do not use the current loadout format.`);
await database.exec('BEGIN');
try {
for (const id of obsoleteBuildIds) {
await (await database.prepare('UPDATE builds SET source_build_id=NULL WHERE source_build_id=?')).run(id);
await (await database.prepare('DELETE FROM builds WHERE id=?')).run(id);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
}
const currentBuildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all();
const loadoutColumn = currentBuildColumns.find((column) => column.name === 'loadout_json');
if (loadoutColumn && Number(loadoutColumn.notnull) !== 1) {
console.log('Requiring every build record to contain a current loadout snapshot.');
await database.exec('DROP INDEX IF EXISTS builds_visibility_updated_idx');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('DROP TABLE IF EXISTS builds_current');
await database.exec(`CREATE TABLE builds_current (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
visibility TEXT NOT NULL DEFAULT 'private',
summary TEXT,
guide_body TEXT,
source_build_id TEXT REFERENCES builds_current(id),
loadout_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO builds_current(id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at)
SELECT id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at FROM builds`);
await database.exec('DROP TABLE builds');
await database.exec('ALTER TABLE builds_current RENAME TO builds');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
}
await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)');
@@ -629,7 +747,64 @@ if (skillChipColumns.length && !skillChipColumns.some((column) => column.name ==
console.log('Associating Skill Chip definitions with specialization abilities.');
await database.exec('ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)');
}
const currentSkillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all();
const skillChipAbilityColumn = currentSkillChipColumns.find((column) => column.name === 'specialization_ability_id');
if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) {
const unassignedSkillChips = await (await database.prepare(
'SELECT id FROM skill_chip_sets WHERE specialization_ability_id IS NULL'
)).all();
if (unassignedSkillChips.length) {
console.log(`Removing ${unassignedSkillChips.length} Skill Chip record(s) without a specialization ability.`);
for (const row of unassignedSkillChips) {
await (await database.prepare('DELETE FROM skill_chip_sets WHERE id=?')).run(row.id);
}
}
console.log('Requiring every Skill Chip definition to reference a specialization ability.');
await database.exec('DROP INDEX IF EXISTS skill_chip_sets_ability_idx');
await database.exec('DROP INDEX IF EXISTS skill_chip_sets_name_nocase_idx');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('DROP TABLE IF EXISTS skill_chip_sets_current');
await database.exec(`CREATE TABLE skill_chip_sets_current (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO skill_chip_sets_current(id,name,specialization_ability_id,active,created_at,updated_at)
SELECT id,name,specialization_ability_id,active,created_at,updated_at FROM skill_chip_sets`);
await database.exec('DROP TABLE skill_chip_sets');
await database.exec('ALTER TABLE skill_chip_sets_current RENAME TO skill_chip_sets');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)');
await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)');
const calibrationCases = await (await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases'
)).all();
const obsoleteCalibrationIds = calibrationCases
.filter((calibrationCase) => !isCurrentLoadoutSnapshot(calibrationCase.loadout_snapshot_json))
.map((calibrationCase) => String(calibrationCase.id));
if (obsoleteCalibrationIds.length) {
console.log(`Removing ${obsoleteCalibrationIds.length} calculation calibration case(s) with obsolete loadout snapshots.`);
for (const id of obsoleteCalibrationIds) {
await (await database.prepare('DELETE FROM calculation_calibration_cases WHERE id=?')).run(id);
}
}
const foreignKeyFailures = await (await database.prepare('PRAGMA foreign_key_check')).all();
if (foreignKeyFailures.length) {
throw new Error(`Schema migration left ${foreignKeyFailures.length} foreign-key violation(s).`);
}
await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}'
WHERE builder_ready=1 AND (
SELECT COUNT(*) FROM specialization_focus_abilities link
+2 -2
View File
@@ -70,7 +70,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) {
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0;
@@ -80,7 +80,7 @@ try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
+2 -2
View File
@@ -64,7 +64,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) {
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0;
@@ -74,7 +74,7 @@ try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;