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
@@ -55,6 +55,8 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
_busyTimeoutMs = options.BusyTimeoutMs;
_connectionString = new SqliteConnectionStringBuilder { DataSource = options.Path }.ToString();
EnsureDirectoryExists(options.Path);
_master = new SqliteConnection(_connectionString);
_master.Open();
ExecuteRaw(_master, "PRAGMA journal_mode=WAL; " + PerConnectionPragmas());
@@ -219,6 +221,62 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
}
}
/// <summary>
/// Creates the directory holding the database file when it does not already exist.
/// </summary>
/// <remarks>
/// <para>
/// SQLite creates the database FILE on demand but never its parent DIRECTORY, and this
/// constructor opens the file eagerly — so a configured path under a directory that does
/// not exist yet was a hard startup failure ("SQLite Error 14: unable to open database
/// file") rather than a first-run that simply worked. Relative defaults such as
/// <c>./data/app.db</c> hit it routinely; a containerized consumer escapes only when a
/// volume mount happens to create the directory first.
/// </para>
/// <para>
/// Creating it here is deliberate rather than merely convenient. The objection to a
/// library that creates directories is that it can mask a mis-typed path by silently
/// starting an empty database — but SQLite ALREADY does exactly that for a mis-typed
/// file NAME. Refusing to create the directory therefore protects nothing; it only makes
/// the two halves of the same path behave inconsistently, and turns the directory half
/// into an opaque error.
/// </para>
/// <para>
/// A creation failure (permissions, read-only mount) is reported here rather than left
/// to surface as SQLite error 14, because the error the caller actually needs names the
/// directory and the reason.
/// </para>
/// </remarks>
private static void EnsureDirectoryExists(string path)
{
string? directory;
try
{
directory = Path.GetDirectoryName(Path.GetFullPath(path));
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
// An unresolvable path is not this method's error to report: opening it produces a
// message naming the data source, which is the better diagnostic.
return;
}
if (string.IsNullOrEmpty(directory) || Directory.Exists(directory))
return;
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
throw new InvalidOperationException(
$"LocalDb could not create the directory '{directory}' for database file '{path}'. " +
"SQLite creates the database file but not its parent directory; either create the " +
"directory out of band or point LocalDb:Path somewhere writable.", ex);
}
}
private static async Task<IReadOnlyList<T>> ReadAllAsync<T>(SqliteCommand cmd, Func<SqliteDataReader, T> map, CancellationToken ct)
{
var results = new List<T>();