diff --git a/README.md b/README.md index 738be1f..f0798ca 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,16 @@ php artisan migrate - If you are testing password reset locally and want to inspect the reset link without sending mail, use a local-safe mailer such as `MAIL_MAILER=log`. +### Desktop Persistence Model + +The packaged NativePHP app now keeps Laravel's durable application state on SurrealDB instead of splitting auth state across SQLite and Surreal. + +- In desktop runtime, Katra overrides NativePHP's internal SQLite defaults and points the app's main database connection, sessions, cache store, and queue connection at SurrealDB. +- Fortify users, password reset tokens, and sessions now live in the same local Surreal database as the rest of the desktop app state. +- The main cache store also lives in SurrealDB during desktop runtime, while `CACHE_LIMITER=file` stays in place for Fortify throttling and other limiter middleware that still expect file-safe semantics. +- The desktop-specific persistence defaults are declared in `config/nativephp.php` under `persistence`, so the Surreal-first runtime model is explicit instead of implicit. +- For local inspection, Surrealist can now give you one coherent view of the packaged app's durable data in the configured Surreal namespace and database. + ### Configure AI Providers The Laravel AI SDK is installed and its conversation storage migrations are part of the application now. diff --git a/app/Providers/NativeAppServiceProvider.php b/app/Providers/NativeAppServiceProvider.php index f04e214..0e8a226 100644 --- a/app/Providers/NativeAppServiceProvider.php +++ b/app/Providers/NativeAppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Support\Native\NativeRuntimePersistence; use Native\Desktop\Contracts\ProvidesPhpIni; use Native\Desktop\Facades\Window; @@ -9,6 +10,8 @@ class NativeAppServiceProvider implements ProvidesPhpIni { public function boot(): void { + app(NativeRuntimePersistence::class)->configure(); + Window::open() ->url(route('home')) ->title(config('app.name')) diff --git a/app/Support/Native/NativeRuntimePersistence.php b/app/Support/Native/NativeRuntimePersistence.php new file mode 100644 index 0000000..7412f6e --- /dev/null +++ b/app/Support/Native/NativeRuntimePersistence.php @@ -0,0 +1,55 @@ +isRunningInNativeRuntime()) { + return; + } + + $databaseConnection = (string) $this->config->get('nativephp.persistence.database_connection', 'surreal'); + $sessionDriver = (string) $this->config->get('nativephp.persistence.session_driver', 'surreal'); + $cacheStore = (string) $this->config->get('nativephp.persistence.cache_store', 'surreal'); + $queueConnection = (string) $this->config->get('nativephp.persistence.queue_connection', 'surreal'); + $limiterStore = (string) $this->config->get('nativephp.persistence.limiter_store', $this->config->get('cache.limiter', 'file')); + + $updates = [ + 'database.default' => $databaseConnection, + 'database.migrations.connection' => $databaseConnection, + 'session.driver' => $sessionDriver, + 'cache.default' => $cacheStore, + 'cache.limiter' => $limiterStore, + 'cache.stores.database.connection' => $databaseConnection, + 'cache.stores.database.lock_connection' => $databaseConnection, + 'cache.stores.surreal.connection' => $databaseConnection, + 'cache.stores.surreal.lock_connection' => $databaseConnection, + 'queue.default' => $queueConnection, + 'queue.failed.database' => $databaseConnection, + 'queue.batching.database' => $databaseConnection, + 'queue.connections.database.connection' => $databaseConnection, + 'queue.connections.surreal.connection' => $databaseConnection, + ]; + + if (in_array($this->config->get('session.connection'), [null, '', 'nativephp', 'sqlite'], true)) { + $updates['session.connection'] = $databaseConnection; + } + + if ($this->config->get('ai.caching.embeddings.store') === 'database') { + $updates['ai.caching.embeddings.store'] = $cacheStore; + } + + $this->config->set($updates); + } + + private function isRunningInNativeRuntime(): bool + { + return (bool) $this->config->get('nativephp-internal.running', false); + } +} diff --git a/config/nativephp.php b/config/nativephp.php index 8dd39ce..b08c739 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -161,6 +161,18 @@ ], ], + /** + * The packaged desktop runtime keeps Laravel's durable application state + * on SurrealDB instead of NativePHP's internal SQLite connection. + */ + 'persistence' => [ + 'database_connection' => env('NATIVEPHP_DATABASE_CONNECTION', 'surreal'), + 'session_driver' => env('NATIVEPHP_SESSION_DRIVER', 'surreal'), + 'cache_store' => env('NATIVEPHP_CACHE_STORE', 'surreal'), + 'queue_connection' => env('NATIVEPHP_QUEUE_CONNECTION', 'surreal'), + 'limiter_store' => env('NATIVEPHP_CACHE_LIMITER', env('CACHE_LIMITER', 'file')), + ], + /** * Define your own scripts to run before and after the build process. */ diff --git a/docs/development/nativephp.md b/docs/development/nativephp.md index 3fee6ab..69531ef 100644 --- a/docs/development/nativephp.md +++ b/docs/development/nativephp.md @@ -108,6 +108,15 @@ The initial shell is intentionally small and focused: - the window uses Katra-focused defaults for title and size - the root page acts as a lightweight desktop landing screen for smoke testing +## Desktop Persistence Model + +Katra now treats SurrealDB as the durable local application store inside the NativePHP runtime. + +- NativePHP still brings its own internal SQLite wiring, but Katra overrides the packaged runtime defaults in `config/nativephp.php` so Laravel's main database connection, session driver, cache store, and queue connection all point at SurrealDB. +- Desktop auth data now lives in SurrealDB too, which means `users`, `password_reset_tokens`, `sessions`, and the main cache tables can be inspected together through Surreal tooling. +- The limiter store remains file-backed by default because Fortify throttling and similar middleware still behave best without SQL-style transactional expectations. +- If you need to customize the packaged app persistence targets later, start with the `nativephp.persistence` block in `config/nativephp.php` before reaching for NativePHP's internal SQLite connection. + ## Troubleshooting - If the desktop shell does not reflect frontend changes, restart `composer native:dev` or ensure Vite is running. diff --git a/tests/Feature/NativeRuntimePersistenceTest.php b/tests/Feature/NativeRuntimePersistenceTest.php new file mode 100644 index 0000000..0e4c8a8 --- /dev/null +++ b/tests/Feature/NativeRuntimePersistenceTest.php @@ -0,0 +1,219 @@ +isAvailable()) { + $this->markTestSkipped('The `surreal` CLI is not available in this environment.'); + } + + $storagePath = storage_path('app/surrealdb/native-runtime-test-'.Str::uuid()); + $originalConfig = snapshotNativeRuntimeConfig(); + + File::deleteDirectory($storagePath); + File::ensureDirectoryExists(dirname($storagePath)); + + try { + $server = retryStartingNativeRuntimeSurrealServer($client, $storagePath); + + 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', 'native_runtime_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('nativephp-internal.running', true); + config()->set('database.default', 'nativephp'); + config()->set('database.migrations.connection', 'nativephp'); + config()->set('session.driver', 'file'); + config()->set('session.connection', null); + config()->set('session.table', 'sessions'); + config()->set('session.cookie', 'native-runtime-surreal'); + config()->set('cache.default', 'database'); + config()->set('cache.limiter', 'file'); + config()->set('cache.stores.database.connection', 'nativephp'); + config()->set('cache.stores.database.lock_connection', 'nativephp'); + config()->set('cache.stores.surreal.connection', 'nativephp'); + config()->set('cache.stores.surreal.lock_connection', 'nativephp'); + config()->set('queue.default', 'database'); + config()->set('queue.failed.database', 'nativephp'); + config()->set('queue.batching.database', 'nativephp'); + config()->set('queue.connections.database.connection', 'nativephp'); + config()->set('queue.connections.surreal.connection', 'nativephp'); + config()->set('ai.caching.embeddings.store', 'database'); + + app(NativeRuntimePersistence::class)->configure(); + + resetNativeRuntimePersistenceState(); + + expect(config('database.default'))->toBe('surreal') + ->and(config('database.migrations.connection'))->toBe('surreal') + ->and(config('session.driver'))->toBe('surreal') + ->and(config('session.connection'))->toBe('surreal') + ->and(config('cache.default'))->toBe('surreal') + ->and(config('queue.default'))->toBe('surreal') + ->and(config('queue.failed.database'))->toBe('surreal') + ->and(config('queue.batching.database'))->toBe('surreal') + ->and(config('ai.caching.embeddings.store'))->toBe('surreal') + ->and(cache()->driver(config('cache.limiter'))->getStore())->toBeInstanceOf(FileStore::class); + + expect(Artisan::call('migrate', [ + '--database' => 'surreal', + '--force' => true, + '--realpath' => true, + '--path' => database_path('migrations/0001_01_01_000000_create_users_table.php'), + ]))->toBe(0); + + expect(Artisan::call('migrate', [ + '--database' => 'surreal', + '--force' => true, + '--realpath' => true, + '--path' => database_path('migrations/2026_03_24_064850_add_profile_name_columns_to_users_table.php'), + ]))->toBe(0); + + expect(Artisan::call('migrate', [ + '--database' => 'surreal', + '--force' => true, + '--realpath' => true, + '--path' => database_path('migrations/0001_01_01_000001_create_cache_table.php'), + ]))->toBe(0); + + expect(Cache::store(config('cache.default'))->put('desktop:last-workspace', 'katra-local', 60))->toBeTrue() + ->and(Cache::store(config('cache.default'))->get('desktop:last-workspace'))->toBe('katra-local'); + + $this->post(route('register'), [ + 'first_name' => 'Native', + 'last_name' => 'Tester', + 'email' => 'native@katra.test', + 'password' => 'password', + 'password_confirmation' => 'password', + ])->assertRedirect(route('home')); + + $this->assertAuthenticated(); + + $storedUser = DB::connection('surreal')->table('users') + ->where('email', 'native@katra.test') + ->first(); + + expect($storedUser)->not->toBeNull() + ->and(data_get($storedUser, 'first_name'))->toBe('Native') + ->and(data_get($storedUser, 'last_name'))->toBe('Tester') + ->and(DB::connection('surreal')->table('sessions')->count())->toBeGreaterThan(0); + } finally { + restoreNativeRuntimeConfig($originalConfig); + resetNativeRuntimePersistenceState(); + + if (isset($server['process'])) { + $server['process']->stop(1); + } + + File::deleteDirectory($storagePath); + } +}); + +function snapshotNativeRuntimeConfig(): array +{ + $configKeys = [ + 'nativephp-internal.running', + 'database.default', + 'database.migrations.connection', + 'session.driver', + 'session.connection', + 'session.table', + 'session.cookie', + 'cache.default', + 'cache.limiter', + 'cache.stores.database.connection', + 'cache.stores.database.lock_connection', + 'cache.stores.surreal.connection', + 'cache.stores.surreal.lock_connection', + 'queue.default', + 'queue.failed.database', + 'queue.batching.database', + 'queue.connections.database.connection', + 'queue.connections.surreal.connection', + 'ai.caching.embeddings.store', + ]; + + $snapshot = []; + + foreach ($configKeys as $key) { + $snapshot[$key] = config($key); + } + + return $snapshot; +} + +function restoreNativeRuntimeConfig(array $snapshot): void +{ + foreach ($snapshot as $key => $value) { + config()->set($key, $value); + } +} + +function resetNativeRuntimePersistenceState(): void +{ + app()->forgetInstance(SurrealConnection::class); + app()->forgetInstance(SurrealRuntimeManager::class); + DB::purge('surreal'); + DB::purge('nativephp'); + Cache::forgetDriver('database'); + Cache::forgetDriver('surreal'); + app()->forgetInstance('cache'); + app()->forgetInstance('cache.store'); + app('session')->forgetDrivers(); + app()->forgetInstance('session.store'); + app()->forgetInstance('migration.repository'); + app()->forgetInstance('migrator'); +} + +/** + * @return array{endpoint: string, port: int, process: Process} + */ +function retryStartingNativeRuntimeSurrealServer(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 native runtime test server.'); +}