Skip to content
Merged
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ php artisan migrate --database=surreal --path=database/migrations/0001_01_01_000
- Core cache operations like `get`, `put`, `add`, `many`, `forever`, `forget`, and `flush` are covered in the test suite against a real Surreal runtime.
- SQL-transaction-dependent limiter semantics are still treated as unsupported on Surreal-backed cache storage, so Katra keeps `CACHE_LIMITER=file` by default for Fortify throttling and other limiter middleware.

### Surreal-Backed Sessions

Katra now also exposes a dedicated `surreal` Laravel session driver backed by the framework's database session handler on the Surreal connection.

- Set `SESSION_DRIVER=surreal` to store Laravel sessions in SurrealDB.
- The driver defaults to the `surreal` connection, but you can still override the table and connection with `SESSION_CONNECTION` and `SESSION_TABLE` if needed.
- Make sure the sessions table exists on the Surreal connection before relying on this driver. Katra's current auth/session migration also creates the `users` and `password_reset_tokens` tables alongside `sessions`:

```bash
php artisan migrate --database=surreal --path=database/migrations/0001_01_01_000000_create_users_table.php
```

- Session read, write, update, and expiry behavior are covered in the test suite against a real Surreal runtime.
- This driver intentionally follows Laravel's normal database-session lifecycle, so expiry cleanup still relies on Laravel's standard session lottery / pruning behavior instead of Surreal-native TTL features.

## Planning Docs

- [Katra v2 Overview](docs/v2-overview.md)
Expand Down
14 changes: 14 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Services\Surreal\SurrealHttpClient;
use App\Services\Surreal\SurrealRuntimeManager;
use Illuminate\Database\DatabaseManager;
use Illuminate\Session\DatabaseSessionHandler;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
Expand Down Expand Up @@ -36,5 +37,18 @@ public function boot(): void
$name,
);
});

$this->app->make('session')->extend('surreal', function ($app): DatabaseSessionHandler {
$connection = config('session.connection') ?: 'surreal';
$table = config('session.table');
$lifetime = (int) config('session.lifetime');

return new DatabaseSessionHandler(
$app['db']->connection($connection),
$table,
$lifetime,
$app,
);
});
}
}
8 changes: 4 additions & 4 deletions config/session.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
| Supported: "file", "cookie", "database", "surreal",
| "memcached", "redis", "dynamodb", "array"
|
*/

Expand Down Expand Up @@ -67,7 +67,7 @@
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| When using the "database", "surreal", or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
Expand All @@ -80,7 +80,7 @@
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| When using the "database" or "surreal" session drivers, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
Expand Down
169 changes: 169 additions & 0 deletions tests/Feature/SurrealSessionDriverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

use App\Services\Surreal\SurrealCliClient;
use App\Services\Surreal\SurrealConnection;
use App\Services\Surreal\SurrealHttpClient;
use App\Services\Surreal\SurrealRuntimeManager;
use Illuminate\Session\DatabaseSessionHandler;
use Illuminate\Session\Store;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;

