diff --git a/database/migrations/2026_03_27_135040_repair_connection_workspaces_table.php b/database/migrations/2026_03_27_135040_repair_connection_workspaces_table.php new file mode 100644 index 0000000..21e541e --- /dev/null +++ b/database/migrations/2026_03_27_135040_repair_connection_workspaces_table.php @@ -0,0 +1,132 @@ +getDriverName(); + + if (! Schema::hasTable('connection_workspaces')) { + Schema::create('connection_workspaces', function (Blueprint $table) use ($driver): void { + $table->id(); + + if ($driver === 'surreal') { + $table->unsignedBigInteger('instance_connection_id'); + } else { + $table->foreignId('instance_connection_id')->constrained()->cascadeOnDelete(); + } + + $table->string('name'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->timestamps(); + + if ($driver !== 'surreal') { + $table->unique(['instance_connection_id', 'slug'], 'workspaces_connection_slug_unique'); + } + }); + } + + if (! Schema::hasTable('workspaces') || ! Schema::hasColumn('workspaces', 'instance_connection_id')) { + return; + } + + $legacyWorkspaces = DB::table('workspaces') + ->whereNotNull('instance_connection_id') + ->get([ + 'id', + 'instance_connection_id', + 'name', + 'slug', + 'summary', + 'created_at', + 'updated_at', + ]); + + if ($legacyWorkspaces->isEmpty()) { + return; + } + + $existingWorkspaces = collect(DB::table('connection_workspaces')->get([ + 'id', + 'instance_connection_id', + 'slug', + ])); + + $existingIds = $existingWorkspaces + ->map(fn (object $workspace): int|string|null => $workspace->id ?? null) + ->filter(fn (int|string|null $id): bool => $id !== null && $id !== '') + ->mapWithKeys(fn (int|string $id): array => [(string) $id => true]) + ->all(); + $existingWorkspaceKeys = $existingWorkspaces + ->map(fn (object $workspace): string => sprintf( + '%s:%s', + (string) ($workspace->instance_connection_id ?? ''), + (string) ($workspace->slug ?? ''), + )) + ->filter(fn (string $key): bool => $key !== ':') + ->mapWithKeys(fn (string $key): array => [$key => true]) + ->all(); + + $workspacePayload = $legacyWorkspaces + ->reject(fn (object $workspace): bool => isset($existingIds[(string) $workspace->id]) + || isset($existingWorkspaceKeys[sprintf('%s:%s', (string) $workspace->instance_connection_id, (string) $workspace->slug)])) + ->map(fn (object $workspace): array => [ + 'id' => $workspace->id, + 'instance_connection_id' => $workspace->instance_connection_id, + 'name' => $workspace->name, + 'slug' => $workspace->slug, + 'summary' => $workspace->summary, + 'created_at' => $workspace->created_at, + 'updated_at' => $workspace->updated_at, + ]) + ->values() + ->all(); + + if ($workspacePayload === []) { + $this->syncSurrealSequence($driver); + + return; + } + + DB::table('connection_workspaces')->insert($workspacePayload); + + $this->syncSurrealSequence($driver); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No-op: this repair/data migration should not drop the `connection_workspaces` table. + } + + private function syncSurrealSequence(string $driver): void + { + if ($driver !== 'surreal') { + return; + } + + $maxId = collect(DB::table('connection_workspaces')->get(['id'])) + ->map(fn (object $workspace): int => (int) ($workspace->id ?? 0)) + ->max(); + + if (! is_int($maxId) || $maxId <= 0) { + return; + } + + DB::statement(sprintf( + 'UPSERT ONLY __katra_sequences:connection_workspaces SET value = %d;', + $maxId, + )); + } +}; diff --git a/tests/Feature/SurrealWorkspaceModelTest.php b/tests/Feature/SurrealWorkspaceModelTest.php index e635438..81a4cb6 100644 --- a/tests/Feature/SurrealWorkspaceModelTest.php +++ b/tests/Feature/SurrealWorkspaceModelTest.php @@ -1,12 +1,16 @@ isAvailable()) { + $this->markTestSkipped('The `surreal` CLI is not available in this environment.'); + } + + $storagePath = storage_path('app/surrealdb/workspace-repair-test-'.Str::uuid()); + $originalDefaultConnection = config('database.default'); + + File::deleteDirectory($storagePath); + File::ensureDirectoryExists(dirname($storagePath)); + + try { + $server = retryStartingWorkspaceServer($client, $storagePath); + $port = $server['port']; + $endpoint = $server['endpoint']; + + config()->set('database.default', 'surreal'); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', $port); + config()->set('surreal.endpoint', $endpoint); + config()->set('surreal.username', 'root'); + config()->set('surreal.password', 'root'); + config()->set('surreal.namespace', 'katra'); + config()->set('surreal.database', 'workspace_repair_test'); + config()->set('surreal.storage_engine', 'surrealkv'); + config()->set('surreal.storage_path', $storagePath); + config()->set('surreal.runtime', 'local'); + config()->set('surreal.autostart', false); + + DB::purge('surreal'); + + $schema = Schema::connection('surreal'); + $connection = DB::connection('surreal'); + + $schema->create('connection_workspaces', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('instance_connection_id'); + $table->string('name'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->timestamps(); + }); + + $schema->create('workspaces', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('instance_connection_id'); + $table->string('name'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->timestamps(); + }); + + $connection->table('workspaces')->insert([ + [ + 'id' => 1, + 'instance_connection_id' => 42, + 'name' => 'Alpha', + 'slug' => 'alpha', + 'summary' => 'Legacy alpha workspace.', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 2, + 'instance_connection_id' => 42, + 'name' => 'Beta', + 'slug' => 'beta', + 'summary' => 'Legacy beta workspace.', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $migration = require database_path('migrations/2026_03_27_135040_repair_connection_workspaces_table.php'); + $migration->up(); + + $workspace = Workspace::create([ + 'instance_connection_id' => 42, + 'name' => 'Gamma', + 'slug' => 'gamma', + 'summary' => 'New workspace after repair.', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $workspaceIds = collect($connection->table('connection_workspaces')->orderBy('id')->get(['id'])) + ->map(fn (object $workspace): int => (int) ($workspace->id ?? 0)) + ->all(); + + expect((int) $workspace->getKey())->toBe(3) + ->and($workspaceIds)->toBe([1, 2, 3]); + } finally { + config()->set('database.default', $originalDefaultConnection); + + DB::purge('surreal'); + + if (isset($server['process'])) { + $server['process']->stop(1); + } + + File::deleteDirectory($storagePath); + } +}); + /** * @return array{endpoint: string, port: int, process: Process} */ diff --git a/tests/Feature/WorkspaceManagementTest.php b/tests/Feature/WorkspaceManagementTest.php index da41d42..5b341c5 100644 --- a/tests/Feature/WorkspaceManagementTest.php +++ b/tests/Feature/WorkspaceManagementTest.php @@ -5,6 +5,8 @@ use App\Models\Workspace; use App\Support\Connections\InstanceConnectionManager; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use function Pest\Laravel\actingAs; use function Pest\Laravel\get; @@ -183,3 +185,46 @@ post(route('workspaces.activate', $workspace)) ->assertNotFound(); }); + +test('the repair migration backfills connection workspaces for existing installs', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + ]); + + Schema::disableForeignKeyConstraints(); + + try { + Schema::dropIfExists('connection_workspaces'); + Schema::dropIfExists('workspaces'); + + Schema::create('workspaces', function ($table): void { + $table->id(); + $table->unsignedBigInteger('instance_connection_id'); + $table->string('name'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->timestamps(); + }); + + DB::table('workspaces')->insert([ + 'id' => 7, + 'instance_connection_id' => $connection->getKey(), + 'name' => 'Product Atlas', + 'slug' => 'product-atlas', + 'summary' => 'Legacy workspace row.', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_03_27_135040_repair_connection_workspaces_table.php'); + $migration->up(); + + expect(Schema::hasTable('connection_workspaces'))->toBeTrue() + ->and(DB::table('connection_workspaces')->where('id', 7)->value('name'))->toBe('Product Atlas') + ->and(DB::table('connection_workspaces')->where('id', 7)->value('instance_connection_id'))->toBe($connection->getKey()); + } finally { + Schema::enableForeignKeyConstraints(); + } +});