diff --git a/README.md b/README.md index 19431e8..2e416b4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 93dcefa..c3a6f82 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 @@ -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, + ); + }); } } diff --git a/config/session.php b/config/session.php index f574482..5008267 100644 --- a/config/session.php +++ b/config/session.php @@ -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" | */ @@ -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. | @@ -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. | diff --git a/tests/Feature/SurrealSessionDriverTest.php b/tests/Feature/SurrealSessionDriverTest.php new file mode 100644 index 0000000..64d9504 --- /dev/null +++ b/tests/Feature/SurrealSessionDriverTest.php @@ -0,0 +1,169 @@ +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.'); +}