Files
SGF/app/Shared/Installation/CompositionSchema.php
2026-11-11 09:00:17 +02:00

103 lines
8.1 KiB
PHP

<?php
namespace App\Shared\Installation;
use Illuminate\Support\Facades\{DB,Schema};
/** Explicit additive composition, separate from global migrate and the SGC connector. */
final class CompositionSchema
{
public const MIGRATIONS=[
'2026_10_25_030000_create_services_delivery_a.php',
'2026_10_25_040000_create_internal_confirmations.php',
'2026_10_26_010000_create_document_preparation.php',
'2026_10_26_020000_create_document_preview_models.php',
'2026_10_26_030000_create_service_calculations.php',
'2026_10_26_040000_create_catalogue_accounting.php',
'2026_10_26_050000_create_service_fiscal_lifecycle.php',
'2026_10_26_060000_create_test_invoice_issuance.php',
'2026_10_27_070000_create_simulated_receivables.php',
'2026_10_27_080000_create_workshop_resource_records.php',
'2026_10_27_090000_create_workshop_direct_costs.php',
'2026_10_27_100000_create_industrial_cost_simulation.php',
'2026_10_28_110000_create_industrial_budgets.php',
'2026_10_28_120000_create_workshop_billing_drafts.php',
'2026_10_28_130000_create_workshop_billing_corrections.php',
];
public static function identity():array
{
abort_unless(app()->runningInConsole()&&app()->environment('budget-testing'),403);
return CompositionRuntime::physical();
}
/** Callback may prove installed structures inside the same transaction.
* Default is a full rollback. No role, grant, account, association or document is created.
*/
public function run(bool $commit=false,?\Closure $verify=null):array
{
$identity=self::identity();$host=DB::connection();
abort_unless($host->transactionLevel()===0,409,'Não compor dentro de uma transacção alheia.');
$constraint=$host->selectOne("SELECT pg_get_constraintdef(oid) value FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->value??null;
if(!is_string($constraint)||!preg_match('/^CHECK \((.*)\)$/s',$constraint,$m))throw new \LogicException('COMPOSITION_PERMISSION_CHECK_MISSING');
$connection=new CompositionSchemaConnection($host->getPdo(),$host->getDatabaseName(),'',$host->getConfig());
$connection->originalPermissionCheck=$m[1];
$name='sgf_composition_schema';$original=DB::getDefaultConnection();
config(['database.connections.'.$name=>$host->getConfig()]);DB::extend($name,fn()=>$connection);DB::setDefaultConnection($name);Schema::clearResolvedInstance('db.schema');app()->forgetInstance('db.schema');
$result=[];$connection->beginTransaction();
try{
$connection->statement("SET LOCAL lock_timeout='5s'");$connection->statement("SET LOCAL statement_timeout='30s'");
$connection->selectOne("SELECT pg_advisory_xact_lock(hashtext('sgf-single-installation-schema'))");
if($connection->getSchemaBuilder()->hasTable('sgf_composition_schema')){
$stored=$connection->table('sgf_composition_schema')->where('id',1)->first();
if(!$stored||$stored->source_hash!==$this->fingerprint())throw new \LogicException('COMPOSITION_SCHEMA_VERSION_CHANGED');
$result=['identity'=>$identity,'replayed'=>true,'source_hash'=>$stored->source_hash,'reused_tables'=>[],'migrations'=>[]];
}else{
$before=array_column($connection->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"),'tablename');
foreach(['b_internal_confirmations','doc_records','wo_orders','sr_records','ca_proposals'] as $table)if(in_array($table,$before,true))throw new \LogicException('COMPOSITION_PARTIAL_SCHEMA_REQUIRES_REVIEW: '.$table);
if(!$connection->selectOne("SELECT to_regprocedure('public.sgf_append_only_audit()') value")->value)$connection->unprepared("CREATE FUNCTION sgf_append_only_audit() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SGF_APPEND_ONLY_AUDIT'; END; \$\$;");
$migrations=[];
$auditTables=['a_history','a_events','a_commands','a_outbox','a_pending'];$present=array_intersect($auditTables,$before);
if(count($present)!==0&&count($present)!==count($auditTables))throw new \LogicException('COMPOSITION_PARTIAL_A_HISTORY');
foreach(self::MIGRATIONS as $index=>$file){
if($index===0&&count($present)===count($auditTables)){
foreach(['a_clients','a_services','a_service_drafts','a_billing_drafts'] as $table){
if(!in_array($table,$before,true))throw new \LogicException('COMPOSITION_PARTIAL_A_RECORDS');
$connection->getSchemaBuilder()->create($table,fn()=>null);
}
$guards=$connection->selectOne("SELECT count(*) n FROM pg_trigger WHERE NOT tgisinternal AND tgenabled='O' AND tgname IN ('a_clients_guard','a_services_guard','a_service_drafts_guard','a_billing_drafts_guard','a_history_scope','a_history_append_only','a_events_append_only','a_commands_append_only','a_outbox_append_only','a_pending_append_only')")->n;
if((int)$guards!==10)throw new \LogicException('COMPOSITION_A_GUARDS_INCOMPLETE');
continue;
}
$migration=require database_path('migrations/'.$file);$migration->up();$migrations[]=$file;
}
$connection->unprepared('CREATE TABLE sgf_composition_schema(id smallint PRIMARY KEY CHECK(id=1),source_hash char(64) NOT NULL,installed_at timestamptz NOT NULL DEFAULT now())');
$connection->table('sgf_composition_schema')->insert(['id'=>1,'source_hash'=>$this->fingerprint()]);
$connection->unprepared('CREATE TRIGGER composition_schema_append_only BEFORE UPDATE OR DELETE ON sgf_composition_schema FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()');
// Rebind ONLY the newly created QA guards to this exact database,
// role and cluster. The source SQL and all other installations retain
// their original guard; no general local/development bypass is added.
foreach(['wo','sr','dc','ic'] as $prefix){
$connection->unprepared("CREATE OR REPLACE FUNCTION sgf_{$prefix}_environment() RETURNS void LANGUAGE plpgsql AS \$\$ BEGIN IF current_database() IS DISTINCT FROM '".$identity['database']."' OR current_user IS DISTINCT FROM '".$identity['role']."' OR inet_server_port() IS DISTINCT FROM 55439 OR current_setting('cluster_name') IS DISTINCT FROM '".$identity['cluster']."' THEN RAISE EXCEPTION 'SGF_COMPOSITION_OWNED_QA_ONLY'; END IF; END; \$\$;");
}
$after=array_column($connection->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"),'tablename');
$result=['identity'=>$identity,'replayed'=>false,'source_hash'=>$this->fingerprint(),'reused_tables'=>$connection->getSchemaBuilder()->reused,'new_tables'=>array_values(array_diff($after,$before)),'migrations'=>$migrations];
}
if($verify)$result['verification']=$verify($connection);
if($commit)$connection->commit();else $connection->rollBack();
$result['committed']=$commit;return $result;
}finally{
while($connection->transactionLevel()>0)$connection->rollBack();
DB::setDefaultConnection($original);Schema::clearResolvedInstance('db.schema');app()->forgetInstance('db.schema');DB::purge($name);
}
}
public function fingerprint():string
{
$files=[];foreach(self::MIGRATIONS as $file)$files['database/migrations/'.$file]=hash_file('sha256',database_path('migrations/'.$file));
foreach(glob(database_path('sql/*.sql')) as $file)$files['database/sql/'.basename($file)]=hash_file('sha256',$file);
foreach(['CompositionSchema.php','CompositionSchemaConnection.php','CompositionSchemaBuilder.php','CompositionRuntime.php'] as $file)$files[$file]=hash_file('sha256',__DIR__.'/'.$file);
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR));
}
}