test('the surreal session driver supports the normal session lifecycle', function () {
$client = app(SurrealCliClient::class);

if (! $client->isAvailable()) {
$this->markTestSkipped('The `surreal` CLI is not available in this environment.');
}

$storagePath = storage_path('app/surrealdb/session-driver-test-'.Str::uuid());
$originalDefaultConnection = config('database.default');
$originalMigrationConnection = config('database.migrations.connection');
$originalSessionDriver = config('session.driver');
$originalSessionConnection = config('session.connection');
$originalSessionTable = config('session.table');
$originalSessionLifetime = config('session.lifetime');
$originalSessionCookie = config('session.cookie');

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

try {
$server = retryStartingSurrealSessionServer($client, $storagePath);

config()->set('database.default', 'sqlite');
config()->set('database.migrations.connection', null);
config()->set('surreal.host', '127.0.0.1');
config()->set('surreal.port', $server['port']);
config()->set('surreal.endpoint', $server['endpoint']);
config()->set('surreal.username', 'root');
config()->set('surreal.password', 'root');
config()->set('surreal.namespace', 'katra');
config()->set('surreal.database', 'session_driver_test');
config()->set('surreal.storage_engine', 'surrealkv');
config()->set('surreal.storage_path', $storagePath);
config()->set('surreal.runtime', 'local');
config()->set('surreal.autostart', false);
config()->set('session.driver', 'surreal');
config()->set('session.connection', null);
config()->set('session.table', 'sessions');
config()->set('session.lifetime', 1);
config()->set('session.cookie', 'surreal-session-test');

resetSurrealSessionState();

expect(Artisan::call('migrate', [
'--database' => 'surreal',
'--force' => true,
'--realpath' => true,
'--path' => database_path('migrations/0001_01_01_000000_create_users_table.php'),
]))->toBe(0);

$session = app('session')->driver('surreal');

expect($session->getHandler())->toBeInstanceOf(DatabaseSessionHandler::class);

$session->start();
$session->put('workspace', 'katra-local');
$session->save();

$sessionId = $session->getId();

$reloadedSession = refreshedSurrealSessionStore($sessionId);

expect($reloadedSession->get('workspace'))->toBe('katra-local');

$reloadedSession->put('workspace', 'katra-server');
$reloadedSession->save();

$updatedSession = refreshedSurrealSessionStore($sessionId);

expect($updatedSession->get('workspace'))->toBe('katra-server');

expect(DB::connection('surreal')->table('sessions')->where('id', $sessionId)->update([
'last_activity' => now()->subMinutes(5)->timestamp,
]))->toBe(1);

$expiredSession = refreshedSurrealSessionStore($sessionId);

expect($expiredSession->get('workspace'))->toBeNull();

$expiredSession->getHandler()->gc(config('session.lifetime') * 60);

expect(DB::connection('surreal')->table('sessions')->where('id', $sessionId)->count())->toBe(0);
} finally {
config()->set('database.default', $originalDefaultConnection);
config()->set('database.migrations.connection', $originalMigrationConnection);
config()->set('session.driver', $originalSessionDriver);
config()->set('session.connection', $originalSessionConnection);
config()->set('session.table', $originalSessionTable);
config()->set('session.lifetime', $originalSessionLifetime);
config()->set('session.cookie', $originalSessionCookie);

resetSurrealSessionState();

if (isset($server['process'])) {
$server['process']->stop(1);
}

File::deleteDirectory($storagePath);
}
});

function refreshedSurrealSessionStore(string $sessionId): Store
{
resetSurrealSessionState();

/** @var Store $session */
$session = app('session')->driver('surreal');
$session->setId($sessionId);
$session->start();

return $session;
}

function resetSurrealSessionState(): void
{
app()->forgetInstance(SurrealConnection::class);
app()->forgetInstance(SurrealRuntimeManager::class);
DB::purge('surreal');
app('session')->forgetDrivers();
app()->forgetInstance('session.store');
app()->forgetInstance('migration.repository');
app()->forgetInstance('migrator');
}

/**
* @return array{endpoint: string, port: int, process: Process}
*/
function retryStartingSurrealSessionServer(SurrealCliClient $client, string $storagePath, int $attempts = 3): array
{
$httpClient = app(SurrealHttpClient::class);

for ($attempt = 1; $attempt <= $attempts; $attempt++) {
$port = random_int(10240, 65535);
$endpoint = sprintf('ws://127.0.0.1:%d', $port);
$process = $client->startLocalServer(
bindAddress: sprintf('127.0.0.1:%d', $port),
datastorePath: $storagePath,
username: 'root',
password: 'root',
storageEngine: 'surrealkv',
);

if ($httpClient->waitUntilReady($endpoint)) {
return [
'endpoint' => $endpoint,
'port' => $port,
'process' => $process,
];
}

$process->stop(1);
}

throw new RuntimeException('Unable to start the SurrealDB session test runtime.');
}
Loading