## Summary - gate object-tree v2 on exact, schema-backed supersession audit columns - serialize checked additive DDL and fail closed without disrupting legacy invoicing - preserve pre-schema supersession markers when structured columns are still null - add a superuser-only, self-scoped canary endpoint with locked legacy-to-canonical allowlist migration - verify effective activation, roll back failed readiness, and audit enable/disable/failure ## Verification - focused invoicing safety: 16 tests passed (99 assertions) - full unit suite: 1,237 passed (9,003 assertions), 2 skipped, existing warnings only - PHP syntax and `git diff --check` clean - independent architecture, security, and reviewer gates: GO ## Activation Deploy with global database/environment enablement off. POST the self-canary endpoint for one authenticated superuser, require `configured_enabled=true` and `effective_enabled=true`, then verify the exact period and tree GET routes. Roll back with the same endpoint using `enabled=false`.
143 lines
4.5 KiB
PHP
143 lines
4.5 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
/**
|
|
* Ensures additive audit columns used when invoice collections are superseded.
|
|
*/
|
|
class invoice_collection_schema_bootstrap
|
|
{
|
|
private const TABLE = 'collected_order_invoices';
|
|
private const LOCK_NAME = 'invoice_collection_schema_v1';
|
|
|
|
/** @var array<string, string> */
|
|
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.
|
|
}
|
|
}
|
|
}
|