*/ private const REQUIRED_COLUMNS = [ 'superseded_by_collection_id' => 'INT NULL', 'superseded_at' => 'DATETIME NULL', 'superseded_by_user_id' => 'INT NULL', ]; public static function hasRequiredColumns(): bool { try { global $db; if (!self::canInspectSchema($db) || !self::tableExists($db)) { return false; } if (self::allColumnsExist($db)) { return true; } if (!self::acquireLock($db)) { return false; } try { // Another worker may have completed the additive migration while // this request waited for the advisory lock. foreach (self::REQUIRED_COLUMNS as $column => $definition) { if (self::columnExists($db, $column)) { continue; } $result = $db->query( "ALTER TABLE `" . self::TABLE . "` ADD COLUMN `{$column}` {$definition}" ); if ($result === false) { return false; } } if (!self::allColumnsExist($db)) { return false; } return true; } finally { self::releaseLock($db); } } catch (\Throwable) { // Schema readiness is a capability gate. It must never break the // legacy invoicing routes when DDL or metadata access is unavailable. return false; } } private static function canInspectSchema(mixed $db): bool { return is_object($db) && method_exists($db, 'query') && method_exists($db, 'escape_string') && method_exists($db, 'getDatabase'); } private static function tableExists(object $db): bool { return self::informationSchemaCount( $db, 'information_schema.TABLES', 'TABLE_NAME', self::TABLE ) > 0; } private static function columnExists(object $db, string $column): bool { return self::informationSchemaCount( $db, 'information_schema.COLUMNS', 'COLUMN_NAME', $column ) > 0; } private static function informationSchemaCount( object $db, string $informationSchemaTable, string $nameField, string $name ): int { $database = $db->escape_string((string)$db->getDatabase()); $name = $db->escape_string($name); $result = $db->query( "SELECT COUNT(*) AS c FROM {$informationSchemaTable} " . "WHERE TABLE_SCHEMA = '{$database}' AND TABLE_NAME = '" . self::TABLE . "' " . "AND {$nameField} = '{$name}'" ); if (!is_object($result) || !method_exists($result, 'fetch_assoc')) { throw new \RuntimeException('Invoice collection schema inspection failed'); } $row = $result->fetch_assoc(); if (!is_array($row) || !array_key_exists('c', $row)) { throw new \RuntimeException('Invoice collection schema inspection returned an invalid result'); } return (int)$row['c']; } private static function allColumnsExist(object $db): bool { foreach (array_keys(self::REQUIRED_COLUMNS) as $column) { if (!self::columnExists($db, $column)) { return false; } } return true; } private static function acquireLock(object $db): bool { $result = $db->query("SELECT GET_LOCK('" . self::LOCK_NAME . "', 5) AS acquired"); if (!is_object($result) || !method_exists($result, 'fetch_assoc')) { return false; } $row = $result->fetch_assoc(); return is_array($row) && (int)($row['acquired'] ?? 0) === 1; } private static function releaseLock(object $db): void { try { $db->query("SELECT RELEASE_LOCK('" . self::LOCK_NAME . "')"); } catch (\Throwable) { // The connection also releases advisory locks automatically. } } }