@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.
- package/README.md +233 -250
- package/dist/adapter/node-fs.d.ts +3 -0
- package/dist/adapter/node-fs.js +49 -0
- package/dist/adapter/opfs.d.ts +49 -0
- package/dist/adapter/opfs.js +36 -0
- package/dist/browser.d.ts +24 -0
- package/dist/browser.js +19 -0
- package/dist/cli/lock.d.ts +7 -0
- package/dist/cli/lock.js +51 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +15 -0
- package/dist/cli/readonly-fs.d.ts +3 -0
- package/dist/cli/readonly-fs.js +41 -0
- package/dist/cli/run.d.ts +7 -0
- package/dist/cli/run.js +231 -0
- package/dist/core.d.ts +11 -8
- package/dist/core.js +15 -41
- package/dist/index.d.ts +12 -2
- package/dist/index.js +14 -1
- package/dist/lens/catalog.d.ts +1 -1
- package/dist/lens/document.js +4 -1
- package/dist/lens/relational.js +8 -1
- package/dist/lens/types.d.ts +2 -2
- package/dist/lens/types.js +2 -2
- package/package.json +67 -7
|
@@ -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";
|
package/dist/browser.js
ADDED
|
@@ -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;
|
package/dist/cli/lock.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cli/main.js
ADDED
|
@@ -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,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
|
+
}
|
package/dist/cli/run.js
ADDED
|
@@ -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
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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.
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
export
|
|
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
|
-
|
|
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";
|