fix(updater): never sweep the live sqlite database as a stale file

The cleanup step deletes any file the shipped manifest does not list outside
the protected prefixes. A SQLite database kept under database/ (a path the
installer accepts) is user state that can never appear in a manifest, so an
update destroyed it. The keep-list now always includes the resolved database
file of every in-installation SQLite connection, plus its -wal/-shm/-journal
side files. Found by the 3.0.0-alpha.2 -> alpha.3 upgrade rehearsal.
This commit is contained in:
Darko Gjorgjijoski
2026-08-22 13:21:40 +02:00
parent 9a83b7364f
commit 0de0126d94
2 changed files with 95 additions and 1 deletions
+43 -1
View File
@@ -152,6 +152,48 @@ class Updater
* are never touched. Directories left empty by the sweep are removed in a
* second walk, so a pruned subtree disappears entirely.
*/
/**
* The paths the cleanup sweep must never touch: the configured protected
* prefixes, plus the live database files of every SQLite connection whose
* file sits inside the installation. The database is user state, not
* shipped code, so it can never appear in a release manifest; without this
* a database kept outside `storage` (for example `database/database.sqlite`,
* which the installer accepts) would be swept as stale on every update.
* SQLite side files (`-wal`, `-shm`, `-journal`) ride along with their
* database.
*/
public static function protectedPaths(): array
{
$keep = config('invoiceshelf.update_protected_paths', []);
foreach (config('database.connections', []) as $connection) {
if (($connection['driver'] ?? null) !== 'sqlite') {
continue;
}
$database = $connection['database'] ?? null;
if (! is_string($database) || $database === '' || $database === ':memory:') {
continue;
}
$root = rtrim(base_path(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$absolute = str_starts_with($database, DIRECTORY_SEPARATOR) ? $database : $root.$database;
if (! str_starts_with($absolute, $root)) {
continue;
}
$relative = substr($absolute, strlen($root));
foreach (['', '-wal', '-shm', '-journal'] as $suffix) {
$keep[] = $relative.$suffix;
}
}
return array_values(array_unique($keep));
}
public static function cleanStaleFiles(): array
{
$manifestPath = base_path('manifest.json');
@@ -167,7 +209,7 @@ class Updater
}
$shipped = array_flip($manifest);
$keep = config('invoiceshelf.update_protected_paths', []);
$keep = static::protectedPaths();
$cleaned = 0;
foreach (static::walkInstallation() as $entry) {
@@ -0,0 +1,52 @@
<?php
use App\Platform\Operations\Update\Updater;
/*
* The cleanup sweep's keep-list. The static config prefixes are the baseline;
* on top of them the live SQLite database file (and its side files) must be
* protected wherever the installer put it, because user state can never appear
* in a release manifest and would otherwise be swept as stale.
*/
test('the configured protected prefixes are always kept', function (): void {
$paths = Updater::protectedPaths();
foreach (config('invoiceshelf.update_protected_paths') as $prefix) {
expect($paths)->toContain($prefix);
}
});
test('a sqlite database inside the installation is protected with its side files', function (): void {
config()->set('database.connections.sqlite.database', base_path('database/database.sqlite'));
$paths = Updater::protectedPaths();
expect($paths)
->toContain('database/database.sqlite')
->toContain('database/database.sqlite-wal')
->toContain('database/database.sqlite-shm')
->toContain('database/database.sqlite-journal');
});
test('a relative sqlite path resolves against the installation root', function (): void {
config()->set('database.connections.sqlite.database', 'database/relative.sqlite');
expect(Updater::protectedPaths())->toContain('database/relative.sqlite');
});
test('an in-memory database adds nothing to the keep list', function (): void {
config()->set('database.connections.sqlite.database', ':memory:');
$baseline = count(config('invoiceshelf.update_protected_paths'));
expect(Updater::protectedPaths())->toHaveCount($baseline);
});
test('a sqlite database outside the installation adds nothing to the keep list', function (): void {
config()->set('database.connections.sqlite.database', '/var/lib/invoiceshelf/external.sqlite');
$baseline = count(config('invoiceshelf.update_protected_paths'));
expect(Updater::protectedPaths())->toHaveCount($baseline);
});