Skip to content

Commit f016957

Browse files
Adopt workflow package migrations with tables already present
Restore server:bootstrap resilience when a workflow_schedule_history_events table exists on a fresh connection but the corresponding migration row is missing. The workflow v2 migration slate is clean-slate create-table only, so the recovery guard for this scenario lives in the server. A new MigrationAdoption helper scans every registered migration file, resolves Schema::create targets (including self::CONST literals), and records migrations whose target tables are already present, letting the subsequent migrate call skip them. Covers six feature cases: the schedule-history adoption path, no-op on clean state, refusal when the table is missing, pass-through for ALTER-style migrations, repository creation, and multi-table adoption.
1 parent 80e3aa1 commit f016957

3 files changed

Lines changed: 261 additions & 1 deletion

File tree

app/Support/MigrationAdoption.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
namespace App\Support;
4+
5+
use Illuminate\Database\Migrations\Migrator;
6+
use Illuminate\Support\Facades\Schema;
7+
8+
/**
9+
* Adopts create-table migrations whose target tables already exist but are not
10+
* yet recorded in the `migrations` table.
11+
*
12+
* BLK-S002 surfaced a real operator scenario: a fresh MySQL deploy of the
13+
* server image wedged because an earlier partial bootstrap had already created
14+
* the `workflow_schedule_history_events` table but not recorded the migration.
15+
* Each retry then hit "table already exists".
16+
*
17+
* The workflow v2 migration slate is pinned as final-form create-table only
18+
* (Workflow\V2 `MigrationsTest::testV2MigrationSlateDoesNotUseSchemaDetectionGuards`),
19+
* so the recovery guard lives here in the server instead of in the package
20+
* migrations. This scans every registered migration, and for any pending
21+
* migration whose `Schema::create()` targets all already exist on the
22+
* connection, records it as applied in the current batch so `migrate` becomes
23+
* a no-op for those files.
24+
*/
25+
class MigrationAdoption
26+
{
27+
public function __construct(
28+
private readonly Migrator $migrator,
29+
) {
30+
}
31+
32+
/**
33+
* @return list<string> names of migrations that were adopted
34+
*/
35+
public function adopt(): array
36+
{
37+
$repository = $this->migrator->getRepository();
38+
39+
if (! $repository->repositoryExists()) {
40+
$repository->createRepository();
41+
}
42+
43+
$paths = array_merge(
44+
[database_path('migrations')],
45+
$this->migrator->paths(),
46+
);
47+
48+
$files = $this->migrator->getMigrationFiles($paths);
49+
50+
if ($files === []) {
51+
return [];
52+
}
53+
54+
$ran = $repository->getRan();
55+
$batch = $repository->getNextBatchNumber();
56+
$adopted = [];
57+
58+
foreach ($files as $name => $path) {
59+
if (in_array($name, $ran, true)) {
60+
continue;
61+
}
62+
63+
$tables = $this->createdTablesIn($path);
64+
65+
if ($tables === []) {
66+
continue;
67+
}
68+
69+
foreach ($tables as $table) {
70+
if (! Schema::hasTable($table)) {
71+
continue 2;
72+
}
73+
}
74+
75+
$repository->log($name, $batch);
76+
$adopted[] = $name;
77+
}
78+
79+
return $adopted;
80+
}
81+
82+
/**
83+
* Extract table names from `Schema::create(...)` calls in a migration file.
84+
* Handles both string literals and `self::CONST` references where the
85+
* constant is declared in the same file as a string. Returns an empty list
86+
* for migrations that do not create tables (ALTER-style, no-op tombstones,
87+
* dynamic names), which are left to the normal migrator.
88+
*
89+
* @return list<string>
90+
*/
91+
private function createdTablesIn(string $path): array
92+
{
93+
$contents = @file_get_contents($path);
94+
95+
if ($contents === false || $contents === '') {
96+
return [];
97+
}
98+
99+
$constants = [];
100+
101+
if (preg_match_all(
102+
"/\\bconst\\s+(\\w+)\\s*=\\s*['\"]([^'\"]+)['\"]/",
103+
$contents,
104+
$constMatches,
105+
)) {
106+
$constants = array_combine($constMatches[1], $constMatches[2]);
107+
}
108+
109+
if (! preg_match_all(
110+
"/Schema::create\\(\\s*(?:['\"]([^'\"]+)['\"]|self::(\\w+))/",
111+
$contents,
112+
$createMatches,
113+
)) {
114+
return [];
115+
}
116+
117+
$tables = [];
118+
119+
foreach ($createMatches[1] as $i => $literal) {
120+
if ($literal !== '') {
121+
$tables[] = $literal;
122+
123+
continue;
124+
}
125+
126+
$constName = $createMatches[2][$i] ?? '';
127+
128+
if ($constName !== '' && isset($constants[$constName])) {
129+
$tables[] = $constants[$constName];
130+
}
131+
}
132+
133+
return array_values(array_unique($tables));
134+
}
135+
}

routes/console.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,26 @@
33
use App\Models\WorkflowNamespace;
44
use App\Support\EnvAuditor;
55
use App\Support\HistoryRetentionEnforcer;
6+
use App\Support\MigrationAdoption;
7+
use Illuminate\Database\Migrations\Migrator;
68
use Illuminate\Support\Facades\Artisan;
79
use Workflow\V2\Enums\RunStatus;
810
use Workflow\V2\Models\WorkflowRunSummary;
911
use Workflow\V2\Support\ActivityTimeoutEnforcer;
1012
use Workflow\V2\Support\ScheduleManager;
1113

