Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ The Laravel AI SDK is installed and its conversation storage migrations are part

The current AI foundation test uses agent fakes, so the repo test suite does not require live provider credentials just to verify the integration.

### Surreal-Backed Migrations

Katra now includes a first Laravel-compatible Surreal schema driver for migration work.

- Use `Schema::connection('surreal')` inside migrations when you want to target Surreal-backed application data.
- You can also set `DB_CONNECTION=surreal` and run Laravel migrations, migration status, and `migrate:fresh` directly against SurrealDB.
- The current slice is intentionally narrow: table creation, field creation, field removal, and table removal are supported for common Katra field types.
- This is still not full SQL-driver parity yet, but it is enough for Katra's current migration set and for Surreal-backed application schema work without relying on SQLite migration bookkeeping.

## Planning Docs

- [Katra v2 Overview](docs/v2-overview.md)
Expand Down
12 changes: 7 additions & 5 deletions app/Console/Commands/SurrealProbeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Console\Commands;

use App\Services\Surreal\SurrealCliClient;
use App\Services\Surreal\SurrealHttpClient;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
Expand All @@ -15,14 +16,15 @@
class SurrealProbeCommand extends Command
{
public function __construct(
private readonly SurrealCliClient $client,
private readonly SurrealCliClient $cliClient,
private readonly SurrealHttpClient $httpClient,
) {
parent::__construct();
}

public function handle(): int
{
if (! $this->client->isAvailable()) {
if (! $this->cliClient->isAvailable()) {
$this->components->error('Unable to find the `surreal` CLI. Install it first or set SURREAL_BINARY to the executable path.');

return self::FAILURE;
Expand Down Expand Up @@ -55,7 +57,7 @@ public function handle(): int

File::ensureDirectoryExists(dirname($storagePath));

$server = $this->client->startLocalServer(
$server = $this->cliClient->startLocalServer(
bindAddress: $bindAddress,
datastorePath: $storagePath,
username: $username,
Expand All @@ -64,11 +66,11 @@ public function handle(): int
);

try {
if (! $this->client->waitUntilReady($endpoint)) {
if (! $this->httpClient->waitUntilReady($endpoint)) {
throw new RuntimeException(sprintf('SurrealDB did not become ready on %s.', $endpoint));
}

$results = $this->client->runQuery(
$results = $this->httpClient->runQuery(
endpoint: $endpoint,
namespace: $namespace,
database: $database,
Expand Down
19 changes: 18 additions & 1 deletion app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,39 @@

namespace App\Providers;

use App\Services\Surreal\Migrations\SurrealMigrationRepository;
use App\Services\Surreal\Schema\SurrealSchemaConnection;
use App\Services\Surreal\SurrealConnection;
use App\Services\Surreal\SurrealDocumentStore;
use App\Services\Surreal\SurrealHttpClient;
use App\Services\Surreal\SurrealRuntimeManager;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(SurrealConnection::class, fn (): SurrealConnection => SurrealConnection::fromConfig(config('surreal')));
$this->app->singleton(SurrealHttpClient::class);
$this->app->singleton(SurrealRuntimeManager::class);
$this->app->singleton(SurrealDocumentStore::class);
}

public function boot(): void
{
//
$this->app->extend('migration.repository', function ($repository, $app): SurrealMigrationRepository {
$migrations = $app['config']['database.migrations'];
$table = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations;

return new SurrealMigrationRepository($app['db'], $table, $app->make(SurrealHttpClient::class));
});

$this->app->make(DatabaseManager::class)->extend('surreal', function (array $config, string $name): SurrealSchemaConnection {
return SurrealSchemaConnection::fromConfig(
array_merge(config('surreal'), $config),
$name,
);
});
}
}
264 changes: 264 additions & 0 deletions app/Services/Surreal/Migrations/SurrealMigrationRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
<?php

namespace App\Services\Surreal\Migrations;

use App\Services\Surreal\Schema\SurrealSchemaConnection;
use App\Services\Surreal\Schema\SurrealSchemaManager;
use App\Services\Surreal\SurrealHttpClient;
use Illuminate\Database\ConnectionResolverInterface as Resolver;
use Illuminate\Database\Migrations\DatabaseMigrationRepository;
use Illuminate\Support\Arr;
use JsonException;

class SurrealMigrationRepository extends DatabaseMigrationRepository
{
public function __construct(Resolver $resolver, string $table, private readonly SurrealHttpClient $client)
{
parent::__construct($resolver, $table);
}

public function getRan()
{
if (! $this->usesSurrealRepository()) {
return parent::getRan();
}

return array_map(
static fn (array $row): string => (string) $row['migration'],
$this->selectRows(sprintf(
'SELECT migration, batch FROM %s ORDER BY batch ASC, migration ASC;',
$this->normalizedTable(),
)),
);
}

public function getMigrations($steps)
{
if (! $this->usesSurrealRepository()) {
return parent::getMigrations($steps);
}

return $this->selectRows(sprintf(
'SELECT migration, batch FROM %s WHERE batch >= 1 ORDER BY batch DESC, migration DESC LIMIT %d;',
$this->normalizedTable(),
(int) $steps,
));
}

public function getMigrationsByBatch($batch)
{
if (! $this->usesSurrealRepository()) {
return parent::getMigrationsByBatch($batch);
}

return $this->selectRows(sprintf(
'SELECT migration, batch FROM %s WHERE batch = %d ORDER BY migration DESC;',
$this->normalizedTable(),
(int) $batch,
));
}

public function getLast()
{
if (! $this->usesSurrealRepository()) {
return parent::getLast();
}

$lastBatch = $this->getLastBatchNumber();

if ($lastBatch === 0) {
return [];
}

return $this->getMigrationsByBatch($lastBatch);
}

public function getMigrationBatches()
{
if (! $this->usesSurrealRepository()) {
return parent::getMigrationBatches();
}

$batches = [];

foreach ($this->selectRows(sprintf(
'SELECT migration, batch FROM %s ORDER BY batch ASC, migration ASC;',
$this->normalizedTable(),
)) as $row) {
$batches[(string) $row['migration']] = (int) $row['batch'];
}

return $batches;
}

public function log($file, $batch)
{
if (! $this->usesSurrealRepository()) {
parent::log($file, $batch);

return;
}

$this->schemaManager()->statement(sprintf(
'CREATE %s CONTENT %s;',
$this->normalizedTable(),
$this->jsonLiteral([
'migration' => (string) $file,
'batch' => (int) $batch,
]),
));
}

public function delete($migration)
{
if (! $this->usesSurrealRepository()) {
parent::delete($migration);

return;
}

$this->schemaManager()->statement(sprintf(
'DELETE %s WHERE migration = %s;',
$this->normalizedTable(),
$this->jsonLiteral((string) $migration->migration),
));
}

public function getLastBatchNumber()
{
if (! $this->usesSurrealRepository()) {
return parent::getLastBatchNumber();
}

return (int) $this->selectScalar(sprintf(
'SELECT VALUE batch FROM %s ORDER BY batch DESC LIMIT 1;',
$this->normalizedTable(),
), 0);
}

public function createRepository()
{
if (! $this->usesSurrealRepository()) {
parent::createRepository();

return;
}

$this->schemaManager()->statements([
sprintf('DEFINE TABLE %s SCHEMAFULL;', $this->normalizedTable()),
sprintf('DEFINE FIELD migration ON TABLE %s TYPE string;', $this->normalizedTable()),
sprintf('DEFINE FIELD batch ON TABLE %s TYPE int;', $this->normalizedTable()),
sprintf('DEFINE INDEX migration_unique ON TABLE %s COLUMNS migration UNIQUE;', $this->normalizedTable()),
]);
}

public function repositoryExists()
{
if (! $this->usesSurrealRepository()) {
return parent::repositoryExists();
}

return $this->schemaManager()->hasTable($this->normalizedTable());
}

public function deleteRepository()
{
if (! $this->usesSurrealRepository()) {
parent::deleteRepository();

return;
}

$this->schemaManager()->statement(sprintf('REMOVE TABLE %s;', $this->normalizedTable()));
}

private function usesSurrealRepository(): bool
{
$connectionName = $this->connection ?? $this->resolver->getDefaultConnection();

if ($connectionName === null) {
return false;
}

return $this->connectionDriver($connectionName) === 'surreal';
}

private function connectionDriver(string $connectionName): ?string
{
return $this->resolver->connection($connectionName)->getConfig('driver');
}

/**
* @return list<array<string, mixed>>
*/
private function selectRows(string $query): array
{
$result = Arr::get($this->query($query), '0', []);

if (! is_array($result)) {
return [];
}

return array_values(array_filter(
$result,
static fn (mixed $row): bool => is_array($row),
));
}

private function selectScalar(string $query, mixed $default = null): mixed
{
$result = Arr::get($this->query($query), '0', []);

if (! is_array($result) || $result === []) {
return $default;
}

return $result[0] ?? $default;
}

/**
* @return list<mixed>
*/
private function query(string $query): array
{
return $this->client->runQuery(
endpoint: (string) $this->surrealConfig('endpoint'),
namespace: (string) $this->surrealConfig('namespace'),
database: (string) $this->surrealConfig('database'),
username: (string) $this->surrealConfig('username'),
password: (string) $this->surrealConfig('password'),
query: $query,
);
}

private function schemaManager(): SurrealSchemaManager
{
/** @var SurrealSchemaConnection $connection */
$connection = $this->resolver->connection($this->connection ?? $this->resolver->getDefaultConnection());

return $connection->schemaManager();
}

private function surrealConfig(string $key): mixed
{
return $this->resolver->connection($this->connection ?? $this->resolver->getDefaultConnection())->getConfig($key);
}

private function normalizedTable(): string
{
if (! preg_match('/^[A-Za-z0-9_]+$/', $this->table)) {
throw new \RuntimeException(sprintf('The Surreal migration table identifier [%s] contains unsupported characters.', $this->table));
}

return $this->table;
}

private function jsonLiteral(mixed $value): string
{
try {
return json_encode($value, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new \RuntimeException('Unable to encode the Surreal migration payload.', previous: $exception);
}
}
}
Loading
Loading