import { connect } from '@tursodatabase/database'; import { access, mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import process from 'node:process'; import { initializeDefaultUsers } from './default-users.mjs'; const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'); const schemaPath = resolve('./database/schema.sql'); let databaseAlreadyExisted = true; try { await access(databasePath); } catch { databaseAlreadyExisted = false; } await mkdir(dirname(databasePath), { recursive: true }); const database = await connect(databasePath); const schema = await readFile(schemaPath, 'utf8'); const usersTable = await (await database.prepare( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='users'" )).get(); if (usersTable) { const userColumns = await (await database.prepare("PRAGMA table_info('users')")).all(); if (!userColumns.some((column) => column.name === 'login_username')) { console.log('Separating optional local login usernames from internal account identifiers.'); await database.exec('ALTER TABLE users ADD COLUMN login_username TEXT'); await database.exec('UPDATE users SET login_username=username WHERE password_hash IS NOT NULL'); } } // The project schema deliberately avoids semicolons inside string literals. const statements = schema .split(';') .map((statement) => statement.trim()) .filter(Boolean); for (const statement of statements) { await database.exec(statement); } const loginEventsDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='login_events'" )).get(); if (typeof loginEventsDefinition?.sql === 'string' && !loginEventsDefinition.sql.includes('magic_link')) { console.log('Expanding login event providers for passwordless authentication.'); await database.exec('ALTER TABLE login_events RENAME TO login_events_legacy'); await database.exec(`CREATE TABLE login_events ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id) ON DELETE SET NULL, provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')), succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)), created_at TEXT NOT NULL )`); await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at) SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`); await database.exec('DROP TABLE login_events_legacy'); await database.exec('CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)'); } const loginEventColumns = await (await database.prepare("PRAGMA table_info('login_events')")).all(); for (const [name, definition] of [ ['ip_address', 'TEXT'], ['user_agent', 'TEXT'], ['client_name', 'TEXT'], ['operating_system', 'TEXT'], ['device_type', 'TEXT'], ['location_country', 'TEXT'], ['location_region', 'TEXT'], ['location_city', 'TEXT'], ['failure_reason', 'TEXT'], ['risk_flags', 'TEXT'], ['credential_id', 'TEXT'], ['session_id', 'TEXT REFERENCES sessions(id) ON DELETE SET NULL'], ['session_ended_at', 'TEXT'], ['session_end_reason', 'TEXT'], ['sensitive_purged_at', 'TEXT'] ]) { if (!loginEventColumns.some((column) => column.name === name)) { await database.exec(`ALTER TABLE login_events ADD COLUMN ${name} ${definition}`); } } const proposalColumns = await (await database.prepare("PRAGMA table_info('data_change_proposals')")).all(); for (const [name, definition] of [ ['draft_entity_id', 'TEXT'], ['applied_entity_id', 'TEXT'] ]) { if (!proposalColumns.some((column) => column.name === name)) { await database.exec(`ALTER TABLE data_change_proposals ADD COLUMN ${name} ${definition}`); } } await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL"); await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); const proposalDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" )).get(); if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'configuration'")) { console.log('Expanding moderated data modules for Global Configuration.'); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec(`CREATE TABLE data_change_proposals_expanded ( id TEXT PRIMARY KEY, module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','protocols','skillchips')), action TEXT NOT NULL, entity_id TEXT, draft_entity_id TEXT, applied_entity_id TEXT, summary TEXT NOT NULL, payload_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')), proposed_by TEXT NOT NULL REFERENCES users(id), reviewed_by TEXT REFERENCES users(id), review_note TEXT, created_at TEXT NOT NULL, reviewed_at TEXT, applied_at TEXT )`); await database.exec(`INSERT INTO data_change_proposals_expanded SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at FROM data_change_proposals`); await database.exec('DROP TABLE data_change_proposals'); await database.exec('ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals'); await database.exec('PRAGMA foreign_keys=ON'); } await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); const iconDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" )).get(); if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'skill_chip'")) { console.log('Adding Skill Chip icon storage support.'); await database.exec(`CREATE TABLE game_data_icons_expanded ( entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')), entity_id TEXT NOT NULL, storage_key TEXT NOT NULL UNIQUE, byte_size INTEGER NOT NULL CHECK(byte_size > 0), updated_at TEXT NOT NULL, PRIMARY KEY(entity_type, entity_id) )`); await database.exec(`INSERT INTO game_data_icons_expanded SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`); await database.exec('DROP TABLE game_data_icons'); await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons'); } const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) { console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.'); await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_legacy'); await database.exec(`CREATE TABLE gear_tactical_augments ( id TEXT PRIMARY KEY, gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')), stat_attribute_id TEXT REFERENCES stat_attributes(id), value REAL NOT NULL, unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(gear_slot, augment_slot), CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)), CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0)) )`); await database.exec(`INSERT INTO gear_tactical_augments (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,unlock_cost,created_at,updated_at) SELECT id,gear_slot,augment_slot, CASE WHEN augment_slot=1 THEN 'Custom' ELSE 'Base Percentage' END, CASE WHEN augment_slot=1 THEN stat_attribute_id ELSE NULL END, CASE WHEN augment_slot=1 THEN value WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=2 THEN 40 WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=3 THEN 70 WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=4 THEN 100 WHEN augment_slot=2 THEN 50 ELSE 100 END, unlock_cost,created_at,updated_at FROM gear_tactical_augments_legacy`); await database.exec('DROP TABLE gear_tactical_augments_legacy'); } const tieredGearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); if (tieredGearAugmentColumns.some((column) => column.name === 'unlock_cost')) { console.log('Separating Gear Tactical Augment unlock costs by item tier.'); await database.exec('DROP TABLE IF EXISTS gear_tactical_augment_costs'); await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_single_cost'); await database.exec(`CREATE TABLE gear_tactical_augments ( id TEXT PRIMARY KEY, gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')), stat_attribute_id TEXT REFERENCES stat_attributes(id), value REAL NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(gear_slot, augment_slot), CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)), CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0)) )`); await database.exec(`INSERT INTO gear_tactical_augments (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at) SELECT id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at FROM gear_tactical_augments_single_cost`); await database.exec(`CREATE TABLE gear_tactical_augment_costs ( augment_id TEXT NOT NULL REFERENCES gear_tactical_augments(id) ON DELETE CASCADE, gear_tier INTEGER NOT NULL CHECK(gear_tier BETWEEN 0 AND 2), unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), PRIMARY KEY(augment_id, gear_tier) )`); await database.exec(`INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) SELECT id,0,unlock_cost FROM gear_tactical_augments_single_cost UNION ALL SELECT id,1,unlock_cost FROM gear_tactical_augments_single_cost UNION ALL SELECT id,2,unlock_cost FROM gear_tactical_augments_single_cost`); await database.exec('DROP TABLE gear_tactical_augments_single_cost'); } const gearColumns = await (await database.prepare("PRAGMA table_info('gear')")).all(); if (gearColumns.some((column) => column.name === 'level')) { console.log('Moving Gear level from canonical ISAC data to builder-instance state.'); await database.exec('ALTER TABLE gear DROP COLUMN level'); } if (!gearColumns.some((column) => column.name === 'range_reference_level')) { console.log('Recording the reference level for canonical Gear ranges.'); await database.exec('ALTER TABLE gear ADD COLUMN range_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(range_reference_level BETWEEN 1 AND 50)'); } const weaponRangeColumns = await (await database.prepare("PRAGMA table_info('weapon_tier_ranges')")).all(); if (!weaponRangeColumns.some((column) => column.name === 'reference_level')) { console.log('Recording the reference level for canonical Weapon ranges.'); await database.exec('ALTER TABLE weapon_tier_ranges ADD COLUMN reference_level INTEGER NOT NULL DEFAULT 50 CHECK(reference_level BETWEEN 1 AND 50)'); } const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all(); for (const [name, definition] of [ ['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'], ['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)'] ]) { if (!attributeColumns.some((column) => column.name === name)) { console.log(`Recording the reference level for Attribute source ranges: ${name}.`); await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`); } } const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all(); for (const [name, definition] of [ ['cooldown_seconds', 'REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds > 0)'], ['duration_seconds', 'REAL CHECK(duration_seconds IS NULL OR duration_seconds > 0)'], ['max_stacks', 'INTEGER CHECK(max_stacks IS NULL OR max_stacks > 0)'] ]) { if (!talentColumns.some((column) => column.name === name)) { console.log(`Adding optional Talent mechanic: ${name}.`); await database.exec(`ALTER TABLE talents ADD COLUMN ${name} ${definition}`); } } const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) { console.log('Adding persistent builder loadout snapshots.'); await database.exec('ALTER TABLE builds ADD COLUMN loadout_json TEXT'); } if (buildColumns.length) { await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'"); } await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)'); console.log(`Applied ${statements.length} schema statements to ${databasePath}`); if (!databaseAlreadyExisted) { console.log('New database detected; creating default users.'); await initializeDefaultUsers(database); }