12-
Artisan::command('server:bootstrap {--force : Run bootstrap commands without a production prompt}', function (): int {
14+
Artisan::command('server:bootstrap {--force : Run bootstrap commands without a production prompt}', function (Migrator $migrator): int {
1315
$this->components->info('Running Durable Workflow server bootstrap...');
1416

17+
$adopted = (new MigrationAdoption($migrator))->adopt();
18+
19+
foreach ($adopted as $name) {
20+
$this->components->twoColumnDetail(
21+
$name,
22+
'<fg=yellow>adopted</> (table already existed)',
23+
);
24+
}
25+
1526
$migrate = $this->call('migrate', [
1627
'--force' => (bool) $this->option('force'),
1728
]);
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature;
6+
7+
use App\Support\MigrationAdoption;
8+
use Illuminate\Database\Migrations\Migrator;
9+
use Illuminate\Database\Schema\Blueprint;
10+
use Illuminate\Foundation\Testing\RefreshDatabase;
11+
use Illuminate\Support\Facades\DB;
12+
use Illuminate\Support\Facades\Schema;
13+
use Tests\TestCase;
14+
15+
class MigrationAdoptionTest extends TestCase
16+
{
17+
use RefreshDatabase;
18+
19+
public function test_adopts_workflow_package_migration_when_table_exists_without_record(): void
20+
{
21+
$migration = '2026_04_16_000180_create_workflow_schedule_history_events_table';
22+
23+
$this->assertTrue(Schema::hasTable('workflow_schedule_history_events'));
24+
25+
DB::table('migrations')->where('migration', $migration)->delete();
26+
27+
$adopted = (new MigrationAdoption($this->app->make(Migrator::class)))->adopt();
28+
29+
$this->assertContains($migration, $adopted);
30+
$this->assertTrue(
31+
DB::table('migrations')->where('migration', $migration)->exists()
32+
);
33+
}
34+
35+
public function test_does_not_adopt_migrations_that_already_have_records(): void
36+
{
37+
$adopted = (new MigrationAdoption($this->app->make(Migrator::class)))->adopt();
38+
39+
$this->assertSame([], $adopted);
40+
}
41+
42+
public function test_does_not_adopt_when_target_table_is_missing(): void
43+
{
44+
$migration = '2026_04_16_000180_create_workflow_schedule_history_events_table';
45+
46+
Schema::drop('workflow_schedule_history_events');
47+
DB::table('migrations')->where('migration', $migration)->delete();
48+
49+
$adopted = (new MigrationAdoption($this->app->make(Migrator::class)))->adopt();
50+
51+
$this->assertNotContains($migration, $adopted);
52+
$this->assertFalse(
53+
DB::table('migrations')->where('migration', $migration)->exists()
54+
);
55+
}
56+
57+
public function test_skips_alter_style_migrations_with_no_create_table(): void
58+
{
59+
$migration = '2026_04_21_000300_add_workflow_definition_fingerprints_to_worker_registrations';
60+
61+
$this->assertTrue(Schema::hasColumn('workflow_worker_registrations', 'workflow_definition_fingerprints'));
62+
63+
DB::table('migrations')->where('migration', $migration)->delete();
64+
65+
$adopted = (new MigrationAdoption($this->app->make(Migrator::class)))->adopt();
66+
67+
$this->assertNotContains($migration, $adopted);
68+
}
69+
70+
public function test_creates_migrations_table_when_missing(): void
71+
{
72+
Schema::drop('migrations');
73+
74+
$this->assertFalse(Schema::hasTable('migrations'));
75+
76+
(new MigrationAdoption($this->app->make(Migrator::class)))->adopt();
77+
78+
$this->assertTrue(Schema::hasTable('migrations'));
79+
}
80+
81+
public function test_adopts_only_create_migrations_with_all_target_tables_present(): void
82+
{
83+
Schema::create('adoption_synthetic_one', function (Blueprint $table): void {
84+
$table->id();
85+
});
86+
87+
Schema::create('adoption_synthetic_two', function (Blueprint $table): void {
88+
$table->id();
89+
});
90+
91+
$fixture = sys_get_temp_dir() . '/adoption-' . uniqid();
92+
mkdir($fixture);
93+
94+
file_put_contents(
95+
$fixture . '/2099_01_01_000001_create_synthetic_tables.php',
96+
'<?php return new class extends \\Illuminate\\Database\\Migrations\\Migration { '
97+
. 'public function up(): void { \\Illuminate\\Support\\Facades\\Schema::create(\'adoption_synthetic_one\', fn ($t) => $t->id()); '
98+
. '\\Illuminate\\Support\\Facades\\Schema::create(\'adoption_synthetic_two\', fn ($t) => $t->id()); } '
99+
. 'public function down(): void {} };'
100+
);
101+
102+
$migrator = $this->app->make(Migrator::class);
103+
$migrator->path($fixture);
104+
105+
try {
106+
$adopted = (new MigrationAdoption($migrator))->adopt();
107+
} finally {
108+
unlink($fixture . '/2099_01_01_000001_create_synthetic_tables.php');
109+
rmdir($fixture);
110+
}
111+
112+
$this->assertContains('2099_01_01_000001_create_synthetic_tables', $adopted);
113+
}
114+
}

0 commit comments

Comments
 (0)