103 lines
8.1 KiB
PHP
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));
|
|
}
|
|
}
|