Opening a LibreDB file takes an exclusive lock
There is no host, no port and no server, and the lock sidecar means command-line tooling cannot hold the same file while a connection is open.
Opening a LibreDB file is not a handshake. There is no socket to dial, no
credential to present and no server process to be running first. The
@libredb/libredb package opens the file in this process, synchronously, and
takes an exclusive lock on it before the call returns.
A connection that is a path and nothing else
The provider reuses the database field of the connection record for the file
path. Nothing else on the record is read:
const connection = {
id: 'libredb-1',
name: 'App Data',
type: 'libredb',
database: '/data/app.libredb',
createdAt: new Date(),
};
No host, no port, no user, no password, no connection string. The
declared capabilities say so rather than leaving it to be discovered:
supportsConnectionString is false and defaultPort is null. The evidence
is getCapabilities() in src/lib/db/providers/embedded/libredb.ts.
The consequence is a deployment fact before it is a feature. The .libredb file
must sit on the filesystem of the machine running Studio. There is no remote
connection model, because the database has no server and no wire protocol to
carry one; embedded in-process is the only supported mode. Run Studio as a
container beside your data and the file has to be on a volume that container can
see. That is the whole networking story, and it is why this engine appears on
the engine grid as a file rather than a host and port.
Every open takes a lock, and the second one is refused
lib.open({ path }) writes a sidecar next to the database: <path>.lock. Its
contents, measured, are four lines — the literal libredb-lock, the holder’s
pid, the holder’s hostname, and a nonce. While that sidecar is held, the file
admits exactly one handle. A second open() throws LibreDbError with code
LOCKED, and the provider turns that into a ConnectionError saying the file is
already open by another process and naming the fix: close the other writer.
State it plainly, because it is the constraint the rest of your tooling has to be arranged around: the open call takes an exclusive lock sidecar, so a LibreDB file admits exactly one handle and a second open is refused. Not queued, not degraded to read-only, not resolved last-writer-wins. Refused.
This is the only engine in the product that declares singleWriterFile: true.
The other file-backed engine, SQLite, takes its locks per transaction rather than
at open, so two connections to one file coexist and contend statement by
statement. LibreDB moves that contention forward to connect(). You find out at
connection time, once, instead of at an arbitrary write.
The lock is released on disconnect(), and locks left by verifiably dead holders
are reclaimed automatically, so a killed process does not permanently strand a
file.
Inside Studio, three callers used to trip over this lock on every request,
because they build a provider outside the writable cache: POST /api/db/test-connection, the agent’s grounding read
(acquireExecutionProfileProvider(connection, "agent-operations")), and POST /api/db/schema-snapshot. The symptoms were specific. The connection dialog tests
before it saves, so the built-in sample connection could not be edited at all —
the edit came back as a connection error, as if the sample were broken. Every
plan-mode run on a LibreDB connection was silently ungrounded from the moment
anyone clicked the connection in the sidebar. The Schema Diff tab’s Snapshot
button answered HTTP 503 for a schema the sidebar was listing on screen.
All three now call findOpenSingleWriterProvider first and reuse the handle that
already holds the file. The lookup is keyed by the resolved file path, not the
connection id, because the second opener is usually a different connection record
pointing at the same file. A borrowed handle is never disconnected by its
borrower and never cached under the profiled key. Two bounds keep the agent’s
isolation intact: only agent-operations borrows, and no borrow happens for a
connection that configures an agentUser, because a reuse cannot substitute one
principal for another. Agent AUTO mode is unavailable on this engine regardless —
it requires a provider-level queryReadOnly, which exists only on PostgreSQL,
SQLite and DuckDB, so an auto run here ends engine-unsupported. Plan mode opens
and drafts a command for a person to run.
Working alongside command-line tooling
The reuse above is internal. It does not extend to anything outside the server
process. While a Studio connection holds the file, the libredb CLI and any
other external writer get LOCKED.
The timing detail that surprises people: Studio caches a connected provider per connection id and evicts it after 30 minutes idle. The lock is held for that whole window, not just while a query is running. Browse a LibreDB connection in the sidebar, walk away, and the file stays locked for half an hour.
There are two ways to work with that:
- Disconnect the Studio connection first (or wait out the eviction) when the external tool needs to write.
- Read without taking the lock using the package’s
readonlyFileSystem, which opens no lock and performs no writes. Read-only tooling can run concurrently with a live Studio connection.
Files this refuses to open, and how it refuses them
Since @libredb/libredb 0.2.0 the file boundary is hardened, and the refusals are
precise about what they leave behind. New databases begin with an 8-byte LRDB
magic and version header; headerless files written by 0.1.x still open through a
legacy read path, so upgrading Studio does not require migrating anything.
| Kernel code | When | What Studio reports |
|---|---|---|
LOCKED |
Another writer holds the exclusive lock | Already open by another process; close the other writer |
NOT_A_DATABASE |
The file is not a LibreDB database | Not a LibreDB database; the file is left untouched |
UNSUPPORTED_VERSION |
Written by a newer format version | Upgrade @libredb/libredb; the file is left untouched |
CORRUPT_WAL |
Mid-log corruption | The write-ahead log is corrupt mid-file, plus the kernel detail |
The important word in rows two and three is untouched. Point a connection at a
JPEG or at last week’s tarball and the kernel refuses it byte-for-byte intact.
Version 0.1.x silently truncated such a file to zero bytes; 0.2.x does not. The
same restraint governs CORRUPT_WAL: the kernel declines to destroy data it
cannot parse rather than recovering aggressively over it.
The provider branches on error.code via instanceof lib.LibreDbError, never on
message text. Messages are free to change between releases; codes are the stable
part, and a mapping built on message matching breaks silently on a patch bump.
One direction is not protected, and it is worth writing down: a file written by 0.2.x must never be opened by 0.1.3 or older. The old recovery path cannot parse the header, classifies the whole file as a torn tail, and truncates it to zero bytes. Back up before any downgrade.
Why a missing path is an error rather than a scratch database
The kernel’s open() creates an ephemeral in-memory store when given no path,
one that is discarded when the process closes. The provider refuses to use it. A connection with no database value
throws DatabaseConfigError at validate() time, before anything is opened.
That is the second half of the limit this engine publishes: a missing path is refused outright rather than silently opening an ephemeral store. The reasoning is that an in-memory database offers no durable value to a GUI tool. You would write keys into it, browse them, close the tab, and lose them with no error at any point. A connection that succeeds and then discards your work is worse than one that refuses to be created.
A path that points at a file which does not exist yet is a different case, and it is allowed: the package creates an empty ordered key-value store there. Naming a file is a decision; omitting the field is not.
If you want to see the shape of this before deciding on a path, a standalone
Studio instance seeds a connection named “Sample (LibreDB)” on first startup,
covering all three lenses — a relational table, a document collection and raw
key-value keys. LIBREDB_EMBEDDED_SAMPLE=false turns it off and
LIBREDB_EMBEDDED_SAMPLE_PATH moves the file. The
getting started guide covers bringing the container up next to
the volume that holds it.