fix(localdb): create the database file's parent directory (0.1.1)

SQLite creates the database FILE on demand but never its parent DIRECTORY, and
SqliteLocalDb opens the file eagerly in its constructor — so a configured path
under a not-yet-existing directory was a hard startup failure ("SQLite Error
14: unable to open database file") rather than a first run that just worked.
Relative defaults like ./data/app.db hit it routinely; a containerized consumer
escapes only when a volume mount happens to create the directory first.

ScadaBridge found this during LocalDb Phase 2 and worked around it app-side
(SiteLocalDbDirectory.Ensure). Every other consumer still had the gap, so the
guarantee belongs here.

The standing objection to a library that creates directories is that it can
mask a mis-typed path by silently starting an empty database. That does not
apply: SQLite ALREADY does exactly that for a mis-typed file NAME. Refusing to
create the directory protects nothing — it only makes the two halves of one
path behave inconsistently and turns the directory half into an opaque error.

A creation failure (permissions, read-only mount) now throws an
InvalidOperationException naming the directory and the cause, instead of
surfacing as SQLite error 14. That is a change of exception type, but only on a
path that already failed hard.

Both behaviours are pinned and verified to fail with the call removed.
147 tests pass, 0 warnings. Published to the Gitea feed at 0.1.1.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-20 04:51:18 -04:00
parent baea6cc2e0
commit 12e6f0d2c5
3 changed files with 113 additions and 1 deletions
@@ -16,6 +16,60 @@ public sealed class SqliteLocalDbTests : IDisposable
private SqliteLocalDb Create() => new(new LocalDbOptions { Path = _path });
[Fact]
public void Open_CreatesTheParentDirectory_WhenItDoesNotExist()
{
// SQLite creates the database FILE on demand but never its parent DIRECTORY, and the
// constructor opens the file eagerly - so before this, a path under a missing
// directory was a hard startup failure ("SQLite Error 14"), not a first run. Relative
// defaults like ./data/app.db hit it routinely.
var root = Path.Combine(Path.GetTempPath(), "localdb-dir-" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(root, "nested", "deeper", "app.db");
try
{
Assert.False(Directory.Exists(root));
using (var db = new SqliteLocalDb(new LocalDbOptions { Path = path }))
{
Assert.True(File.Exists(path));
}
}
finally
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void Open_ReportsTheDirectory_WhenItCannotBeCreated()
{
// The failure that survives: an unwritable location. It must name the directory and
// the reason rather than surfacing as SQLite error 14, which was the whole complaint
// about the previous behaviour.
var blocker = Path.Combine(Path.GetTempPath(), "localdb-blocker-" + Guid.NewGuid().ToString("N"));
try
{
// A FILE where the directory needs to be. Creating a directory under it fails on
// every platform, without needing permission manipulation that varies by OS.
File.WriteAllText(blocker, string.Empty);
var ex = Assert.Throws<InvalidOperationException>(
() => new SqliteLocalDb(new LocalDbOptions { Path = Path.Combine(blocker, "sub", "app.db") }));
Assert.Contains("could not create the directory", ex.Message);
Assert.NotNull(ex.InnerException);
}
finally
{
if (File.Exists(blocker))
File.Delete(blocker);
}
}
[Fact]
public void Open_CreatesDbFile_WalMode()
{