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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions app/Providers/NativeAppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

namespace App\Providers;

use App\Support\Native\NativeRuntimePersistence;
use Native\Desktop\Contracts\ProvidesPhpIni;
use Native\Desktop\Facades\Window;

class NativeAppServiceProvider implements ProvidesPhpIni
{
public function boot(): void
{
app(NativeRuntimePersistence::class)->configure();

Window::open()
->url(route('home'))
->title(config('app.name'))
Expand Down
55 changes: 55 additions & 0 deletions app/Support/Native/NativeRuntimePersistence.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App\Support\Native;

use Illuminate\Contracts\Config\Repository;

class NativeRuntimePersistence
{
public function __construct(private Repository $config) {}

public function configure(): void
{
if (! $this->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);
}
}
12 changes: 12 additions & 0 deletions config/nativephp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
9 changes: 9 additions & 0 deletions docs/development/nativephp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
219 changes: 219 additions & 0 deletions tests/Feature/NativeRuntimePersistenceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php

use App\Services\Surreal\SurrealCliClient;
use App\Services\Surreal\SurrealConnection;
use App\Services\Surreal\SurrealHttpClient;
use App\Services\Surreal\SurrealRuntimeManager;
use App\Support\Native\NativeRuntimePersistence;
use Illuminate\Cache\FileStore;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;

test('the native runtime keeps auth, sessions, and cache state on surreal', function () {
$client = app(SurrealCliClient::class);

if (! $client->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.');
}
Loading