refactor: remove retired data format support
This commit is contained in:
+184
-9
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user