@libredb/libredb 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # LibreDB
2
2
 
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="docs/img/01-hero-dark.png">
5
+ <img alt="LibreDB - Multi-model without the magic. One core, three lenses, every line tested." src="docs/img/01-hero-light.png" width="100%">
6
+ </picture>
7
+
3
8
  **Multi-model without the magic. One core, three lenses, every line tested.**
4
9
 
5
10
  [![npm version](https://img.shields.io/npm/v/@libredb/libredb.svg)](https://www.npmjs.com/package/@libredb/libredb)
@@ -79,8 +84,86 @@ Each lens has its own guide: [key-value](./docs/guides/key-value.md) ·
79
84
  [document](./docs/guides/document.md) · [relational](./docs/guides/relational.md) ·
80
85
  [catalog](./docs/guides/catalog.md).
81
86
 
87
+ ## Install elsewhere: JSR, CDN, and the browser
88
+
89
+ LibreDB is the same ESM-only package everywhere; only how you reach it changes.
90
+
91
+ **JSR** — published to [jsr.io](https://jsr.io/@libredb/libredb) alongside npm:
92
+
93
+ ```sh
94
+ bunx jsr add @libredb/libredb
95
+ # or: npx jsr add @libredb/libredb / deno add jsr:@libredb/libredb
96
+ ```
97
+
98
+ **CDN** — every release is served from the npm registry by the usual CDNs. Pin a version:
99
+
100
+ ```ts
101
+ import { open, kv } from "https://esm.sh/@libredb/libredb@0.1.0";
102
+ ```
103
+
104
+ **Browser** — a dedicated entry that imports nothing from `node:`, so it bundles for the browser
105
+ cleanly. Its `open` carries no default filesystem: an in-memory database works anywhere, and a
106
+ path-backed open takes a filesystem you inject (e.g. the OPFS adapter shown below).
107
+
108
+ ```ts
109
+ import { open, kv } from "@libredb/libredb/browser";
110
+
111
+ const db = open(); // in-memory
112
+ kv(db).set("greeting", "hello");
113
+ ```
114
+
115
+ For durable storage in the browser, run inside a Web Worker and back the database with an OPFS sync
116
+ access handle (the kernel stays synchronous — no async core):
117
+
118
+ ```ts
119
+ import { open, opfsFileSystem } from "@libredb/libredb/browser";
120
+
121
+ const root = await navigator.storage.getDirectory();
122
+ const file = await root.getFileHandle("app.libredb", { create: true });
123
+ const handle = await file.createSyncAccessHandle();
124
+ const db = open({ path: "app.libredb", fs: opfsFileSystem(handle) });
125
+ ```
126
+
127
+ A browser-targeting bundler resolves the browser build automatically via the package's `browser`
128
+ export condition, so importing the main `@libredb/libredb` entry works too.
129
+
130
+ ## Command-line tool
131
+
132
+ The package ships a `libredb` bin for inspecting and editing `.libredb` files — no code required:
133
+
134
+ ```sh
135
+ npx libredb inspect data.libredb # namespaces, kinds, and table schemas
136
+ npx libredb stats data.libredb # file size and namespace counts
137
+ npx libredb get data.libredb user:1 # print one value
138
+ npx libredb scan data.libredb user: # print key=value under a prefix
139
+ npx libredb set data.libredb user:1 Ada # set a key
140
+ npx libredb delete data.libredb user:1 # remove a key
141
+ npx libredb import data.libredb seed.json # bulk-set from a JSON object, atomically
142
+ ```
143
+
144
+ Read commands open the file read-only, so inspection never mutates it. Write commands take an
145
+ advisory `<path>.lock` to refuse a second concurrent writer; pass `--force` to override a stale lock.
146
+
147
+ Prefer a standalone binary with no Node or Bun installed? Each release attaches self-contained
148
+ executables (Linux, macOS, Windows; x64 and arm64) with `.sha256` checksums on its
149
+ [GitHub Release](https://github.com/libredb/libredb-database/releases). Or build one locally with
150
+ `bun run compile`.
151
+
152
+ Or run the CLI from a container (multi-arch, published to GHCR) — mount your data and pass a command:
153
+
154
+ ```sh
155
+ docker run --rm -v "$PWD:/data" ghcr.io/libredb/libredb inspect /data/app.libredb
156
+ ```
157
+
158
+ The image is a CLI shell, not a server: LibreDB stays an embedded, in-process database.
159
+
82
160
  ## How it works: one core, three lenses
83
161
 
162
+ <picture>
163
+ <source media="(prefers-color-scheme: dark)" srcset="docs/img/02-lenses-dark.png">
164
+ <img alt="One core, three lenses: kv, document, and relational APIs over a single ordered key-value core (core.ts), reaching disk through one FileSystem seam." src="docs/img/02-lenses-light.png" width="100%">
165
+ </picture>
166
+
84
167
  LibreDB has a single ordered byte key-value kernel (`src/core.ts`). Key-value, document, and relational
85
168
  are thin typed *lenses* over it — three faces of the same store. A relational table is physically a
86
169
  JSON document collection, which is physically ordered key-value entries built from composite keys like
@@ -122,6 +205,11 @@ flowchart TB
122
205
  CORE --> FS
123
206
  ```
124
207
 
208
+ <picture>
209
+ <source media="(prefers-color-scheme: dark)" srcset="docs/img/03-trust-dark.png">
210
+ <img alt="Open at the edges, guarded at the core: lenses, query surface, catalog, adapters, Studio, and docs are open to contribute; core.ts is guarded with 100% line coverage, deterministic crash tests, and heavy review - everything reaches the store through one narrow transact() port." src="docs/img/03-trust-light.png" width="100%">
211
+ </picture>
212
+
125
213
  **The file boundary is the trust boundary.** Below the line (`core.ts`) is guarded: heavy review and
126
214
  deterministic crash tests, because a bug there corrupts data. Above the line (lenses, query, catalog)
127
215
  is open and fast to contribute to, because the worst a bug can do is present a bad *view* — it reaches
@@ -150,6 +238,11 @@ See the [Manifesto](./MANIFESTO.md).
150
238
 
151
239
  ## Reliability
152
240
 
241
+ <picture>
242
+ <source media="(prefers-color-scheme: dark)" srcset="docs/img/04-reliability-dark.png">
243
+ <img alt="Crash recovery you can trust: a length-framed, CRC-32-checksummed write-ahead log fsync'd before commit; recovery truncates the last un-fsync'd record so what remains is always a valid committed prefix - proven by deterministic simulation testing." src="docs/img/04-reliability-light.png" width="100%">
244
+ </picture>
245
+
153
246
  A transaction that returns has been written to a length-framed, CRC-32-checksummed write-ahead log and
154
247
  `fsync`'d *before* the commit becomes visible — so a committed write survives a crash, and a crash can
155
248
  only ever damage the last, un-fsync'd record (which recovery detects and truncates). This is not just
@@ -187,6 +280,11 @@ but the API may still change and it is not yet meant for production data.
187
280
 
188
281
  ## The LibreDB family
189
282
 
283
+ <picture>
284
+ <source media="(prefers-color-scheme: dark)" srcset="docs/img/05-family-dark.png">
285
+ <img alt="The LibreDB family: LibreDB (the database, this repo), LibreDB Studio (the open-source IDE for every database), and LibreDB Platform (the managed, team-oriented form of data) - three products, one access-model spine." src="docs/img/05-family-light.png" width="100%">
286
+ </picture>
287
+
190
288
  LibreDB is the database in a three-product family that shares one access-model spine:
191
289
 
192
290
  - **LibreDB** — the database (this repository).
@@ -0,0 +1,3 @@
1
+ import type { FileSystem } from "../core.ts";
2
+ /** Build the default node:fs-backed {@link FileSystem}. */
3
+ export declare function nodeFileSystem(): FileSystem;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * adapter/node-fs.ts — the default {@link FileSystem} for Node and Bun.
3
+ *
4
+ * This is an edge, not the kernel (DESIGN.md section 5): it holds the one place
5
+ * LibreDB touches `node:fs`. The kernel (`core.ts`) is runtime-agnostic and
6
+ * imports nothing from `node:`; it reaches the disk only through the injected
7
+ * {@link FileSystem} seam. Keeping the node dependency HERE — and out of the
8
+ * kernel — is what lets the browser entry (`browser.ts`) ship without dragging
9
+ * `node:fs` into its import graph. The default Node entry (`index.ts`) wires this
10
+ * adapter in as the default `fs`, so production behaviour is unchanged.
11
+ *
12
+ * Each method is the obvious synchronous syscall, so the adapter adds an
13
+ * interface boundary, not behaviour. Appends go through one append-mode
14
+ * descriptor (creating the file if missing); reads, size and truncate work by
15
+ * path, matching how the WAL has always reached the disk.
16
+ */
17
+ import { closeSync, fsyncSync, openSync, readFileSync, statSync, truncateSync, writeSync } from "node:fs";
18
+ /** Build the default node:fs-backed {@link FileSystem}. */
19
+ export function nodeFileSystem() {
20
+ return {
21
+ open(path) {
22
+ const fd = openSync(path, "a"); // append-only; creates the file if missing
23
+ return {
24
+ size() {
25
+ return statSync(path).size;
26
+ },
27
+ read(offset, length) {
28
+ // A fresh Uint8Array so the returned slice is an independent copy, not
29
+ // a view aliasing a shared Buffer pool.
30
+ return new Uint8Array(readFileSync(path)).subarray(offset, offset + length);
31
+ },
32
+ append(bytes) {
33
+ for (let written = 0; written < bytes.length;) {
34
+ written += writeSync(fd, bytes, written);
35
+ }
36
+ },
37
+ fsync() {
38
+ fsyncSync(fd);
39
+ },
40
+ truncate(length) {
41
+ truncateSync(path, length);
42
+ },
43
+ close() {
44
+ closeSync(fd);
45
+ },
46
+ };
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * adapter/opfs.ts — an OPFS-backed {@link FileSystem} for the browser.
3
+ *
4
+ * An edge, not the kernel: it carries no durability logic, only the mapping from
5
+ * the kernel's synchronous {@link FileSystem} seam onto an OPFS sync access
6
+ * handle. The point is that a `FileSystemSyncAccessHandle` exposes SYNCHRONOUS
7
+ * read/write/getSize/truncate/flush/close — the exact shape the kernel's WAL
8
+ * needs — so LibreDB can be durable in a browser with no async core.
9
+ *
10
+ * Sync access handles only exist inside a Web Worker, and obtaining one is async
11
+ * (navigator.storage.getDirectory -> getFileHandle -> createSyncAccessHandle).
12
+ * That async acquisition is the caller's, done once before {@link
13
+ * import("../core.ts").open}; this adapter takes the already-open handle and
14
+ * wraps it synchronously, keeping `open` synchronous. Typical use in a Worker:
15
+ *
16
+ * const root = await navigator.storage.getDirectory();
17
+ * const file = await root.getFileHandle("app.libredb", { create: true });
18
+ * const handle = await file.createSyncAccessHandle();
19
+ * const db = open({ path: "app.libredb", fs: opfsFileSystem(handle) });
20
+ *
21
+ * The handle is bound to one file, so the kernel's `path` is unused here — the
22
+ * database opened with this adapter is the file the handle was created for.
23
+ */
24
+ import type { FileSystem } from "../core.ts";
25
+ /**
26
+ * The synchronous subset of a browser `FileSystemSyncAccessHandle` the WAL uses.
27
+ * Declared locally so the package needs no DOM lib types; a real sync access
28
+ * handle satisfies it structurally.
29
+ */
30
+ export interface SyncAccessHandle {
31
+ /** Read into `buffer` starting at `options.at` (default 0); returns bytes read. */
32
+ read(buffer: Uint8Array, options?: {
33
+ at?: number;
34
+ }): number;
35
+ /** Write `buffer` starting at `options.at` (default 0); returns bytes written. */
36
+ write(buffer: Uint8Array, options?: {
37
+ at?: number;
38
+ }): number;
39
+ /** The file's current size in bytes. */
40
+ getSize(): number;
41
+ /** Resize the file to `newSize`, dropping anything beyond it. */
42
+ truncate(newSize: number): void;
43
+ /** Persist buffered writes to storage. */
44
+ flush(): void;
45
+ /** Release the handle. */
46
+ close(): void;
47
+ }
48
+ /** Build a {@link FileSystem} backed by an open OPFS sync access `handle`. */
49
+ export declare function opfsFileSystem(handle: SyncAccessHandle): FileSystem;
@@ -0,0 +1,36 @@
1
+ /** Build a {@link FileSystem} backed by an open OPFS sync access `handle`. */
2
+ export function opfsFileSystem(handle) {
3
+ return {
4
+ open() {
5
+ return {
6
+ size() {
7
+ return handle.getSize();
8
+ },
9
+ read(offset, length) {
10
+ const buffer = new Uint8Array(length);
11
+ const read = handle.read(buffer, { at: offset });
12
+ return buffer.subarray(0, read);
13
+ },
14
+ append(data) {
15
+ // write() may write fewer bytes than asked (hence the returned count),
16
+ // so loop until the whole record is on the handle — otherwise a short
17
+ // write would persist a torn record while fsync reports success. This
18
+ // mirrors the node-fs adapter's writeSync loop.
19
+ const base = handle.getSize();
20
+ for (let written = 0; written < data.length;) {
21
+ written += handle.write(data.subarray(written), { at: base + written });
22
+ }
23
+ },
24
+ fsync() {
25
+ handle.flush();
26
+ },
27
+ truncate(length) {
28
+ handle.truncate(length);
29
+ },
30
+ close() {
31
+ handle.close();
32
+ },
33
+ };
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * browser.ts — the browser entry point of the LibreDB npm package
3
+ * (`@libredb/libredb/browser`).
4
+ *
5
+ * Same lens surface as the default Node entry ({@link import("./index.ts")}),
6
+ * with one difference: `open` is the kernel's own, carrying NO default
7
+ * filesystem. An in-memory database (`open()`) works anywhere; a path-backed
8
+ * open requires an injected `fs` (e.g. the bundled {@link opfsFileSystem}). The
9
+ * point of this entry is the import graph: it reaches nothing in `node:`, so a
10
+ * bundler can ship it to a browser. The node:fs adapter lives behind Node only.
11
+ */
12
+ export { open, version } from "./core.ts";
13
+ export type { Database, FileSystem, OpenOptions, WalFile } from "./core.ts";
14
+ export { opfsFileSystem } from "./adapter/opfs.ts";
15
+ export type { SyncAccessHandle } from "./adapter/opfs.ts";
16
+ export { kv } from "./lens/kv.ts";
17
+ export type { Kv, KvEntry } from "./lens/kv.ts";
18
+ export { doc } from "./lens/document.ts";
19
+ export type { DocCollection, Doc, DocEntry, JsonValue } from "./lens/document.ts";
20
+ export { table } from "./lens/relational.ts";
21
+ export type { Table, TableSchema, Row, ColumnType, Query } from "./lens/relational.ts";
22
+ export { catalog, isReservedKey, CATALOG_PREFIX, RESERVED_MARKER } from "./lens/catalog.ts";
23
+ export type { CatalogEntry, CatalogRegistry } from "./lens/catalog.ts";
24
+ export type { Result, WriteResult } from "./lens/types.ts";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * browser.ts — the browser entry point of the LibreDB npm package
3
+ * (`@libredb/libredb/browser`).
4
+ *
5
+ * Same lens surface as the default Node entry ({@link import("./index.ts")}),
6
+ * with one difference: `open` is the kernel's own, carrying NO default
7
+ * filesystem. An in-memory database (`open()`) works anywhere; a path-backed
8
+ * open requires an injected `fs` (e.g. the bundled {@link opfsFileSystem}). The
9
+ * point of this entry is the import graph: it reaches nothing in `node:`, so a
10
+ * bundler can ship it to a browser. The node:fs adapter lives behind Node only.
11
+ */
12
+ export { open, version } from "./core.js";
13
+ // OPFS persistence (browser-only): wrap an OPFS sync access handle as the
14
+ // filesystem for a path-backed open. See adapter/opfs.ts for usage in a Worker.
15
+ export { opfsFileSystem } from "./adapter/opfs.js";
16
+ export { kv } from "./lens/kv.js";
17
+ export { doc } from "./lens/document.js";
18
+ export { table } from "./lens/relational.js";
19
+ export { catalog, isReservedKey, CATALOG_PREFIX, RESERVED_MARKER } from "./lens/catalog.js";
@@ -0,0 +1,7 @@
1
+ /** A held lock. Call {@link Lock.release} once the write is done. */
2
+ export interface Lock {
3
+ release(): void;
4
+ }
5
+ /** Acquire the advisory lock for `path`. With `force`, an existing libredb lock
6
+ * is dropped first; otherwise an existing lock makes this throw. */
7
+ export declare function acquireLock(path: string, force: boolean): Lock;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * cli/lock.ts — an advisory write lock for the CLI.
3
+ *
4
+ * LibreDB is single-process and does no file locking itself, so two writers to
5
+ * one file would corrupt it. Write commands take a `<path>.lock` first: an
6
+ * exclusive create that fails if the file already exists, turning a concurrent
7
+ * writer into a loud error instead of silent corruption. It is advisory only —
8
+ * `--force` drops a stale lock and proceeds. The lock is always released after
9
+ * the write (see withWriteDb in run.ts).
10
+ */
11
+ import { closeSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
12
+ // Written into every lock file so a forced acquire can tell a real libredb lock
13
+ // from an unrelated file that merely happens to be named <path>.lock, and refuse
14
+ // to delete the latter.
15
+ const SENTINEL = "libredb-lock\n";
16
+ /** Acquire the advisory lock for `path`. With `force`, an existing libredb lock
17
+ * is dropped first; otherwise an existing lock makes this throw. */
18
+ export function acquireLock(path, force) {
19
+ const lockPath = `${path}.lock`;
20
+ if (force)
21
+ dropOwnLock(lockPath);
22
+ try {
23
+ const fd = openSync(lockPath, "wx"); // "wx": exclusive create, fails if it exists
24
+ writeSync(fd, SENTINEL);
25
+ closeSync(fd);
26
+ }
27
+ catch {
28
+ throw new Error(`libredb: ${path} is locked (${lockPath}); another writer may be active — use --force to override`);
29
+ }
30
+ return {
31
+ release() {
32
+ rmSync(lockPath, { force: true });
33
+ },
34
+ };
35
+ }
36
+ /** Remove an existing libredb lock so a forced acquire can proceed. Refuses to
37
+ * touch a file that is not a libredb lock, so `--force` can never delete
38
+ * unrelated user data that happens to share the `.lock` name. */
39
+ function dropOwnLock(lockPath) {
40
+ let contents;
41
+ try {
42
+ contents = readFileSync(lockPath, "utf8");
43
+ }
44
+ catch {
45
+ return; // no lock file to drop
46
+ }
47
+ if (!contents.startsWith(SENTINEL)) {
48
+ throw new Error(`libredb: refusing to remove ${lockPath} with --force: not a libredb lock file`);
49
+ }
50
+ rmSync(lockPath, { force: true });
51
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli/main.ts — the `libredb` bin shim.
4
+ *
5
+ * The only file that touches the real process: it wires process argv/stdout/
6
+ * stderr/exit code to the pure {@link run}. All behaviour lives in run.ts and is
7
+ * tested there; this glue is excluded from coverage (see bunfig.toml) because it
8
+ * cannot be exercised without spawning a process, and a behavioural smoke test
9
+ * (main.test.ts) runs the bin end-to-end instead.
10
+ */
11
+ import { run } from "./run.js";
12
+ process.exitCode = run(process.argv.slice(2), {
13
+ out: (line) => process.stdout.write(`${line}\n`),
14
+ err: (line) => process.stderr.write(`${line}\n`),
15
+ });
@@ -0,0 +1,3 @@
1
+ import type { FileSystem } from "../core.ts";
2
+ /** Build a read-only {@link FileSystem} over `node:fs`. */
3
+ export declare function readonlyFileSystem(): FileSystem;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * cli/readonly-fs.ts — a read-only {@link FileSystem} for the inspection CLI.
3
+ *
4
+ * An edge, not the kernel: it carries no durability logic, only the node:fs
5
+ * calls a read needs. Inspection commands open a database purely to read it, but
6
+ * open() runs recovery, which truncates a torn tail — a write. This adapter makes
7
+ * that impossible: `append` and `fsync` refuse, and `truncate` is a no-op, so a
8
+ * crash-interrupted file is recovered correctly IN MEMORY (the torn tail is
9
+ * dropped from the returned entries) while the bytes on disk are left exactly as
10
+ * found. `size` and `read` are the obvious read syscalls.
11
+ */
12
+ import { closeSync, openSync, readFileSync, statSync } from "node:fs";
13
+ /** Build a read-only {@link FileSystem} over `node:fs`. */
14
+ export function readonlyFileSystem() {
15
+ return {
16
+ open(path) {
17
+ const fd = openSync(path, "r"); // read-only; throws if the file is absent
18
+ return {
19
+ size() {
20
+ return statSync(path).size;
21
+ },
22
+ read(offset, length) {
23
+ return new Uint8Array(readFileSync(path)).subarray(offset, offset + length);
24
+ },
25
+ append() {
26
+ throw new Error("libredb: read-only database; refusing to write");
27
+ },
28
+ fsync() {
29
+ throw new Error("libredb: read-only database; refusing to write");
30
+ },
31
+ truncate() {
32
+ // Deliberate no-op: a read must not alter the file. Recovery still
33
+ // drops a torn tail from the in-memory state; the disk is untouched.
34
+ },
35
+ close() {
36
+ closeSync(fd);
37
+ },
38
+ };
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,7 @@
1
+ /** Where the CLI writes its output. One call is one line; the sink adds newlines. */
2
+ interface Io {
3
+ out(line: string): void;
4
+ err(line: string): void;
5
+ }
6
+ export declare function run(argv: string[], io: Io): number;
7
+ export {};
@@ -0,0 +1,231 @@
1
+ /**
2
+ * cli/run.ts — the LibreDB CLI as a pure function.
3
+ *
4
+ * `run(argv, io)` takes an argument vector and an IO sink and returns a process
5
+ * exit code. Keeping the whole CLI behind this seam — no direct stdout, no
6
+ * process.exit — is what makes every command and error path unit-testable; the
7
+ * bin shim (main.ts) is the only place that touches the real process.
8
+ *
9
+ * This is open-edge tooling over the public API, not kernel code: it adds no
10
+ * durability logic. Read commands (inspect/stats/get/scan) open through the
11
+ * read-only filesystem adapter so inspecting a file never mutates it. Write
12
+ * commands (set/delete/import) take an advisory lock first, and import commits
13
+ * all keys in one transaction so a bulk load is atomic.
14
+ */
15
+ import { readFileSync, statSync } from "node:fs";
16
+ import { parseArgs } from "node:util";
17
+ import { open } from "../index.js";
18
+ import { catalog, isReservedKey } from "../lens/catalog.js";
19
+ import { kv } from "../lens/kv.js";
20
+ import { acquireLock } from "./lock.js";
21
+ import { readonlyFileSystem } from "./readonly-fs.js";
22
+ const encoder = new TextEncoder();
23
+ const utf8 = (s) => encoder.encode(s);
24
+ const USAGE = [
25
+ "libredb - inspect and edit .libredb files",
26
+ "",
27
+ "Usage:",
28
+ " libredb inspect <path> List each namespace, its kind, and table schemas",
29
+ " libredb stats <path> Summarize the file: size and namespace counts",
30
+ " libredb get <path> <key> Print the value stored at a key",
31
+ " libredb scan <path> <prefix> Print key=value for every key under a prefix",
32
+ " libredb set <path> <key> <value> Set a key to a value",
33
+ " libredb delete <path> <key> Remove a key",
34
+ " libredb import <path> <file.json> Bulk-set keys from a JSON object (one atomic commit)",
35
+ "",
36
+ "Options:",
37
+ " --force Override an existing write lock",
38
+ ].join("\n");
39
+ /** Open `path` read-only, run `fn`, and always close — so a read leaves the file
40
+ * exactly as it was (recovery cannot truncate a torn tail through this adapter). */
41
+ const withReadDb = (path, fn) => {
42
+ const db = open({ path, fs: readonlyFileSystem() });
43
+ try {
44
+ return fn(db);
45
+ }
46
+ finally {
47
+ db.close();
48
+ }
49
+ };
50
+ /** Take the advisory lock, open `path` for writing, run `fn`, then always close
51
+ * and release — so a crash mid-write cannot leave the lock stranded. */
52
+ const withWriteDb = (path, force, fn) => {
53
+ const lock = acquireLock(path, force);
54
+ try {
55
+ const db = open({ path });
56
+ try {
57
+ return fn(db);
58
+ }
59
+ finally {
60
+ db.close();
61
+ }
62
+ }
63
+ finally {
64
+ lock.release();
65
+ }
66
+ };
67
+ function inspect({ path, io }) {
68
+ return withReadDb(path, (db) => {
69
+ const registry = catalog(db);
70
+ io.out(`${path} ${statSync(path).size} bytes`);
71
+ if (registry.size === 0) {
72
+ io.out(" (no catalogued namespaces)");
73
+ return 0;
74
+ }
75
+ for (const [name, entry] of registry) {
76
+ const schema = entry.schema === undefined ? "" : ` ${JSON.stringify(entry.schema)}`;
77
+ io.out(` ${name} ${entry.kind}${schema}`);
78
+ }
79
+ return 0;
80
+ });
81
+ }
82
+ function stats({ path, io }) {
83
+ return withReadDb(path, (db) => {
84
+ const registry = catalog(db);
85
+ const counts = { kv: 0, document: 0, relational: 0 };
86
+ for (const entry of registry.values())
87
+ counts[entry.kind]++;
88
+ io.out(`${path} ${statSync(path).size} bytes ${registry.size} namespaces`);
89
+ io.out(` kv: ${counts.kv} document: ${counts.document} relational: ${counts.relational}`);
90
+ return 0;
91
+ });
92
+ }
93
+ function get({ path, args, io }) {
94
+ const [key] = args;
95
+ if (key === undefined) {
96
+ io.err("missing <key>");
97
+ return 2;
98
+ }
99
+ return withReadDb(path, (db) => {
100
+ const value = kv(db).get(key);
101
+ if (value === undefined) {
102
+ io.err(`key not found: ${key}`);
103
+ return 1;
104
+ }
105
+ io.out(value);
106
+ return 0;
107
+ });
108
+ }
109
+ function scan({ path, args, io }) {
110
+ const [prefix] = args;
111
+ if (prefix === undefined) {
112
+ io.err("missing <prefix>");
113
+ return 2;
114
+ }
115
+ return withReadDb(path, (db) => {
116
+ for (const entry of kv(db).prefix(prefix))
117
+ io.out(`${entry.key}=${entry.value}`);
118
+ return 0;
119
+ });
120
+ }
121
+ function set({ path, args, io, force }) {
122
+ const [key, value] = args;
123
+ if (key === undefined || value === undefined) {
124
+ io.err("missing <key> <value>");
125
+ return 2;
126
+ }
127
+ if (isReservedKey(key)) {
128
+ io.err(`refusing to write a reserved key: ${key}`);
129
+ return 2;
130
+ }
131
+ return withWriteDb(path, force, (db) => {
132
+ const { changed } = kv(db).set(key, value);
133
+ io.out(`set ${key} (${changed} changed)`);
134
+ return 0;
135
+ });
136
+ }
137
+ function remove({ path, args, io, force }) {
138
+ const [key] = args;
139
+ if (key === undefined) {
140
+ io.err("missing <key>");
141
+ return 2;
142
+ }
143
+ return withWriteDb(path, force, (db) => {
144
+ const { changed } = kv(db).delete(key);
145
+ io.out(`delete ${key} (${changed} removed)`);
146
+ return 0;
147
+ });
148
+ }
149
+ function importKeys({ path, args, io, force }) {
150
+ const [file] = args;
151
+ if (file === undefined) {
152
+ io.err("missing <file>");
153
+ return 2;
154
+ }
155
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
156
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
157
+ io.err("import expects a JSON object of string values");
158
+ return 2;
159
+ }
160
+ const pairs = [];
161
+ for (const [key, value] of Object.entries(parsed)) {
162
+ if (typeof value !== "string") {
163
+ io.err("import expects a JSON object of string values");
164
+ return 2;
165
+ }
166
+ if (isReservedKey(key)) {
167
+ io.err(`import: refusing to write a reserved key: ${key}`);
168
+ return 2;
169
+ }
170
+ pairs.push([key, value]);
171
+ }
172
+ return withWriteDb(path, force, (db) => {
173
+ // One transaction for the whole load: a bulk import either lands entirely or,
174
+ // on a crash mid-write, not at all (recovery discards the torn record).
175
+ db.transact((tx) => {
176
+ for (const [key, value] of pairs)
177
+ tx.set(utf8(key), utf8(value));
178
+ });
179
+ io.out(`import ${pairs.length} keys`);
180
+ return 0;
181
+ });
182
+ }
183
+ /** The commands, keyed by name. Each takes the parsed {@link Ctx}. */
184
+ const commands = {
185
+ inspect,
186
+ stats,
187
+ get,
188
+ scan,
189
+ set,
190
+ delete: remove,
191
+ import: importKeys,
192
+ };
193
+ export function run(argv, io) {
194
+ let positionals;
195
+ let values;
196
+ try {
197
+ const parsed = parseArgs({
198
+ args: argv,
199
+ allowPositionals: true,
200
+ options: { help: { type: "boolean", short: "h" }, force: { type: "boolean" } },
201
+ });
202
+ positionals = parsed.positionals;
203
+ values = parsed.values;
204
+ }
205
+ catch (error) {
206
+ io.err(error instanceof Error ? error.message : String(error));
207
+ return 2;
208
+ }
209
+ if (values.help === true || positionals.length === 0) {
210
+ io.out(USAGE);
211
+ return 0;
212
+ }
213
+ const command = positionals[0];
214
+ const handler = commands[command];
215
+ if (handler === undefined) {
216
+ io.err(`unknown command: ${command}`);
217
+ return 2;
218
+ }
219
+ const path = positionals[1];
220
+ if (path === undefined) {
221
+ io.err("missing <path>");
222
+ return 2;
223
+ }
224
+ try {
225
+ return handler({ path, args: positionals.slice(2), io, force: values.force === true });
226
+ }
227
+ catch (error) {
228
+ io.err(error instanceof Error ? error.message : String(error));
229
+ return 1;
230
+ }
231
+ }
package/dist/core.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * and discards any record a crash left half-written.
21
21
  */
22
22
  /** The LibreDB package version. Kept in sync with package.json. */
23
- export declare const version = "0.0.4";
23
+ export declare const version = "0.1.0";
24
24
  /**
25
25
  * A key in the kernel: an immutable sequence of bytes.
26
26
  *
@@ -94,9 +94,10 @@ export interface Database {
94
94
  * goes through this interface. That keeps the IO boundary explicit and in one
95
95
  * place (a readability gain), and it lets a test inject a simulated filesystem
96
96
  * to torture crash recovery without a real disk (DESIGN.md section 6.4). The
97
- * default is a thin real-`node:fs` adapter, so production behaviour is
98
- * unchanged. The interface is deliberately the SMALLEST set of operations the
99
- * WAL performs and nothing more.
97
+ * kernel carries NO default filesystem: a path-backed open must be given one.
98
+ * The Node entry (index.ts) supplies a `node:fs`-backed adapter by default; the
99
+ * browser entry supplies none. The interface is deliberately the SMALLEST set
100
+ * of operations the WAL performs and nothing more.
100
101
  */
101
102
  export interface FileSystem {
102
103
  /** Open the log file at `path` for reading and appending, creating it if it
@@ -133,10 +134,12 @@ export interface WalFile {
133
134
  export interface OpenOptions {
134
135
  readonly path?: string;
135
136
  /**
136
- * The filesystem the write-ahead log runs on. Defaults to a thin real
137
- * `node:fs` adapter. Injecting one lets tests simulate crashes and IO faults
138
- * deterministically (DESIGN.md section 6.4). Ignored when there is no `path`
139
- * (an in-memory database never touches a filesystem).
137
+ * The filesystem the write-ahead log runs on. Required for a path-backed open:
138
+ * the kernel carries no default, so omitting it with a `path` throws. (The
139
+ * Node entry defaults this to a `node:fs` adapter; the browser entry has none.)
140
+ * Injecting one lets tests simulate crashes and IO faults deterministically
141
+ * (DESIGN.md section 6.4). Ignored when there is no `path` (an in-memory
142
+ * database never touches a filesystem).
140
143
  */
141
144
  readonly fs?: FileSystem;
142
145
  }
package/dist/core.js CHANGED
@@ -19,9 +19,8 @@
19
19
  * transactions, and a write-ahead log for durability. Recovery replays that log
20
20
  * and discards any record a crash left half-written.
21
21
  */
22
- import { closeSync, fsyncSync, openSync, readFileSync, statSync, truncateSync, writeSync } from "node:fs";
23
22
  /** The LibreDB package version. Kept in sync with package.json. */
24
- export const version = "0.0.4";
23
+ export const version = "0.1.0";
25
24
  /**
26
25
  * Compare two keys by unsigned byte-lexicographic order: the first differing
27
26
  * byte decides, and if one key is a prefix of the other the shorter sorts
@@ -245,44 +244,6 @@ function recover(file) {
245
244
  file.truncate(offset);
246
245
  return entries;
247
246
  }
248
- /**
249
- * The default {@link FileSystem}: a thin adapter over `node:fs`. Each method is
250
- * the obvious synchronous syscall, so the seam adds an interface, not behaviour.
251
- * Appends go through one append-mode descriptor (creating the file if missing);
252
- * reads, size and truncate work by path, matching how the WAL has always
253
- * reached the disk.
254
- */
255
- function nodeFileSystem() {
256
- return {
257
- open(path) {
258
- const fd = openSync(path, "a"); // append-only; creates the file if missing
259
- return {
260
- size() {
261
- return statSync(path).size;
262
- },
263
- read(offset, length) {
264
- // A fresh Uint8Array so the returned slice is an independent copy, not
265
- // a view aliasing a shared Buffer pool.
266
- return new Uint8Array(readFileSync(path)).subarray(offset, offset + length);
267
- },
268
- append(bytes) {
269
- for (let written = 0; written < bytes.length;) {
270
- written += writeSync(fd, bytes, written);
271
- }
272
- },
273
- fsync() {
274
- fsyncSync(fd);
275
- },
276
- truncate(length) {
277
- truncateSync(path, length);
278
- },
279
- close() {
280
- closeSync(fd);
281
- },
282
- };
283
- },
284
- };
285
- }
286
247
  /** Open the log file at `path` on `fs`, recover the store from it, and return
287
248
  * the recovered entries together with a {@link Log} that appends future commits
288
249
  * to the same file. */
@@ -317,7 +278,20 @@ export const open = (options) => {
317
278
  let committed;
318
279
  let log;
319
280
  if (options?.path !== undefined) {
320
- const opened = openLog(options.path, options.fs ?? nodeFileSystem());
281
+ // A path must actually name a file: reject the degenerate empty string here
282
+ // with a clear error, rather than letting it reach the filesystem and
283
+ // surface a raw, adapter-specific failure (e.g. node's ENOENT for "").
284
+ if (options.path === "") {
285
+ throw new Error("libredb: open({ path }) requires a non-empty path");
286
+ }
287
+ // The kernel is runtime-agnostic: it carries no default filesystem, so a
288
+ // path-backed open MUST be given one. The default node:fs adapter lives at
289
+ // the package edge (index.ts wires it in); the browser entry has none. A
290
+ // pathless, in-memory open never reaches here and needs no filesystem.
291
+ if (options.fs === undefined) {
292
+ throw new Error("libredb: open({ path }) requires a filesystem; none was provided");
293
+ }
294
+ const opened = openLog(options.path, options.fs);
321
295
  committed = opened.entries;
322
296
  log = opened.log;
323
297
  }
package/dist/index.d.ts CHANGED
@@ -10,8 +10,18 @@
10
10
  * {@link isReservedKey} (with {@link RESERVED_MARKER} / {@link CATALOG_PREFIX})
11
11
  * lets a raw-KV tool hide engine-internal keys instead of hardcoding the layout.
12
12
  */
13
- export { version, open } from "./core.ts";
14
- export type { Database, OpenOptions } from "./core.ts";
13
+ import { type Open } from "./core.ts";
14
+ export { version } from "./core.ts";
15
+ export type { Database, FileSystem, OpenOptions, WalFile } from "./core.ts";
16
+ /**
17
+ * Open a LibreDB database on Node or Bun. Identical to the kernel's
18
+ * {@link import("./core.ts").open}, except a path-backed open with no `fs`
19
+ * defaults to the real `node:fs` adapter — so `open({ path })` is durable out of
20
+ * the box. A pathless open stays in-memory; an explicit `fs` is passed through.
21
+ * The browser entry (`@libredb/libredb/browser`) omits this default so it never
22
+ * imports `node:fs`.
23
+ */
24
+ export declare const open: Open;
15
25
  export { kv } from "./lens/kv.ts";
16
26
  export type { Kv, KvEntry } from "./lens/kv.ts";
17
27
  export { doc } from "./lens/document.ts";
package/dist/index.js CHANGED
@@ -10,7 +10,20 @@
10
10
  * {@link isReservedKey} (with {@link RESERVED_MARKER} / {@link CATALOG_PREFIX})
11
11
  * lets a raw-KV tool hide engine-internal keys instead of hardcoding the layout.
12
12
  */
13
- export { version, open } from "./core.js";
13
+ import { open as openKernel } from "./core.js";
14
+ import { nodeFileSystem } from "./adapter/node-fs.js";
15
+ export { version } from "./core.js";
16
+ /**
17
+ * Open a LibreDB database on Node or Bun. Identical to the kernel's
18
+ * {@link import("./core.ts").open}, except a path-backed open with no `fs`
19
+ * defaults to the real `node:fs` adapter — so `open({ path })` is durable out of
20
+ * the box. A pathless open stays in-memory; an explicit `fs` is passed through.
21
+ * The browser entry (`@libredb/libredb/browser`) omits this default so it never
22
+ * imports `node:fs`.
23
+ */
24
+ export const open = (options) => options?.path !== undefined && options.fs === undefined
25
+ ? openKernel({ ...options, fs: nodeFileSystem() })
26
+ : openKernel(options);
14
27
  export { kv } from "./lens/kv.js";
15
28
  export { doc } from "./lens/document.js";
16
29
  export { table } from "./lens/relational.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libredb/libredb",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "A small, readable, embeddable, multi-model database. One ordered key-value core, thin model lenses on top.",
5
5
  "keywords": [
6
6
  "database",
@@ -23,6 +23,9 @@
23
23
  },
24
24
  "type": "module",
25
25
  "license": "MIT",
26
+ "bin": {
27
+ "libredb": "./dist/cli/main.js"
28
+ },
26
29
  "files": [
27
30
  "dist"
28
31
  ],
@@ -34,8 +37,16 @@
34
37
  "types": "./dist/index.d.ts",
35
38
  "exports": {
36
39
  ".": {
40
+ "browser": {
41
+ "types": "./dist/browser.d.ts",
42
+ "import": "./dist/browser.js"
43
+ },
37
44
  "types": "./dist/index.d.ts",
38
45
  "import": "./dist/index.js"
46
+ },
47
+ "./browser": {
48
+ "types": "./dist/browser.d.ts",
49
+ "import": "./dist/browser.js"
39
50
  }
40
51
  },
41
52
  "sideEffects": false,
@@ -50,6 +61,7 @@
50
61
  "lint": "oxlint && eslint .",
51
62
  "test": "bun test --coverage",
52
63
  "build": "tsc --project tsconfig.build.json",
64
+ "compile": "bun build --compile src/cli/main.ts --outfile libredb",
53
65
  "size": "size-limit",
54
66
  "attw": "rm -rf .attw && bun pm pack --quiet --destination .attw && attw .attw/*.tgz --profile esm-only",
55
67
  "publint": "publint",