v0.2.0 — durability, safety, and API-contract hardening
Minor Changes
-
69f7161: Durability, safety, and API-contract hardening across the kernel, adapters, lenses, and CLI (the pre-announcement audit wave).
On-disk format: new databases now begin with an 8-byte
LRDBmagic/version header, and each record header carries a checksum of its own length field. Files written by earlier releases (headerless) keep opening through a legacy read path, and keep their legacy record framing on later appends. The header is what letsopen()refuse a file that is not a LibreDB database with a clear error instead of destroying it; the record-header checksum is what lets recovery refuse a damaged length field instead of mistaking it for a torn tail.DOWNGRADE WARNING: a file written by this release must never be opened by 0.1.3 or older — the old recovery cannot parse the header, classifies the whole file as a torn tail, and silently truncates it to zero bytes. Back up before any downgrade. Three smaller legacy-behavior changes: a headerless file whose only record is torn/incomplete now refuses to open as
NOT_A_DATABASE(0.1.3 recovered it to an empty database; refusing is the safe reading, since such a file is indistinguishable from a foreign one); any file shorter than the 8-byte header is likewise refused untouched (a crash inside the first bytes of a brand-new database's first-ever commit therefore needs a manual delete — nothing in it was acknowledged); and a legacy length-field corruption still reads as a torn tail (the legacy format has no header checksum — the v1 format exists to close exactly that gap).Kernel:
open({ path })on a non-LibreDB file throwsNOT_A_DATABASEand leaves the file byte-for-byte untouched (previously the file was silently truncated to zero).- Recovery classifies failures: a torn tail truncates (reported through the new
onRecoveryopen option), while mid-log corruption throwsCORRUPT_WALand truncates nothing. Record payloads are structurally validated during replay. - A failed append/fsync latches the database: every later
transact()throwsFAILEDuntil reopen, so an IO error can never lead recovery to silently drop later acknowledged commits. transact()rejects async callbacks (ASYNC_TRANSACTION): writes after anawaitcould never reach the log.- Keys and values are copied at the transaction boundary in both directions — caller buffer reuse and mutation of returned buffers can no longer corrupt the store.
getRangesnapshots at first iteration, so delete-while-scanning visits every entry exactly once.close()inside a transaction throwsCLOSE_IN_TRANSACTIONinstead of surfacing a raw file error.open()takes an exclusive per-file lock (<path>.lock, pid/host/nonce): a second writer throwsLOCKEDinstead of silently diverging; locks from verifiably dead holders are reclaimed automatically.FileSystemgains an optionallock()seam method.- All kernel failures are now
LibreDbErrorinstances carrying a stablecode(exported, with theErrorCodeandRecoveryInfotypes).
Adapters:
- node-fs: creating a database fsyncs the parent directory (a fresh database can no longer vanish wholesale on power loss); a directory-fsync failure that is not a platform limitation (e.g. EIO) now surfaces as an error instead of being silently ignored; recovery truncation is fsync'd; reads are positional on the WAL's own file descriptor instead of re-reading the whole file per call.
- OPFS: reads loop until filled, so a legal short read can no longer masquerade as a torn tail; recovery treats an incomplete read as an IO fault (
INCOMPLETE_READ), never as license to truncate.
Lenses:
- Collection/table names may not be empty or contain
:(both broke namespace isolation); ids keep full freedom. - Strings that are not well-formed UTF-16 (lone surrogates) are rejected wherever they would become keys, ids, names, or kv values — distinct strings can no longer silently collide on one key.
- Relational
numbercolumns rejectNaNand the infinities (JSON would store them asnull). doc()refuses a name cataloged as a relational table (it would bypass schema validation);table()refuses a document collection's name.find()/where()reject a predicate field explicitly set toundefined, which previously matched documents missing the field.
CLI:
- Write commands rely on the kernel's exclusive lock;
--forceremoves a lock only when its holder is not verifiably alive, and never deletes a file that is not a libredb lock. Automatic reclaim is stricter still: only a lock whose holder is VERIFIABLY dead (same host, pid gone) is reclaimed without--force— anonymous locks (empty, or the sentinel-only 0.1.x format) carry no liveness information and now require--force. get/scanescape control characters (including tab and newline) by default so untrusted values cannot inject terminal escape sequences — scripts that consumed values verbatim should pass--raw.
New exports:
LibreDbError,ErrorCode,RecoveryInfo,nodeFileSystem, andreadonlyFileSystem(open a database for inspection with no lock and no writes — the supported way to read a file a live writer holds).Docker image now runs as a non-root user (distroless
:nonroot, uid 65532): bind-mounted directories must be writable by that uid, or pass--user "$(id -u):$(id -g)".