@libredb/libredb 0.0.3 → 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.
@@ -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.3";
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.3";
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";