diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4ebbf13 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Keep the build context tiny: the image only needs src/ to compile the CLI +# (bun build --compile bundles src directly). Everything else is excluded. +node_modules +dist +coverage +.git +.github +.attw +*.tgz +*.log +docs +*.md +.changeset +.remember +.ralph +loop +.vscode +.idea +libredb +libredb-* +*.sha256 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5cc7a9d..9ade7b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,7 +1,20 @@ name: Publish -# Publishes to npm ONLY when a GitHub Release is published. It never runs on -# push or pull_request, so creating the tag + release is the single trigger. +# Publishes to npm, JSR, GitHub Releases, GHCR and Docker Hub ONLY when a GitHub +# Release is published. It never runs on push or pull_request, so creating the +# tag + release is the single trigger. +# +# JSR (one-time setup by the maintainer before the first JSR release): create the +# `@libredb` scope on jsr.io, create the `libredb` package, and link this GitHub +# repository to it. That linkage is what lets the `jsr` job below authenticate via +# OIDC (`id-token: write`) with no token or secret. +# +# Docker Hub (one-time setup): set the repository VARIABLE `DOCKER_HUB_USERNAME` +# (the Docker Hub namespace, e.g. `libredb`) and the repository SECRET +# `DOCKER_HUB_TOKEN`. Both are required by the `docker` job; if the username +# variable is unset the Docker Hub login step fails fast (a clear guard) rather +# than pushing to a malformed `docker.io//libredb` path. GHCR needs no setup (it +# uses the built-in `GITHUB_TOKEN`). on: release: types: [published] @@ -12,6 +25,9 @@ permissions: jobs: publish: name: npm publish + # Full releases only: a GitHub pre-release must not publish to the npm/JSR + # `latest` channel or Docker `:latest`. Every job carries the same guard. + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -38,10 +54,142 @@ jobs: - name: Job summary if: success() run: | - V=$(node -p "require('./package.json').version") + V=$(node -p 'require("./package.json").version') { echo "## Published to npm" echo "" echo "- Package: \`@libredb/libredb@${V}\`" echo "- [View on npm](https://www.npmjs.com/package/@libredb/libredb/v/${V})" } >> "$GITHUB_STEP_SUMMARY" + + jsr: + name: JSR publish + # Runs only after npm publish succeeds (npm is the primary registry and has + # already run the full gate), so JSR mirrors exactly what npm shipped. + needs: publish + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # OIDC: token-less JSR publish via the repo-package linkage + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # Use Node + `npx jsr publish` (JSR's canonical invocation) rather than + # `bunx jsr`, which can resolve `jsr` to the repo-root jsr.json on some Bun + # versions ("Cannot run jsr.json"). JSR publishes from source and runs its + # own slow-types check; no build or dependency install is needed. + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + - run: npx --yes jsr publish + - name: Job summary + if: success() + run: | + V=$(node -p 'require("./package.json").version') + { + echo "## Published to JSR" + echo "" + echo "- Package: \`@libredb/libredb@${V}\`" + echo "- [View on JSR](https://jsr.io/@libredb/libredb@${V})" + } >> "$GITHUB_STEP_SUMMARY" + + binaries: + name: Standalone binaries + # A self-contained executable of the CLI attached to the GitHub Release (not + # shipped to npm/JSR). Gated behind the publish job so binaries never ship + # from a commit that failed the gate. Bun cross-compiles every target from + # one Linux runner. + needs: publish + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: write # upload assets to the GitHub Release + strategy: + fail-fast: false + matrix: + include: + - target: bun-linux-x64 + out: libredb-linux-x64 + - target: bun-linux-arm64 + out: libredb-linux-arm64 + - target: bun-darwin-x64 + out: libredb-darwin-x64 + - target: bun-darwin-arm64 + out: libredb-darwin-arm64 + - target: bun-windows-x64 + out: libredb-windows-x64.exe + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - run: bun install --frozen-lockfile + - name: Compile + env: + TARGET: ${{ matrix.target }} + OUT: ${{ matrix.out }} + run: bun build --compile --target="$TARGET" src/cli/main.ts --outfile "$OUT" + - name: Checksum + env: + OUT: ${{ matrix.out }} + run: sha256sum "$OUT" > "$OUT.sha256" + - name: Upload to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + OUT: ${{ matrix.out }} + run: gh release upload "$TAG" "$OUT" "$OUT.sha256" --clobber + + docker: + name: Docker image + # A portable shell for the CLI (not a server), built multi-arch once and + # pushed to both GHCR and Docker Hub. Gated behind the publish job so the + # image (and :latest) never ships from a commit that failed the gate. + needs: publish + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Resolve version + id: meta + run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + username: ${{ vars.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + # One buildx build, pushed to both registries via the tag list. + - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/libredb:${{ steps.meta.outputs.version }} + ghcr.io/${{ github.repository_owner }}/libredb:latest + docker.io/${{ vars.DOCKER_HUB_USERNAME }}/libredb:${{ steps.meta.outputs.version }} + docker.io/${{ vars.DOCKER_HUB_USERNAME }}/libredb:latest + - name: Job summary + if: success() + env: + OWNER: ${{ github.repository_owner }} + DOCKERHUB: ${{ vars.DOCKER_HUB_USERNAME }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + { + echo "## Published Docker image" + echo "" + echo "- \`ghcr.io/${OWNER}/libredb:${VERSION}\` (and \`:latest\`)" + echo "- \`docker.io/${DOCKERHUB}/libredb:${VERSION}\` (and \`:latest\`)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 7cbcc90..f528ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ dist/ build/ *.tsbuildinfo +# Compiled standalone binaries (bun build --compile / `bun run compile`) +/libredb +/libredb-* +*.sha256 + # Packaging-check scratch (attw runs against a packed tarball) .attw/ *.tgz @@ -39,3 +44,4 @@ loop/ .npmrc +.qoder/ diff --git a/.size-limit.json b/.size-limit.json index 3600456..cb0dded 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -4,5 +4,10 @@ "path": "dist/index.js", "ignore": ["node:fs", "node:os", "node:path"], "limit": "4 kB" + }, + { + "name": "browser entry (min+brotli)", + "path": "dist/browser.js", + "limit": "4 kB" } ] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9946b7e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# @libredb/libredb + +## 0.1.3 + +### Patch Changes + +- 78f3547: Add a `libredb` CLI for inspecting and editing `.libredb` files (`npx libredb`). + + Read commands: `inspect` (list each namespace, its kind, and table schemas), + `stats` (file size and namespace counts by kind), `get `, and + `scan `. They open through a read-only filesystem adapter, so inspecting + a file never mutates it — even a crash-torn tail is recovered in memory only, + leaving the bytes on disk untouched. + + Write commands: `set `, `delete `, and `import ` + (bulk-set from a JSON object in a single atomic commit). Writes take an advisory + `.lock` so a second concurrent writer fails loudly instead of corrupting + the file; `--force` overrides a stale lock. + + The CLI is built on the public API with zero dependencies (Node/Bun `parseArgs`). + +- 8a1bd79: Add a browser entry point (`@libredb/libredb/browser`) and make the kernel + runtime-agnostic. + + The `node:fs` dependency moved out of the kernel (`core.ts`) into a dedicated + adapter, so importing LibreDB no longer drags `node:fs` into the module graph. + The default Node entry (`@libredb/libredb`) is unchanged: `open({ path })` still + defaults to the real filesystem and is durable out of the box. The new browser + entry exposes the same lens surface with an `open` that has no default + filesystem — in-memory databases work anywhere, and a path-backed open accepts + an injected filesystem. A bundler targeting the browser now resolves a build + free of Node built-ins via the `browser` export condition. + +- 1f823de: Add OPFS-backed browser persistence via `opfsFileSystem` (exported from + `@libredb/libredb/browser`). + + A browser `FileSystemSyncAccessHandle` exposes synchronous read/write/getSize/ + truncate/flush/close, which map directly onto the kernel's synchronous filesystem + seam — so a LibreDB database can be durable in the browser with no async core. + Inside a Web Worker, obtain a sync access handle and pass it to `open`: + + ```ts + const root = await navigator.storage.getDirectory(); + const file = await root.getFileHandle("app.libredb", { create: true }); + const db = open({ + path: "app.libredb", + fs: opfsFileSystem(await file.createSyncAccessHandle()), + }); + ``` + + The adapter takes an already-open handle (acquisition is async and the caller's), + keeping `open` synchronous. The new `SyncAccessHandle` type names the handle + shape the adapter needs, so the package depends on no DOM lib types. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b1fb53b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 +# +# A portable shell for the `libredb` CLI — NOT a server. LibreDB is an embedded, +# in-process database; this image just carries the inspection/edit CLI so it can +# run anywhere with a volume-mounted .libredb file. Mount your data and run a +# command, e.g.: +# +# docker run --rm -v "$PWD:/data" ghcr.io/libredb/libredb inspect /data/app.libredb +# +# The CLI has zero runtime dependencies, so the build needs no `bun install`: +# `bun build --compile` bundles only src/ into one self-contained executable. + +# Stage 1: compile the CLI for the build platform (buildx sets it per --platform, +# so the same Dockerfile cross-builds amd64 and arm64). +# Pinned by digest (the tag is mutable) for a reproducible, supply-chain-safe +# build; the tag stays for readability and tracks .bun-version (1.3.14, the same +# Bun the binaries job pins via setup-bun). Bump both the tag and the digest +# together when .bun-version changes. +FROM oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS build +WORKDIR /src +# bun build --compile bundles only the source it reaches; the CLI imports nothing +# outside src/, so no package.json/tsconfig and no `bun install` are needed. +COPY src ./src +RUN bun build --compile src/cli/main.ts --outfile /libredb + +# Stage 2: a minimal runtime carrying only the binary and the glibc/libstdc++ a +# bun-compiled executable links against. distroless/cc has exactly that. Pinned +# by digest because `cc-debian12` is a rolling tag (no version), so the digest is +# what makes the runtime reproducible; refresh it periodically for base updates. +FROM gcr.io/distroless/cc-debian12@sha256:d703b626ba455c4e6c6fbe5f36e6f427c85d51445598d564652a2f334179f96e +COPY --from=build /libredb /usr/local/bin/libredb +WORKDIR /data +ENTRYPOINT ["/usr/local/bin/libredb"] diff --git a/README.md b/README.md index 5ec74cd..688cba1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ **Multi-model without the magic. One core, three lenses, every line tested.** [![npm version](https://img.shields.io/npm/v/@libredb/libredb.svg)](https://www.npmjs.com/package/@libredb/libredb) +[![JSR](https://jsr.io/badges/@libredb/libredb)](https://jsr.io/@libredb/libredb) +[![Docker Hub](https://img.shields.io/docker/v/libredb/libredb?logo=docker&logoColor=white&label=docker%20hub&color=2496ED&sort=semver)](https://hub.docker.com/r/libredb/libredb) [![CI](https://github.com/libredb/libredb-database/actions/workflows/ci.yml/badge.svg)](https://github.com/libredb/libredb-database/actions/workflows/ci.yml) [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=libredb_libredb-database&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=libredb_libredb-database) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=libredb_libredb-database&metric=coverage)](https://sonarcloud.io/summary/new_code?id=libredb_libredb-database) @@ -84,6 +86,89 @@ Each lens has its own guide: [key-value](./docs/guides/key-value.md) · [document](./docs/guides/document.md) · [relational](./docs/guides/relational.md) · [catalog](./docs/guides/catalog.md). +## Install elsewhere: JSR, CDN, and the browser + +LibreDB is the same ESM-only package everywhere; only how you reach it changes. + +**JSR** — published to [jsr.io](https://jsr.io/@libredb/libredb) alongside npm: + +```sh +bunx jsr add @libredb/libredb +# or: npx jsr add @libredb/libredb / deno add jsr:@libredb/libredb +``` + +**CDN** — every release is served from the npm registry by the usual CDNs. Pin a version: + +```ts +import { open, kv } from "https://esm.sh/@libredb/libredb@0.1.3"; +``` + +**Browser** — a dedicated entry that imports nothing from `node:`, so it bundles for the browser +cleanly. Its `open` carries no default filesystem: an in-memory database works anywhere, and a +path-backed open takes a filesystem you inject (e.g. the OPFS adapter shown below). + +```ts +import { open, kv } from "@libredb/libredb/browser"; + +const db = open(); // in-memory +kv(db).set("greeting", "hello"); +``` + +For durable storage in the browser, run inside a Web Worker and back the database with an OPFS sync +access handle (the kernel stays synchronous — no async core): + +```ts +import { open, opfsFileSystem } from "@libredb/libredb/browser"; + +const root = await navigator.storage.getDirectory(); +const file = await root.getFileHandle("app.libredb", { create: true }); +const handle = await file.createSyncAccessHandle(); +const db = open({ path: "app.libredb", fs: opfsFileSystem(handle) }); +``` + +A browser-targeting bundler also resolves the browser build from the main `@libredb/libredb` entry +via the package's `browser` export condition. Note that TypeScript usually still resolves the Node +types for that entry (where `fs` is optional) unless it is configured for the `browser` condition +(`customConditions`). To get the browser-specific typing — `fs` required when `path` is given — and +keep types in step with the runtime, import the explicit `@libredb/libredb/browser` subpath. + +## Command-line tool + +The package ships a `libredb` bin for inspecting and editing `.libredb` files — no code required: + +```sh +npx libredb inspect data.libredb # namespaces, kinds, and table schemas +npx libredb stats data.libredb # file size and namespace counts +npx libredb get data.libredb user:1 # print one value +npx libredb scan data.libredb user: # print key=value under a prefix +npx libredb set data.libredb user:1 Ada # set a key +npx libredb delete data.libredb user:1 # remove a key +npx libredb import data.libredb seed.json # bulk-set from a JSON object, atomically +``` + +Read commands open the file read-only, so inspection never mutates it. Write commands take an +advisory `.lock` to refuse a second concurrent writer. Use `--force` only to clear a stale +lock left by a crashed writer: the lock is advisory and LibreDB is single-process, so two writers +that force at the same time can still race and corrupt the file. + +Prefer a standalone binary with no Node or Bun installed? Each release attaches self-contained +executables (Linux, macOS, Windows; x64 and arm64) with `.sha256` checksums on its +[GitHub Release](https://github.com/libredb/libredb-database/releases). Or build one locally with +`bun run compile`. + +Or run the CLI from a container (multi-arch, published to GHCR and Docker Hub) — mount your data and +pass a command: + +```sh +docker run --rm -v "$PWD:/data" ghcr.io/libredb/libredb inspect /data/app.libredb +# or from Docker Hub: docker run --rm -v "$PWD:/data" libredb/libredb inspect /data/app.libredb +``` + +The same multi-arch image is published to both registries: +[Docker Hub](https://hub.docker.com/r/libredb/libredb) and +[GHCR](https://github.com/libredb/libredb-database/pkgs/container/libredb). It is a CLI shell, not a +server: LibreDB stays an embedded, in-process database. + ## How it works: one core, three lenses diff --git a/bunfig.toml b/bunfig.toml index 8571d00..29c9c3b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -6,6 +6,11 @@ coverage = true # CI-based coverage ingestion. coverage/ is git-ignored. coverageReporter = ["text", "lcov"] coverageSkipTestFiles = true +# The bin shim (cli/main.ts) is pure process glue — shebang, process.argv, +# stdout/stderr and exit code — with all behaviour in cli/run.ts (held at 100%). +# It cannot be line-covered without spawning a process, so it is excluded here +# and validated behaviourally by an end-to-end smoke test (cli/main.test.ts). +coveragePathIgnorePatterns = ["src/cli/main.ts"] # Start at 100% and hold it. When the kernel lands this stays the bar for core.ts. # Bun enforces line/function/statement (not branch); all three held at 100%. coverageThreshold = { line = 1.0, function = 1.0, statement = 1.0 } diff --git a/docs/superpowers/specs/2026-06-29-distribution-channels-design.md b/docs/superpowers/specs/2026-06-29-distribution-channels-design.md new file mode 100644 index 0000000..bf489f2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-distribution-channels-design.md @@ -0,0 +1,227 @@ +# Distribution Channels — Research and Design + +Design and research document for [issue #6](https://github.com/libredb/libredb-database/issues/6): +*Explore additional packaging/distribution channels (JSR, CDN/browser, CLI, standalone binary, Docker).* + +Status: design approved (2026-06-29). Implementation deferred to per-phase specs. +This document is the research that the issue mandated before development begins. + +## 1. Scope and intent + +LibreDB ships today through a single channel: the npm package `@libredb/libredb` +(ESM-only, Node 22+/Bun, zero runtime dependencies, public surface is the lens API). +This document evaluates five additional channels and lays out a phased roadmap. + +It is one research/design document covering all five channels — not five +implementation specs. Each channel is decomposed into its own phase; when a phase +is greenlit it gets its own spec, plan, and implementation cycle. + +**Non-goal (carried from the issue and the manifesto):** no networked +client/server engine. LibreDB is an embedded, in-process library (SQLite-style), +not a server/daemon (Postgres-style). Every channel below preserves that posture — +the CLI, binary, and Docker image are all *embedded tooling shells*, never a +listening service. + +## 2. Current state (verified against code) + +These facts shape every decision and were verified against the source, not memory. + +- **Public API.** `open({ path?, fs? }) -> Database`; lenses `kv`, `doc`, `table`, + and the `catalog` registry sit on top (`src/index.ts`). Synchronous throughout + (`transact`, `fsyncSync`). Sync core is a ratified DESIGN decision; an async + face may be layered later as an adapter over the sync core. +- **node:fs coupling (pre-Phase-0 baseline; resolved by this work).** At the start + of this work, `core.ts` statically imported `node:fs` at module top + (`import { ... } from "node:fs"`). The `FileSystem` seam already existed and the + default `nodeFileSystem()` was only used when a `path` was given — but the static + import meant importing the package at all dragged `node:fs` into the import graph, + breaking browser use even for in-memory (`path`-less) databases. This was the + single linchpin blocking the browser channel, and Phase 0 below removes it. +- **File format.** A `.libredb` file IS the write-ahead log; there is no separate + data file. Record framing: `[u32 payloadLength][u32 crc32(payload)][payload]`, + where payload is a sequence of ops (`[1 byte kind][u32 keyLen][key]`, and for a + set `+[u32 valueLen][value]`). Recovery replays records, stops at the first + incomplete/bad-CRC record, and truncates the torn tail. The whole file is read + into memory on open (`readFileSync`). +- **Catalog.** `catalog(store)` returns a registry keyed by namespace with each + namespace's kind (`kv`/`document`/`relational`) and, for a table, its schema. + Reserved prefix `\x00libredb:catalog:`. This is what lets a tool render faithful + per-kind views from a cold file. +- **Build / JSR readiness.** Source uses honest `.ts` import specifiers + (`./core.ts`); the build rewrites them to `.js` via + `rewriteRelativeImportExtensions`. `isolatedDeclarations: true` is on. This is + ideal for JSR, which prefers explicit `.ts` specifiers and rewards explicit + types (its "slow types" check passes cleanly). +- **Constraints.** 100% line/function/statement coverage held by the gate; + changesets for user-facing changes; conventional commits; English-only; no emoji; + zero runtime dependencies. + +## 3. Central architectural decision: decouple node:fs + +The browser channel and a genuinely runtime-agnostic core both depend on one +refactor, so it is decided up front and pulled to Phase 0. + +**Chosen approach (A): split the adapter out of the core and add conditional +exports.** + +- Move `nodeFileSystem()` from `core.ts` to `src/adapter/node-fs.ts`. After this, + `core.ts` has zero `node:` imports — a genuinely runtime-agnostic kernel. +- `core.ts`'s `open` no longer imports a default filesystem. If a `path` is given + without an `fs`, it throws a clear error ("no filesystem provided for path"). +- Two entry points: + - `src/index.ts` (default, Node): re-exports an `open` pre-bound with + `nodeFileSystem()` as the default `fs`, so the Node experience is unchanged + and backward compatible. + - `src/browser.ts` (new): exposes the core `open` as-is. In-memory works + out of the box; a `path` requires an injected `fs` (e.g. a future OPFS + adapter). +- `package.json` `exports` gains `browser`/`import` conditions and a `./browser` + subpath. `node:fs` leaves the browser import graph entirely. + +Rejected alternatives: **(B) dynamic `await import("node:fs")`** breaks the +ratified synchronous contract; **(C) rely on bundler aliasing** is fragile, +depends on the consumer's build, and is not true browser support. + +This change touches the public API (additive: a new subpath, an unchanged Node +default), so it carries a **minor** changeset. The honesty discipline holds: the +core gets smaller and purer, not more complex. + +## 4. Per-channel design + +### Channel 1 — JSR (`jsr.io`) + +- **Effort:** low. **Risk:** low. **Value:** high. +- Add `jsr.json` (`name: "@libredb/libredb"`, `exports` mapping `.` to + `./src/index.ts` and `./browser` to `./src/browser.ts`). JSR publishes from + source and runs its own transpile/type generation. +- Version stays single-sourced from `package.json`: extend `scripts/sync-version.ts` + to also write `jsr.json`, backstopped by a test as today. +- CI: add `npx jsr publish` to the publish workflow (OIDC, token-less), running in + parallel with the npm publish on the same tag. +- Watch item: `node:` built-in imports can be flagged by JSR — already mitigated + because, after Phase 0, only `adapter/node-fs.ts` imports `node:`. + +### Channel 2 — CDN / browser + +- **Effort:** low (docs) + the Phase 0 entry. **Risk:** low. **Value:** high. +- esm.sh / jsdelivr / unpkg already serve the npm package. Document a pinned-version + browser import example (`import { open, kv } from "https://esm.sh/@libredb/libredb"`). +- True browser support is the `src/browser.ts` entry from Phase 0: in-memory fully + works; `path` errors clearly. +- CI proof: a test asserting the browser entry's import graph contains no `node:` + specifier, so the boundary cannot silently regress. +- **Persistence (future, Phase 5 — not in this round):** wire an OPFS adapter into + the existing `FileSystem` seam. OPFS sync access handles (available inside a Web + Worker) preserve the core's synchronous contract and are the recommended path. + IndexedDB is async and would require the DESIGN-anticipated async-face adapter. + The doc recommends OPFS-in-Worker; the final call is deferred to the Phase 5 spec. + +### Channel 3 — CLI (`npx libredb`) — read + write + +- **Effort:** high. **Risk:** medium-high. **Value:** high. +- New `src/cli/` entry, a thin wrapper over the public API. Zero dependencies via + `node:util` `parseArgs`. `package.json` `bin: { "libredb": "./dist/cli/main.js" }`. +- Commands (as shipped): + - Read: `inspect` (catalog summary grouped by kind), `stats` (file size + + namespace count + counts by kind), `get `, `scan `. + - Write: `set`, `delete`, `import`. + - `repl` was deferred (the issue marked it optional; it is interactive and adds + disproportionate test surface). +- **Data-safety design (DBA-critical):** + - LibreDB is single-process with no file locking. Two concurrent writers corrupt + the file. Write commands acquire an advisory lock (`.lock`, carrying a + sentinel so `--force` refuses to delete a non-lock file); if held, they refuse + unless `--force`. + - Writes reject reserved keys (`isReservedKey`) so the CLI cannot overwrite the + catalog/lens keyspace. + - `open()` truncates a torn tail during recovery, so even a read-intent command + can mutate the file. Read commands therefore use a read-only `FileSystem` + adapter that opens `O_RDONLY` and turns `truncate` into a no-op plus a warning. + This is the default for `inspect`/`get`/`scan`/`stats`. + - `import` and bulk writes commit in a single atomic transaction; a crash mid-way + is rolled back by recovery. +- Size/packaging: the CLI bin is separate from the 4 kB library budget, but + `dist/cli` must be included in knip/publint checks and given its own size guard. + +### Channel 4 — standalone binary + +- **Effort:** medium. **Risk:** low-medium. **Value:** medium. +- `bun build --compile` packages the CLI into a single self-contained executable + (embeds the Bun runtime, ~50-90 MB). Cross-compile targets: `linux-x64`, + `linux-arm64`, `darwin-x64`, `darwin-arm64`, `windows-x64`. +- DevOps: a CI matrix builds per target on tag and attaches artifacts to the + GitHub Release with a `SHA256SUMS` file. SLSA provenance / cosign signing is a + later enhancement. Not published to npm or JSR. +- The binary embeds Bun, not Node; the `node:fs` adapter runs unchanged on Bun. + +### Channel 5 — Docker + +- **Effort:** low. **Risk:** low. **Value:** medium. +- A minimal image wrapping the static binary (distroless or `scratch` + a static + linux binary). Volume-mount `.libredb` files. It is a CLI shell, not a server — + honoring the non-goal. +- DevOps: `docker buildx` multi-arch (amd64/arm64), published to GHCR + (`ghcr.io/libredb/libredb`), tagged with the version and `latest`. +- Usage: `docker run -v $PWD:/data ghcr.io/libredb/libredb inspect /data/app.libredb`. + +## 5. Dependency order and phased roadmap + +The real dependency chain differs from the issue's suggested order: Phase 0 (the +node:fs refactor) is the linchpin, and the CLI is a prerequisite for the binary +and Docker. + +``` +Phase 0: node:fs decoupling (browser.ts + exports) <- foundation of everything + | + +- Phase 1: JSR + CDN docs (depends on 0; fastest value) + | + +- Phase 2: CLI (read+write) (depends on 0; independent of browser) + | + +- Phase 3: standalone binary (depends on CLI) + | | + | +- Phase 4: Docker (depends on binary) + | + +- Phase 5: browser persistence (OPFS) (depends on 0+1; optional, last) +``` + +| Phase | Work | Why here | Changeset | +|-------|------|----------|-----------| +| 0 | Decouple `node:fs` -> `adapter/node-fs.ts`; add `src/browser.ts` + `exports` conditions | Linchpin: browser, JSR-cleanliness, and a testable pure core all depend on it | Yes (minor — additive subpath, backward-compatible) | +| 1 | JSR publish + CDN/browser docs | Lowest effort, fastest reach; free once Phase 0 lands | Browser entry shipping: yes; docs-only: no | +| 2 | CLI (read-only adapter for reads + advisory lock for writes) | Prerequisite for CI automation and the binary | Yes (ships a `bin`) | +| 3 | `bun build --compile` cross-compile + GitHub Release matrix | Meaningless without the CLI | No (not in the npm package) | +| 4 | Multi-arch Docker -> GHCR | Wraps the binary | No | +| 5 | OPFS persistence adapter (browser) | Highest architectural risk; after value is proven | Yes (minor) | + +## 6. Risk / effort / value matrix + +| Phase | Effort | Risk | Value | Primary risk item | +|-------|--------|------|-------|-------------------| +| 0 node:fs decouple | Medium | Medium | High | Backward compatibility — Node `open({path})` must behave identically; 100% coverage across two entry points | +| 1 JSR + CDN | Low | Low | High | JSR slow-types / built-in import warnings (likely none); CDN version-pinning hygiene | +| 2 CLI | High | Medium-High | High | Advisory-lock races; read-only recovery suppression; `parseArgs` UX; 100% coverage | +| 3 binary | Medium | Low-Medium | Medium | Cross-compile matrix fragility; ~50-90 MB size expectations; signing/provenance | +| 4 Docker | Low | Low | Medium | Multi-arch buildx; GHCR permissions; preserving "not a server" positioning | +| 5 OPFS persistence | High | High | Medium | Sync access handles are Worker-only; sync contract vs IndexedDB async; browser matrix | + +**Three points needing the most care:** + +1. **Phase 2 — CLI data safety.** The single-writer rule, advisory lock, and + read-only recovery suppression. Done wrong, the CLI can corrupt a live + `.libredb`. The highest "do no harm" risk in the project. +2. **Phase 0 — 100% coverage across two entry points.** The gate holds coverage at + 100%; the node-free browser path and the "path but no fs" error branch must be + fully tested. +3. **Phase 5 — sync/async impedance mismatch.** OPFS-in-Worker vs an async-face + adapter. The doc recommends OPFS-in-Worker and leaves the final decision to the + Phase 5 spec. + +## 7. Execution model (autonomous loop) + +Implementation will run as a six-phase autonomous development loop: + +1. Implement one phase's first step. +2. Wait for GitHub Actions; resolve every problem until all checks are green. +3. Only then advance to the next step/phase. +4. As a software architect, make decisions on ambiguous points and proceed. +5. When everything is complete, do not merge the PR — stop for final human review. diff --git a/jsr.json b/jsr.json new file mode 100644 index 0000000..7332581 --- /dev/null +++ b/jsr.json @@ -0,0 +1,12 @@ +{ + "name": "@libredb/libredb", + "version": "0.1.3", + "exports": { + ".": "./src/index.ts", + "./browser": "./src/browser.ts" + }, + "publish": { + "include": ["src", "README.md", "LICENSE", "jsr.json"], + "exclude": ["src/**/*.test.ts", "src/sim"] + } +} diff --git a/knip.json b/knip.json index 8965218..ff004cf 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/index.ts!", "src/**/*.test.ts"], + "entry": ["src/index.ts!", "src/browser.ts!", "src/cli/main.ts!", "src/**/*.test.ts"], "project": ["src/**/*.ts"], "ignoreDependencies": [ "@secretlint/secretlint-rule-preset-recommend" diff --git a/package.json b/package.json index 069c6e8..50bcd07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@libredb/libredb", - "version": "0.0.4", + "version": "0.1.3", "description": "A small, readable, embeddable, multi-model database. One ordered key-value core, thin model lenses on top.", "keywords": [ "database", @@ -23,6 +23,9 @@ }, "type": "module", "license": "MIT", + "bin": { + "libredb": "./dist/cli/main.js" + }, "files": [ "dist" ], @@ -34,8 +37,16 @@ "types": "./dist/index.d.ts", "exports": { ".": { + "browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js" + }, "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js" } }, "sideEffects": false, @@ -50,6 +61,7 @@ "lint": "oxlint && eslint .", "test": "bun test --coverage", "build": "tsc --project tsconfig.build.json", + "compile": "bun build --compile src/cli/main.ts --outfile libredb", "size": "size-limit", "attw": "rm -rf .attw && bun pm pack --quiet --destination .attw && attw .attw/*.tgz --profile esm-only", "publint": "publint", diff --git a/scripts/sync-version.ts b/scripts/sync-version.ts old mode 100644 new mode 100755 index e5b7203..ee6a154 --- a/scripts/sync-version.ts +++ b/scripts/sync-version.ts @@ -1,8 +1,11 @@ -// Single source of truth for the version is package.json. This rewrites the -// exported `version` constant in src/core.ts to match, so `changeset version` -// (which only touches package.json + CHANGELOG) cannot leave the public API -// reporting a stale version. The "version matches package.json" test in -// core.test.ts is the backstop if this ever fails to run. +// Single source of truth for the version is package.json. This propagates it to +// the places that must agree: the exported `version` constant in src/core.ts (so +// `changeset version`, which only touches package.json + CHANGELOG, cannot leave +// the public API reporting a stale version), the `version` field in jsr.json (the +// JSR manifest, which carries its own version), and the pinned esm.sh example in +// README.md (so the CDN snippet never drifts behind a release). The "version +// matches package.json" tests in core.test.ts are the backstop if this ever +// fails to run. // // Kept OUT of src/core.ts (no runtime package.json read in the guarded core) // and out of the gate; it runs only in the release flow (changeset:version). @@ -10,13 +13,34 @@ import { readFileSync, writeFileSync } from "node:fs"; import pkg from "../package.json" with { type: "json" }; const corePath = new URL("../src/core.ts", import.meta.url); -const pattern = /export const version = "[^"]*";/; -const before = readFileSync(corePath, "utf8"); +const corePattern = /export const version = "[^"]*";/; +const coreBefore = readFileSync(corePath, "utf8"); -if (!pattern.test(before)) { +if (!corePattern.test(coreBefore)) { throw new Error("sync-version: version export not found in src/core.ts"); } -const after = before.replace(pattern, `export const version = "${pkg.version}";`); -writeFileSync(corePath, after); +writeFileSync(corePath, coreBefore.replace(corePattern, `export const version = "${pkg.version}";`)); console.log(`sync-version: src/core.ts version -> ${pkg.version}`); + +const jsrPath = new URL("../jsr.json", import.meta.url); +const jsrPattern = /"version": "[^"]*"/; +const jsrBefore = readFileSync(jsrPath, "utf8"); + +if (!jsrPattern.test(jsrBefore)) { + throw new Error("sync-version: version field not found in jsr.json"); +} + +writeFileSync(jsrPath, jsrBefore.replace(jsrPattern, `"version": "${pkg.version}"`)); +console.log(`sync-version: jsr.json version -> ${pkg.version}`); + +const readmePath = new URL("../README.md", import.meta.url); +const readmePattern = /esm\.sh\/@libredb\/libredb@[\w.-]+/g; +const readmeBefore = readFileSync(readmePath, "utf8"); + +if (!readmeBefore.includes("esm.sh/@libredb/libredb@")) { + throw new Error("sync-version: esm.sh pin not found in README.md"); +} + +writeFileSync(readmePath, readmeBefore.replace(readmePattern, `esm.sh/@libredb/libredb@${pkg.version}`)); +console.log(`sync-version: README.md esm.sh pin -> ${pkg.version}`); diff --git a/sonar-project.properties b/sonar-project.properties index bad1491..8983942 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -19,3 +19,17 @@ sonar.test.inclusions=src/**/*.test.ts,src/sim/** # sonar.javascript.lcov.reportPaths now covers both JS and TS. The report is # produced by `bun test --coverage` (lcov reporter set in bunfig.toml) into coverage/. sonar.javascript.lcov.reportPaths=coverage/lcov.info + +# Suppress tssecurity:S8707 ("CLI argument can escape filesystem restrictions") +# in the storage adapters and the CLI. This is not a vulnerability here by design: +# LibreDB is an embedded, single-process database and `libredb` is its local CLI, +# both of which exist to open the file path their caller names — exactly like +# `sqlite3 ` or `cat `. There is no privilege boundary to cross (the +# operator and the "attacker" are the same local user), and confining access to a +# base directory would defeat the tool's purpose. Scoped to the filesystem-facing +# layers only, so the rule still applies everywhere else. +sonar.issue.ignore.multicriteria=fsadapter,fscli +sonar.issue.ignore.multicriteria.fsadapter.ruleKey=tssecurity:S8707 +sonar.issue.ignore.multicriteria.fsadapter.resourceKey=src/adapter/** +sonar.issue.ignore.multicriteria.fscli.ruleKey=tssecurity:S8707 +sonar.issue.ignore.multicriteria.fscli.resourceKey=src/cli/** diff --git a/src/adapter/node-fs.ts b/src/adapter/node-fs.ts new file mode 100644 index 0000000..8cc91fd --- /dev/null +++ b/src/adapter/node-fs.ts @@ -0,0 +1,52 @@ +/** + * adapter/node-fs.ts — the default {@link FileSystem} for Node and Bun. + * + * This is an edge, not the kernel (DESIGN.md section 5): it holds the one place + * LibreDB touches `node:fs`. The kernel (`core.ts`) is runtime-agnostic and + * imports nothing from `node:`; it reaches the disk only through the injected + * {@link FileSystem} seam. Keeping the node dependency HERE — and out of the + * kernel — is what lets the browser entry (`browser.ts`) ship without dragging + * `node:fs` into its import graph. The default Node entry (`index.ts`) wires this + * adapter in as the default `fs`, so production behaviour is unchanged. + * + * Each method is the obvious synchronous syscall, so the adapter adds an + * interface boundary, not behaviour. Appends go through one append-mode + * descriptor (creating the file if missing); reads, size and truncate work by + * path, matching how the WAL has always reached the disk. + */ +import { closeSync, fsyncSync, openSync, readFileSync, statSync, truncateSync, writeSync } from "node:fs"; + +import type { FileSystem } from "../core.ts"; + +/** Build the default node:fs-backed {@link FileSystem}. */ +export function nodeFileSystem(): FileSystem { + return { + open(path) { + const fd = openSync(path, "a"); // append-only; creates the file if missing + return { + size() { + return statSync(path).size; + }, + read(offset, length) { + // A fresh Uint8Array so the returned slice is an independent copy, not + // a view aliasing a shared Buffer pool. + return new Uint8Array(readFileSync(path)).subarray(offset, offset + length); + }, + append(bytes) { + for (let written = 0; written < bytes.length; ) { + written += writeSync(fd, bytes, written); + } + }, + fsync() { + fsyncSync(fd); + }, + truncate(length) { + truncateSync(path, length); + }, + close() { + closeSync(fd); + }, + }; + }, + }; +} diff --git a/src/adapter/opfs.test.ts b/src/adapter/opfs.test.ts new file mode 100644 index 0000000..67ac8ad --- /dev/null +++ b/src/adapter/opfs.test.ts @@ -0,0 +1,123 @@ +/** + * opfs.test.ts — the OPFS filesystem adapter for the browser. + * + * OPFS sync access handles expose synchronous read/write/getSize/truncate/flush/ + * close, which map exactly onto the kernel's synchronous FileSystem seam — so a + * LibreDB database can be durable in a browser (inside a Worker, where sync + * handles live) with no async core. The handle is injected, so these tests drive + * the adapter against an in-memory fake that mimics a FileSystemSyncAccessHandle, + * proving the mapping and full crash/reopen durability without a real browser. + */ +import { expect, test } from "bun:test"; + +import { open } from "../core.ts"; +import { opfsFileSystem, type SyncAccessHandle } from "./opfs.ts"; + +/** Backing bytes shared across handles opened on the "same file", so a reopen + * sees what a prior handle committed (close does not erase). */ +const makeBacking = (): { bytes: Uint8Array } => ({ bytes: new Uint8Array(0) }); + +/** An in-memory stand-in for a FileSystemSyncAccessHandle over `backing`. */ +const fakeHandle = (backing: { bytes: Uint8Array }): SyncAccessHandle => ({ + getSize() { + return backing.bytes.length; + }, + read(buffer, options) { + const at = options?.at ?? 0; + const slice = backing.bytes.subarray(at, at + buffer.length); + buffer.set(slice); + return slice.length; + }, + write(buffer, options) { + const at = options?.at ?? 0; + if (at + buffer.length > backing.bytes.length) { + const grown = new Uint8Array(at + buffer.length); + grown.set(backing.bytes); + backing.bytes = grown; + } + backing.bytes.set(buffer, at); + return buffer.length; + }, + truncate(newSize) { + backing.bytes = backing.bytes.slice(0, newSize); + }, + flush() { + // no durability model in the fake; the kernel calls this as its commit point + }, + close() { + // a handle close does not erase the backing bytes + }, +}); + +const bytes = (...b: number[]): Uint8Array => new Uint8Array(b); + +test("a database runs durably on an OPFS sync access handle", () => { + const backing = makeBacking(); + const db = open({ path: "db", fs: opfsFileSystem(fakeHandle(backing)) }); + db.transact((tx) => tx.set(bytes(1), bytes(10))); + db.close(); + + // The commit landed in the handle's backing store. + expect(backing.bytes.length).toBeGreaterThan(0); +}); + +test("committed state recovers when a fresh handle reopens the same backing", () => { + const backing = makeBacking(); + const first = open({ path: "db", fs: opfsFileSystem(fakeHandle(backing)) }); + first.transact((tx) => { + tx.set(bytes(1), bytes(10)); + tx.set(bytes(2), bytes(20)); + }); + first.close(); + + // A new handle over the same backing is the browser "reopen" path. + const second = open({ path: "db", fs: opfsFileSystem(fakeHandle(backing)) }); + expect(second.transact((tx) => tx.get(bytes(2)))).toEqual(bytes(20)); + expect(second.transact((tx) => tx.get(bytes(1)))).toEqual(bytes(10)); + second.close(); +}); + +test("append loops until every byte is written, even on a short write", () => { + const backing = makeBacking(); + const real = fakeHandle(backing); + let firstWrite = true; + // A handle whose first write lands only one byte, like a real short write. + const shortWriter: SyncAccessHandle = { + ...real, + write(buffer, options) { + if (firstWrite && buffer.length > 1) { + firstWrite = false; + return real.write(buffer.subarray(0, 1), options); + } + return real.write(buffer, options); + }, + }; + const db = open({ path: "db", fs: opfsFileSystem(shortWriter) }); + db.transact((tx) => tx.set(bytes(1), bytes(10))); + db.close(); + + // Despite the short first write, the whole record landed and recovers intact. + const reopened = open({ path: "db", fs: opfsFileSystem(fakeHandle(backing)) }); + expect(reopened.transact((tx) => tx.get(bytes(1)))).toEqual(bytes(10)); + reopened.close(); +}); + +test("recovery truncates a torn tail through the handle", () => { + const backing = makeBacking(); + const first = open({ path: "db", fs: opfsFileSystem(fakeHandle(backing)) }); + first.transact((tx) => tx.set(bytes(1), bytes(10))); + first.close(); + const committedSize = backing.bytes.length; + + // Simulate a crash mid-append: tack a partial record onto the backing bytes. + const torn = new Uint8Array(committedSize + 5); + torn.set(backing.bytes); + torn.set([0xff, 0xff, 0xff, 0xff, 1], committedSize); + backing.bytes = torn; + + // Reopen: recovery drops the torn tail via handle.truncate, restoring the size. + const second = open({ path: "db", fs: opfsFileSystem(fakeHandle(backing)) }); + expect(second.transact((tx) => tx.get(bytes(1)))).toEqual(bytes(10)); + expect(backing.bytes.length).toBe(committedSize); + second.close(); +}); diff --git a/src/adapter/opfs.ts b/src/adapter/opfs.ts new file mode 100644 index 0000000..c79bd8a --- /dev/null +++ b/src/adapter/opfs.ts @@ -0,0 +1,81 @@ +/** + * adapter/opfs.ts — an OPFS-backed {@link FileSystem} for the browser. + * + * An edge, not the kernel: it carries no durability logic, only the mapping from + * the kernel's synchronous {@link FileSystem} seam onto an OPFS sync access + * handle. The point is that a `FileSystemSyncAccessHandle` exposes SYNCHRONOUS + * read/write/getSize/truncate/flush/close — the exact shape the kernel's WAL + * needs — so LibreDB can be durable in a browser with no async core. + * + * Sync access handles only exist inside a Web Worker, and obtaining one is async + * (navigator.storage.getDirectory -> getFileHandle -> createSyncAccessHandle). + * That async acquisition is the caller's, done once before {@link + * import("../core.ts").open}; this adapter takes the already-open handle and + * wraps it synchronously, keeping `open` synchronous. Typical use in a Worker: + * + * const root = await navigator.storage.getDirectory(); + * const file = await root.getFileHandle("app.libredb", { create: true }); + * const handle = await file.createSyncAccessHandle(); + * const db = open({ path: "app.libredb", fs: opfsFileSystem(handle) }); + * + * The handle is bound to one file, so the kernel's `path` is unused here — the + * database opened with this adapter is the file the handle was created for. + */ +import type { FileSystem } from "../core.ts"; + +/** + * The synchronous subset of a browser `FileSystemSyncAccessHandle` the WAL uses. + * Declared locally so the package needs no DOM lib types; a real sync access + * handle satisfies it structurally. + */ +export interface SyncAccessHandle { + /** Read into `buffer` starting at `options.at` (default 0); returns bytes read. */ + read(buffer: Uint8Array, options?: { at?: number }): number; + /** Write `buffer` starting at `options.at` (default 0); returns bytes written. */ + write(buffer: Uint8Array, options?: { at?: number }): number; + /** The file's current size in bytes. */ + getSize(): number; + /** Resize the file to `newSize`, dropping anything beyond it. */ + truncate(newSize: number): void; + /** Persist buffered writes to storage. */ + flush(): void; + /** Release the handle. */ + close(): void; +} + +/** Build a {@link FileSystem} backed by an open OPFS sync access `handle`. */ +export function opfsFileSystem(handle: SyncAccessHandle): FileSystem { + return { + open() { + return { + size() { + return handle.getSize(); + }, + read(offset, length) { + const buffer = new Uint8Array(length); + const read = handle.read(buffer, { at: offset }); + return buffer.subarray(0, read); + }, + append(data) { + // write() may write fewer bytes than asked (hence the returned count), + // so loop until the whole record is on the handle — otherwise a short + // write would persist a torn record while fsync reports success. This + // mirrors the node-fs adapter's writeSync loop. + const base = handle.getSize(); + for (let written = 0; written < data.length; ) { + written += handle.write(data.subarray(written), { at: base + written }); + } + }, + fsync() { + handle.flush(); + }, + truncate(length) { + handle.truncate(length); + }, + close() { + handle.close(); + }, + }; + }, + }; +} diff --git a/src/browser.test.ts b/src/browser.test.ts new file mode 100644 index 0000000..6ed9070 --- /dev/null +++ b/src/browser.test.ts @@ -0,0 +1,133 @@ +/** + * browser.test.ts — the browser entry point (Phase 0 of the distribution-channels + * work; see docs/superpowers/specs/2026-06-29-distribution-channels-design.md). + * + * The browser entry (`@libredb/libredb/browser`) is the same lens surface as the + * default Node entry, but its `open` carries NO default `node:fs` filesystem. + * In-memory databases work out of the box; a path-backed open requires an + * injected filesystem. The defining guarantee is that importing this entry drags + * NOTHING from `node:` into the import graph, so a bundler can ship it to a + * browser. These tests pin both the runtime behaviour and that static guarantee. + */ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { expect, test } from "bun:test"; + +import { + type BrowserOpenOptions, + catalog, + CATALOG_PREFIX, + doc, + isReservedKey, + kv, + open, + RESERVED_MARKER, + table, + version, +} from "./browser.ts"; + +test("an in-memory database works through the browser entry", () => { + const db = open(); + const store = kv(db); + store.set("greeting", "hello"); + expect(store.get("greeting")).toBe("hello"); + db.close(); +}); + +test("a path-backed open with no fs still throws at runtime (JS callers)", () => { + // BrowserOpenOptions makes this a compile error for TS users, but a JS + // consumer can still reach it — the kernel's runtime guard is the safety net. + const openUntyped = open as (options: unknown) => unknown; + expect(() => openUntyped({ path: "should-not-be-created" })).toThrow(/filesystem/i); +}); + +test("BrowserOpenOptions requires fs whenever a path is given (compile-time)", () => { + // Locks the type-level guarantee: if this assignment ever stops being a type + // error, the directive below becomes unused and the gate fails. No runtime call. + // @ts-expect-error a path-backed open must also provide fs + const pathWithoutFs: BrowserOpenOptions = { path: "x" }; + expect(pathWithoutFs).toBeDefined(); +}); + +test("the browser entry exposes the full lens surface", () => { + expect(typeof version).toBe("string"); + const db = open(); + + // kv lens + kv(db).set("k", "v"); + // document lens + doc(db, "people").put("ada", { name: "ada" }); + // relational lens + const users = table(db, "users", { + primaryKey: "id", + columns: { id: "string", name: "string" }, + }); + users.insert({ id: "u1", name: "ada" }); + // catalog + reserved-key helpers + expect(catalog(db).size).toBeGreaterThan(0); + expect(isReservedKey(`${CATALOG_PREFIX}users`)).toBe(true); + expect(isReservedKey("plain")).toBe(false); + expect(RESERVED_MARKER.length).toBe(1); + + db.close(); +}); + +const transpiler = new Bun.Transpiler({ loader: "ts" }); + +/** + * Every module specifier `source` imports, across ALL forms the build can emit: + * static, side-effect (`import "x"`), dynamic (`import("x")`) and re-export + * (`export ... from "x"`). Type-only imports are erased and correctly excluded. + * Uses Bun's transpiler instead of a regex precisely so the guarantee below + * cannot be defeated by an import form a regex would miss. + */ +const importsOf = (source: string): string[] => transpiler.scanImports(source).map((i) => i.path); + +/** + * The bare (non-relative) module specifiers reachable from `entry` through the + * real runtime import graph. Relative specifiers are followed; the repo + * convention (enforced by the build's import-extension rewriting) is that they + * are explicit `.ts` paths, so a relative spec resolves directly to a file. + * This walks the source graph, proving what a bundler would pull in without + * depending on a bundler's tree-shaking quirks. + */ +const transitiveBareSpecifiers = (entry: string): Set => { + const seen = new Set(); + const bare = new Set(); + const visit = (file: string): void => { + if (seen.has(file)) return; + seen.add(file); + for (const spec of importsOf(readFileSync(file, "utf8"))) { + if (spec.startsWith(".")) visit(resolve(dirname(file), spec)); + else bare.add(spec); + } + }; + visit(entry); + return bare; +}; + +test("the import-graph scanner sees every import form (no blind spots)", () => { + // The guarantee below is only as strong as the scanner. Pin that it catches + // the forms a regex would miss, so a future `import "node:fs"` or + // `import("node:fs")` anywhere in the browser graph cannot pass silently. + expect(importsOf('import "node:fs";')).toContain("node:fs"); + expect(importsOf('const p = import("node:fs");')).toContain("node:fs"); + expect(importsOf('export * from "node:fs";')).toContain("node:fs"); + // Type-only imports are erased and must NOT count: they never reach a bundle. + expect(importsOf('import type { X } from "node:fs";')).not.toContain("node:fs"); +}); + +test("the browser entry's import graph contains no node: builtins", () => { + const specifiers = [...transitiveBareSpecifiers(resolve(import.meta.dir, "browser.ts"))]; + const nodeBuiltins = specifiers.filter((s) => s.startsWith("node:")); + expect(nodeBuiltins).toEqual([]); +}); + +test("the node entry's import graph DOES pull in node:fs (the walker discriminates)", () => { + // Guards the test above from silently becoming a tautology: the default Node + // entry legitimately reaches node:fs through the node-fs adapter, and the + // browser entry must not. If this ever stops being true, the boundary moved. + const specifiers = transitiveBareSpecifiers(resolve(import.meta.dir, "index.ts")); + expect(specifiers.has("node:fs")).toBe(true); +}); diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..a9496d4 --- /dev/null +++ b/src/browser.ts @@ -0,0 +1,60 @@ +/** + * browser.ts — the browser entry point of the LibreDB npm package + * (`@libredb/libredb/browser`). + * + * Same lens surface as the default Node entry ({@link import("./index.ts")}), + * with one difference: `open` carries NO default filesystem. An in-memory + * database (`open()`) works anywhere; a path-backed open requires an injected + * `fs` (e.g. the bundled {@link opfsFileSystem}). Here that requirement is in + * the TYPE — {@link BrowserOpenOptions} makes `fs` mandatory when `path` is + * given, so misuse is a compile error rather than a runtime throw. The point of + * this entry is the import graph: it reaches nothing in `node:`, so a bundler + * can ship it to a browser. The node:fs adapter lives behind Node only. + */ +import { open as openKernel, type Database, type FileSystem } from "./core.ts"; + +export { version } from "./core.ts"; +// OpenOptions (the kernel's permissive type, fs optional) is intentionally NOT +// re-exported here: the browser `open` is typed with BrowserOpenOptions, where fs +// is required alongside a path, so exposing OpenOptions would advertise a +// contract this entry does not honor. FileSystem/WalFile stay — a browser +// consumer needs them to implement a custom fs (e.g. the OPFS adapter). +export type { Database, FileSystem, WalFile } from "./core.ts"; + +/** + * Options for the browser {@link open}. Unlike the kernel's permissive + * `OpenOptions`, `fs` is REQUIRED whenever `path` is present, because the browser + * entry has no default filesystem — so a path-backed open without an `fs` fails + * to compile here instead of throwing at runtime. An in-memory open (no `path`) + * needs no filesystem. + */ +export type BrowserOpenOptions = + | { readonly path: string; readonly fs: FileSystem } + | { readonly path?: never; readonly fs?: FileSystem }; + +/** + * Open a database in the browser. The same runtime as the kernel's `open`, typed + * so a path-backed open requires an injected filesystem (e.g. + * {@link opfsFileSystem}). Assigning the kernel's wider-typed `open` here is + * sound by parameter contravariance, so the kernel itself stays unchanged. + */ +export const open: (options?: BrowserOpenOptions) => Database = openKernel; + +// OPFS persistence (browser-only): wrap an OPFS sync access handle as the +// filesystem for a path-backed open. See adapter/opfs.ts for usage in a Worker. +export { opfsFileSystem } from "./adapter/opfs.ts"; +export type { SyncAccessHandle } from "./adapter/opfs.ts"; + +export { kv } from "./lens/kv.ts"; +export type { Kv, KvEntry } from "./lens/kv.ts"; + +export { doc } from "./lens/document.ts"; +export type { DocCollection, Doc, DocEntry, JsonValue } from "./lens/document.ts"; + +export { table } from "./lens/relational.ts"; +export type { Table, TableSchema, Row, ColumnType, Query } from "./lens/relational.ts"; + +export { catalog, isReservedKey, CATALOG_PREFIX, RESERVED_MARKER } from "./lens/catalog.ts"; +export type { CatalogEntry, CatalogRegistry } from "./lens/catalog.ts"; + +export type { Result, WriteResult } from "./lens/types.ts"; diff --git a/src/cli/lock.test.ts b/src/cli/lock.test.ts new file mode 100644 index 0000000..d6eb9c7 --- /dev/null +++ b/src/cli/lock.test.ts @@ -0,0 +1,87 @@ +/** + * lock.test.ts — the CLI's advisory write lock. + * + * LibreDB is single-process with no file locking of its own, so two concurrent + * writers would corrupt a file. Write commands take an advisory `.lock` + * to make a second writer fail loudly instead. The lock is advisory, not a + * kernel guarantee: `--force` overrides a stale one. + */ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test } from "bun:test"; + +import { acquireLock } from "./lock.ts"; + +const dirs: string[] = []; +const tempPath = (): string => { + const dir = mkdtempSync(join(tmpdir(), "libredb-lock-")); + dirs.push(dir); + return join(dir, "db"); +}; + +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop() as string, { recursive: true, force: true }); +}); + +test("acquires a lock file and releases it", () => { + const path = tempPath(); + const lock = acquireLock(path, false); + expect(existsSync(`${path}.lock`)).toBe(true); + lock.release(); + expect(existsSync(`${path}.lock`)).toBe(false); +}); + +test("refuses when a lock is already held", () => { + const path = tempPath(); + writeFileSync(`${path}.lock`, ""); // another writer holds it + expect(() => acquireLock(path, false)).toThrow(/locked/i); +}); + +test("--force drops a real libredb lock and re-acquires", () => { + const path = tempPath(); + acquireLock(path, false); // a prior writer's lock, left behind (e.g. it crashed) + const lock = acquireLock(path, true); // force clears it and takes a fresh one + expect(existsSync(`${path}.lock`)).toBe(true); + lock.release(); + expect(existsSync(`${path}.lock`)).toBe(false); +}); + +test("--force with no existing lock simply acquires", () => { + const path = tempPath(); + const lock = acquireLock(path, true); + expect(existsSync(`${path}.lock`)).toBe(true); + lock.release(); +}); + +test("a non-lock IO error is surfaced, not misreported as locked", () => { + // A lock path inside a directory that does not exist fails with ENOENT; that + // must not be reported as "locked" (which would send the user down --force). + const bogus = join(tmpdir(), "libredb-no-such-dir-xyz", "db"); + let caught: unknown; + try { + acquireLock(bogus, false); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).not.toMatch(/locked/i); +}); + +test("--force clears an empty stray lock (e.g. a crash before the sentinel was written)", () => { + const path = tempPath(); + writeFileSync(`${path}.lock`, ""); // empty stray, not foreign user data + const lock = acquireLock(path, true); + expect(existsSync(`${path}.lock`)).toBe(true); + lock.release(); + expect(existsSync(`${path}.lock`)).toBe(false); +}); + +test("--force refuses to delete a file that is not a libredb lock", () => { + const path = tempPath(); + writeFileSync(`${path}.lock`, "this is the user's own data, not a lock"); + expect(() => acquireLock(path, true)).toThrow(/not a libredb lock/i); + // The user's file is left intact. + expect(existsSync(`${path}.lock`)).toBe(true); +}); diff --git a/src/cli/lock.ts b/src/cli/lock.ts new file mode 100644 index 0000000..32b4547 --- /dev/null +++ b/src/cli/lock.ts @@ -0,0 +1,67 @@ +/** + * cli/lock.ts — an advisory write lock for the CLI. + * + * LibreDB is single-process and does no file locking itself, so two writers to + * one file would corrupt it. Write commands take a `.lock` first: an + * exclusive create that fails if the file already exists, turning a concurrent + * writer into a loud error instead of silent corruption. It is advisory only — + * `--force` drops a stale lock and proceeds. The lock is always released after + * the write (see withWriteDb in run.ts). + */ +import { closeSync, existsSync, openSync, readFileSync, rmSync, writeSync } from "node:fs"; + +/** A held lock. Call {@link Lock.release} once the write is done. */ +export interface Lock { + release(): void; +} + +// Written into every lock file so a forced acquire can tell a real libredb lock +// from an unrelated file that merely happens to be named .lock, and refuse +// to delete the latter. +const SENTINEL = "libredb-lock\n"; + +/** Acquire the advisory lock for `path`. With `force`, an existing libredb lock + * is dropped first; otherwise an existing lock makes this throw. */ +export function acquireLock(path: string, force: boolean): Lock { + const lockPath = `${path}.lock`; + if (force) dropOwnLock(lockPath); + try { + const fd = openSync(lockPath, "wx"); // "wx": exclusive create, fails if it exists + try { + writeSync(fd, SENTINEL); + } finally { + closeSync(fd); // always release the descriptor, even if the write throws + } + } catch (error) { + // A failed sentinel write (e.g. ENOSPC) leaves an EMPTY .lock behind; + // that stray is recoverable because dropOwnLock treats an empty file as ours. + // Only an existing lock file (EEXIST) means "locked". Surface any other IO + // error (missing directory, permissions, ...) unchanged, so a real problem + // is not misreported as a held lock. + if ((error as { code?: string }).code !== "EEXIST") throw error; + throw new Error( + `libredb: ${path} is locked (${lockPath}); another writer may be active — use --force to override`, + { cause: error }, + ); + } + return { + release() { + rmSync(lockPath, { force: true }); + }, + }; +} + +/** Remove an existing libredb lock so a forced acquire can proceed. A lock this + * tool created carries the SENTINEL; an empty file is a stray from a crash or a + * failed sentinel write (also ours) and is safe to drop. Any other content means + * the file is not our lock, so `--force` refuses it rather than delete unrelated + * user data that happens to share the `.lock` name. A read error other than "no + * such file" (e.g. EACCES) propagates instead of being silently swallowed. */ +function dropOwnLock(lockPath: string): void { + if (!existsSync(lockPath)) return; // nothing to drop + const contents = readFileSync(lockPath, "utf8"); + if (contents !== "" && !contents.startsWith(SENTINEL)) { + throw new Error(`libredb: refusing to remove ${lockPath} with --force: not a libredb lock file`); + } + rmSync(lockPath, { force: true }); +} diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts new file mode 100644 index 0000000..2e0dbd4 --- /dev/null +++ b/src/cli/main.test.ts @@ -0,0 +1,44 @@ +/** + * main.test.ts — an end-to-end smoke test of the `libredb` bin shim. + * + * main.ts is pure process glue and excluded from line coverage; this test proves + * the glue actually works by running the source entry as a real subprocess and + * checking that argv, stdout/stderr and the exit code are wired to run(). + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test } from "bun:test"; + +import { open } from "../index.ts"; +import { kv } from "../lens/kv.ts"; + +const dirs: string[] = []; +const main = join(import.meta.dir, "main.ts"); + +const fixture = (): string => { + const dir = mkdtempSync(join(tmpdir(), "libredb-bin-")); + dirs.push(dir); + const path = join(dir, "app.libredb"); + const db = open({ path }); + kv(db).set("k", "v"); + db.close(); + return path; +}; + +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop() as string, { recursive: true, force: true }); +}); + +test("the bin runs end-to-end and prints a value with exit code 0", () => { + const proc = Bun.spawnSync(["bun", main, "get", fixture(), "k"]); + expect(proc.exitCode).toBe(0); + expect(proc.stdout.toString().trim()).toBe("v"); +}); + +test("the bin sets a non-zero exit code and writes to stderr on error", () => { + const proc = Bun.spawnSync(["bun", main, "get", fixture(), "absent"]); + expect(proc.exitCode).toBe(1); + expect(proc.stderr.toString()).toMatch(/not found/i); +}); diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100644 index 0000000..2089206 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** + * cli/main.ts — the `libredb` bin shim. + * + * The only file that touches the real process: it wires process argv/stdout/ + * stderr/exit code to the pure {@link run}. All behaviour lives in run.ts and is + * tested there; this glue is excluded from coverage (see bunfig.toml) because it + * cannot be exercised without spawning a process, and a behavioural smoke test + * (main.test.ts) runs the bin end-to-end instead. + */ +import { run } from "./run.ts"; + +process.exitCode = run(process.argv.slice(2), { + out: (line) => process.stdout.write(`${line}\n`), + err: (line) => process.stderr.write(`${line}\n`), +}); diff --git a/src/cli/readonly-fs.test.ts b/src/cli/readonly-fs.test.ts new file mode 100644 index 0000000..1478234 --- /dev/null +++ b/src/cli/readonly-fs.test.ts @@ -0,0 +1,59 @@ +/** + * readonly-fs.test.ts — the CLI's read-only filesystem adapter. + * + * Inspection commands (inspect/get/scan/stats) must never mutate the file they + * read. That matters because open() runs recovery, which would truncate a torn + * tail and so write to a "read-only" target. This adapter satisfies the kernel's + * FileSystem seam for reads only: size and read work; append and fsync refuse; + * truncate is a deliberate no-op so recovery can drop a torn tail in memory + * while the file on disk is left exactly as it was found. + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test } from "bun:test"; + +import { readonlyFileSystem } from "./readonly-fs.ts"; + +const dirs: string[] = []; +const tempFile = (contents: Uint8Array): string => { + const dir = mkdtempSync(join(tmpdir(), "libredb-ro-")); + dirs.push(dir); + const path = join(dir, "db"); + writeFileSync(path, contents); + return path; +}; + +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop() as string, { recursive: true, force: true }); +}); + +test("size and read reflect the file on disk", () => { + const path = tempFile(new Uint8Array([1, 2, 3, 4, 5])); + const file = readonlyFileSystem().open(path); + expect(file.size()).toBe(5); + expect(file.read(1, 3)).toEqual(new Uint8Array([2, 3, 4])); + file.close(); +}); + +test("append and fsync refuse, so a read can never write", () => { + const path = tempFile(new Uint8Array([1])); + const file = readonlyFileSystem().open(path); + expect(() => file.append(new Uint8Array([9]))).toThrow(/read-only/i); + expect(() => file.fsync()).toThrow(/read-only/i); + file.close(); +}); + +test("truncate is a no-op: the file on disk is left untouched", () => { + const original = new Uint8Array([1, 2, 3, 4]); + const path = tempFile(original); + const file = readonlyFileSystem().open(path); + file.truncate(2); // recovery dropping a torn tail must not reach the disk here + file.close(); + expect(new Uint8Array(readFileSync(path))).toEqual(original); +}); + +test("opening an absent file throws", () => { + expect(() => readonlyFileSystem().open(join(tmpdir(), "libredb-does-not-exist-xyz"))).toThrow(); +}); diff --git a/src/cli/readonly-fs.ts b/src/cli/readonly-fs.ts new file mode 100644 index 0000000..12b85d7 --- /dev/null +++ b/src/cli/readonly-fs.ts @@ -0,0 +1,44 @@ +/** + * cli/readonly-fs.ts — a read-only {@link FileSystem} for the inspection CLI. + * + * An edge, not the kernel: it carries no durability logic, only the node:fs + * calls a read needs. Inspection commands open a database purely to read it, but + * open() runs recovery, which truncates a torn tail — a write. This adapter makes + * that impossible: `append` and `fsync` refuse, and `truncate` is a no-op, so a + * crash-interrupted file is recovered correctly IN MEMORY (the torn tail is + * dropped from the returned entries) while the bytes on disk are left exactly as + * found. `size` and `read` are the obvious read syscalls. + */ +import { closeSync, openSync, readFileSync, statSync } from "node:fs"; + +import type { FileSystem } from "../core.ts"; + +/** Build a read-only {@link FileSystem} over `node:fs`. */ +export function readonlyFileSystem(): FileSystem { + return { + open(path) { + const fd = openSync(path, "r"); // read-only; throws if the file is absent + return { + size() { + return statSync(path).size; + }, + read(offset, length) { + return new Uint8Array(readFileSync(path)).subarray(offset, offset + length); + }, + append() { + throw new Error("libredb: read-only database; refusing to write"); + }, + fsync() { + throw new Error("libredb: read-only database; refusing to write"); + }, + truncate() { + // Deliberate no-op: a read must not alter the file. Recovery still + // drops a torn tail from the in-memory state; the disk is untouched. + }, + close() { + closeSync(fd); + }, + }; + }, + }; +} diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts new file mode 100644 index 0000000..23b3e7f --- /dev/null +++ b/src/cli/run.test.ts @@ -0,0 +1,291 @@ +/** + * run.test.ts — the CLI dispatcher and its read commands. + * + * run(argv, io) is the whole CLI as a pure function: it takes an argument vector + * and an IO sink and returns an exit code, so every command and error path is + * testable without spawning a process. These cover the read commands (inspect, + * stats, get, scan) against real .libredb files, plus usage and error handling. + */ +import { appendFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test } from "bun:test"; + +import { open } from "../index.ts"; +import { doc } from "../lens/document.ts"; +import { kv } from "../lens/kv.ts"; +import { table } from "../lens/relational.ts"; +import { acquireLock } from "./lock.ts"; +import { run } from "./run.ts"; + +const dirs: string[] = []; + +/** Build a real .libredb fixture with one kv pair, a document, and a table. */ +const fixture = (): string => { + const dir = mkdtempSync(join(tmpdir(), "libredb-cli-")); + dirs.push(dir); + const path = join(dir, "app.libredb"); + const db = open({ path }); + kv(db).set("user:1", "Ada"); + kv(db).set("user:2", "Grace"); + doc(db, "logs").put("l1", { message: "hi" }); + table(db, "people", { primaryKey: "id", columns: { id: "string", name: "string" } }); + db.close(); + return path; +}; + +const missing = (): string => join(tmpdir(), "libredb-absent-xyz", "nope.libredb"); + +/** Run the CLI, collecting stdout/stderr lines and the exit code. */ +const cli = (...argv: string[]): { code: number; out: string[]; err: string[] } => { + const out: string[] = []; + const err: string[] = []; + const code = run(argv, { out: (s) => out.push(s), err: (s) => err.push(s) }); + return { code, out, err }; +}; + +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop() as string, { recursive: true, force: true }); +}); + +test("no command prints usage and succeeds", () => { + const r = cli(); + expect(r.code).toBe(0); + expect(r.out.join("\n")).toMatch(/usage/i); +}); + +test("--help prints usage and succeeds", () => { + const r = cli("--help"); + expect(r.code).toBe(0); + expect(r.out.join("\n")).toMatch(/usage/i); +}); + +test("an unknown option is a usage error", () => { + const r = cli("inspect", "x", "--bogus"); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/option/i); +}); + +test("an unknown command is a usage error", () => { + const r = cli("frobnicate", "x"); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/unknown command/i); +}); + +test("an inherited object property is not a command (no prototype pollution)", () => { + // "toString"/"__proto__" resolve to Object.prototype on a plain object; the + // dispatch Map must reject them like any other unknown command. + for (const name of ["toString", "constructor", "__proto__"]) { + const r = cli(name, "x"); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/unknown command/i); + } +}); + +test("a command with no path is a usage error", () => { + const r = cli("inspect"); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/path/i); +}); + +test("inspect lists each namespace with its kind and a table schema", () => { + const r = cli("inspect", fixture()); + expect(r.code).toBe(0); + const text = r.out.join("\n"); + expect(text).toMatch(/logs\s+document/); + expect(text).toMatch(/people\s+relational/); + expect(text).toMatch(/primaryKey/); // the table's schema is shown +}); + +test("inspect on a file with no catalogued namespaces says so", () => { + const dir = mkdtempSync(join(tmpdir(), "libredb-cli-")); + dirs.push(dir); + const path = join(dir, "empty.libredb"); + open({ path }).close(); // a valid but empty database + const r = cli("inspect", path); + expect(r.code).toBe(0); + expect(r.out.join("\n")).toMatch(/no .*namespaces/i); +}); + +test("stats summarizes file size and namespace counts by kind", () => { + const r = cli("stats", fixture()); + expect(r.code).toBe(0); + const text = r.out.join("\n"); + expect(text).toMatch(/bytes/); + expect(text).toMatch(/document: 1/); + expect(text).toMatch(/relational: 1/); +}); + +test("get prints the value at a key", () => { + const r = cli("get", fixture(), "user:1"); + expect(r.code).toBe(0); + expect(r.out).toEqual(["Ada"]); +}); + +test("get on a missing key fails with a clear error", () => { + const r = cli("get", fixture(), "user:404"); + expect(r.code).toBe(1); + expect(r.err.join("\n")).toMatch(/not found/i); +}); + +test("get with no key is a usage error", () => { + const r = cli("get", fixture()); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/key/i); +}); + +test("scan prints key=value for every key under a prefix", () => { + const r = cli("scan", fixture(), "user:"); + expect(r.code).toBe(0); + expect(r.out).toEqual(["user:1=Ada", "user:2=Grace"]); +}); + +test("scan with no prefix is a usage error", () => { + const r = cli("scan", fixture()); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/prefix/i); +}); + +test("a read command on an absent file fails cleanly (exit 1)", () => { + const r = cli("inspect", missing()); + expect(r.code).toBe(1); + expect(r.err.length).toBeGreaterThan(0); +}); + +test("reading never mutates the file (read-only open)", () => { + const path = fixture(); + const before = Bun.file(path).size; + cli("inspect", path); + cli("get", path, "user:1"); + cli("scan", path, "user:"); + cli("stats", path); + expect(Bun.file(path).size).toBe(before); +}); + +test("set writes a value that get reads back, and releases the lock", () => { + const path = fixture(); + expect(cli("set", path, "color", "teal").code).toBe(0); + expect(cli("get", path, "color").out).toEqual(["teal"]); + expect(existsSync(`${path}.lock`)).toBe(false); // lock released after the write +}); + +test("set with no value is a usage error", () => { + const r = cli("set", fixture(), "k"); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/value/i); +}); + +test("delete removes an existing key, reporting one removed", () => { + const path = fixture(); + const r = cli("delete", path, "user:1"); + expect(r.code).toBe(0); + expect(r.out.join("\n")).toMatch(/1 removed/); + expect(cli("get", path, "user:1").code).toBe(1); // gone +}); + +test("delete of an absent key succeeds and reports zero removed", () => { + const r = cli("delete", fixture(), "user:404"); + expect(r.code).toBe(0); + expect(r.out.join("\n")).toMatch(/0 removed/); +}); + +test("delete with no key is a usage error", () => { + const r = cli("delete", fixture()); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/key/i); +}); + +/** Write a JSON file next to the database and return its path. */ +const jsonFile = (dbPath: string, contents: unknown): string => { + const file = `${dbPath}.import.json`; + writeFileSync(file, JSON.stringify(contents)); + return file; +}; + +test("import bulk-sets keys that get reads back", () => { + const path = fixture(); + const file = jsonFile(path, { a: "1", b: "2", c: "3" }); + const r = cli("import", path, file); + expect(r.code).toBe(0); + expect(r.out.join("\n")).toMatch(/import 3 keys/); + expect(cli("get", path, "a").out).toEqual(["1"]); + expect(cli("get", path, "c").out).toEqual(["3"]); +}); + +test("import rejects a non-object JSON payload", () => { + const path = fixture(); + const r = cli("import", path, jsonFile(path, [1, 2, 3])); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/object of string values/i); +}); + +test("import rejects a non-string value", () => { + const path = fixture(); + const r = cli("import", path, jsonFile(path, { a: "ok", b: 5 })); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/object of string values/i); +}); + +test("import with no file is a usage error", () => { + const r = cli("import", fixture()); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/file/i); +}); + +test("import rejects malformed JSON as a usage error (exit 2)", () => { + const path = fixture(); + const file = `${path}.bad.json`; + writeFileSync(file, "{ not valid json"); + const r = cli("import", path, file); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/json/i); +}); + +test("a write refuses when the database is locked", () => { + const path = fixture(); + writeFileSync(`${path}.lock`, ""); // another writer holds the lock + const r = cli("set", path, "k", "v"); + expect(r.code).toBe(1); + expect(r.err.join("\n")).toMatch(/locked/i); +}); + +test("--force overrides a stale libredb lock", () => { + const path = fixture(); + acquireLock(path, false); // a prior writer's lock, left behind (e.g. it crashed) + const r = cli("set", path, "k", "v", "--force"); + expect(r.code).toBe(0); + expect(cli("get", path, "k").out).toEqual(["v"]); +}); + +test("set refuses to write a reserved key", () => { + const r = cli("set", fixture(), "\u0000libredb:catalog:people", "x"); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/reserved key/i); +}); + +test("delete refuses a reserved key so it cannot corrupt the catalog", () => { + const r = cli("delete", fixture(), "\u0000libredb:catalog:people"); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/reserved key/i); +}); + +test("import refuses a reserved key so it cannot corrupt the catalog", () => { + const path = fixture(); + const file = jsonFile(path, { ok: "1", "\u0000libredb:catalog:people": "evil" }); + const r = cli("import", path, file); + expect(r.code).toBe(2); + expect(r.err.join("\n")).toMatch(/reserved key/i); +}); + +test("a read recovers a crash-torn file in memory without changing the bytes on disk", () => { + const path = fixture(); + // Simulate a crash mid-append: tack a partial/garbage record onto the WAL. + appendFileSync(path, Buffer.from([0xff, 0xff, 0xff, 0xff, 1, 2, 3])); + const sizeBefore = Bun.file(path).size; + const r = cli("get", path, "user:1"); // reads the intact committed prefix + expect(r.code).toBe(0); + expect(r.out).toEqual(["Ada"]); + // Read-only: recovery dropped the torn tail in memory only; disk is untouched. + expect(Bun.file(path).size).toBe(sizeBefore); +}); diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..ff990ab --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,271 @@ +/** + * cli/run.ts — the LibreDB CLI as a pure function. + * + * `run(argv, io)` takes an argument vector and an IO sink and returns a process + * exit code. Keeping the whole CLI behind this seam — no direct stdout, no + * process.exit — is what makes every command and error path unit-testable; the + * bin shim (main.ts) is the only place that touches the real process. + * + * This is open-edge tooling over the public API, not kernel code: it adds no + * durability logic. Read commands (inspect/stats/get/scan) open through the + * read-only filesystem adapter so inspecting a file never mutates it. Write + * commands (set/delete/import) take an advisory lock first, and import commits + * all keys in one transaction so a bulk load is atomic. + */ +import { readFileSync, statSync } from "node:fs"; +import { parseArgs } from "node:util"; + +import type { Database } from "../core.ts"; +import { open } from "../index.ts"; +import { catalog, isReservedKey } from "../lens/catalog.ts"; +import { kv } from "../lens/kv.ts"; +import { acquireLock } from "./lock.ts"; +import { readonlyFileSystem } from "./readonly-fs.ts"; + +/** Where the CLI writes its output. One call is one line; the sink adds newlines. */ +interface Io { + out(line: string): void; + err(line: string): void; +} + +/** Everything a command handler needs: the file path, the command's positional + * arguments (everything after the path), the IO sink, and the `--force` flag. */ +interface Ctx { + path: string; + args: string[]; + io: Io; + force: boolean; +} + +const encoder = new TextEncoder(); +const utf8 = (s: string): Uint8Array => encoder.encode(s); + +const USAGE = [ + "libredb - inspect and edit .libredb files", + "", + "Usage:", + " libredb inspect List each namespace, its kind, and table schemas", + " libredb stats Summarize the file: size and namespace counts", + " libredb get Print the value stored at a key", + " libredb scan Print key=value for every key under a prefix", + " libredb set Set a key to a value", + " libredb delete Remove a key", + " libredb import Bulk-set keys from a JSON object (one atomic commit)", + "", + "Options:", + " --force Override an existing write lock", +].join("\n"); + +/** Open `path` read-only, run `fn`, and always close — so a read leaves the file + * exactly as it was (recovery cannot truncate a torn tail through this adapter). */ +const withReadDb = (path: string, fn: (db: Database) => T): T => { + const db = open({ path, fs: readonlyFileSystem() }); + try { + return fn(db); + } finally { + db.close(); + } +}; + +/** Take the advisory lock, open `path` for writing, run `fn`, then always close + * and release — so a crash mid-write cannot leave the lock stranded. */ +const withWriteDb = (path: string, force: boolean, fn: (db: Database) => T): T => { + const lock = acquireLock(path, force); + try { + const db = open({ path }); + try { + return fn(db); + } finally { + db.close(); + } + } finally { + lock.release(); + } +}; + +function inspect({ path, io }: Ctx): number { + return withReadDb(path, (db) => { + const registry = catalog(db); + io.out(`${path} ${statSync(path).size} bytes`); + if (registry.size === 0) { + io.out(" (no catalogued namespaces)"); + return 0; + } + for (const [name, entry] of registry) { + const schema = entry.schema === undefined ? "" : ` ${JSON.stringify(entry.schema)}`; + io.out(` ${name} ${entry.kind}${schema}`); + } + return 0; + }); +} + +function stats({ path, io }: Ctx): number { + return withReadDb(path, (db) => { + const registry = catalog(db); + const counts = { kv: 0, document: 0, relational: 0 }; + for (const entry of registry.values()) counts[entry.kind]++; + io.out(`${path} ${statSync(path).size} bytes ${registry.size} namespaces`); + io.out(` kv: ${counts.kv} document: ${counts.document} relational: ${counts.relational}`); + return 0; + }); +} + +function get({ path, args, io }: Ctx): number { + const [key] = args; + if (key === undefined) { + io.err("missing "); + return 2; + } + return withReadDb(path, (db) => { + const value = kv(db).get(key); + if (value === undefined) { + io.err(`key not found: ${key}`); + return 1; + } + io.out(value); + return 0; + }); +} + +function scan({ path, args, io }: Ctx): number { + const [prefix] = args; + if (prefix === undefined) { + io.err("missing "); + return 2; + } + return withReadDb(path, (db) => { + for (const entry of kv(db).prefix(prefix)) io.out(`${entry.key}=${entry.value}`); + return 0; + }); +} + +function set({ path, args, io, force }: Ctx): number { + const [key, value] = args; + if (key === undefined || value === undefined) { + io.err("missing "); + return 2; + } + if (isReservedKey(key)) { + io.err(`refusing to write a reserved key: ${key}`); + return 2; + } + return withWriteDb(path, force, (db) => { + const { changed } = kv(db).set(key, value); + io.out(`set ${key} (${changed} changed)`); + return 0; + }); +} + +function remove({ path, args, io, force }: Ctx): number { + const [key] = args; + if (key === undefined) { + io.err("missing "); + return 2; + } + if (isReservedKey(key)) { + io.err(`refusing to delete a reserved key: ${key}`); + return 2; + } + return withWriteDb(path, force, (db) => { + const { changed } = kv(db).delete(key); + io.out(`delete ${key} (${changed} removed)`); + return 0; + }); +} + +function importKeys({ path, args, io, force }: Ctx): number { + const [file] = args; + if (file === undefined) { + io.err("missing "); + return 2; + } + const raw = readFileSync(file, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Malformed JSON is bad input, not a runtime fault — report it like the other + // usage errors (exit 2) instead of letting it fall through to exit 1. + io.err("import expects a file containing a JSON object of string values"); + return 2; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + io.err("import expects a JSON object of string values"); + return 2; + } + const pairs: [string, string][] = []; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "string") { + io.err("import expects a JSON object of string values"); + return 2; + } + if (isReservedKey(key)) { + io.err(`import: refusing to write a reserved key: ${key}`); + return 2; + } + pairs.push([key, value]); + } + return withWriteDb(path, force, (db) => { + // One transaction for the whole load: a bulk import either lands entirely or, + // on a crash mid-write, not at all (recovery discards the torn record). + db.transact((tx) => { + for (const [key, value] of pairs) tx.set(utf8(key), utf8(value)); + }); + io.out(`import ${pairs.length} keys`); + return 0; + }); +} + +/** The commands, keyed by name. A Map (not a plain object) so an inherited + * property name like "toString" or "__proto__" can never resolve to a handler. */ +const commands = new Map number>([ + ["inspect", inspect], + ["stats", stats], + ["get", get], + ["scan", scan], + ["set", set], + ["delete", remove], + ["import", importKeys], +]); + +export function run(argv: string[], io: Io): number { + let positionals: string[]; + let values: { help?: boolean; force?: boolean }; + try { + const parsed = parseArgs({ + args: argv, + allowPositionals: true, + options: { help: { type: "boolean", short: "h" }, force: { type: "boolean" } }, + }); + positionals = parsed.positionals; + values = parsed.values; + } catch (error) { + io.err(error instanceof Error ? error.message : String(error)); + return 2; + } + + if (values.help === true || positionals.length === 0) { + io.out(USAGE); + return 0; + } + + const command = positionals[0] as string; + const handler = commands.get(command); + if (handler === undefined) { + io.err(`unknown command: ${command}`); + return 2; + } + + const path = positionals[1]; + if (path === undefined) { + io.err("missing "); + return 2; + } + + try { + return handler({ path, args: positionals.slice(2), io, force: values.force === true }); + } catch (error) { + io.err(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/src/core.fs.test.ts b/src/core.fs.test.ts index bc58796..2076ae4 100644 --- a/src/core.fs.test.ts +++ b/src/core.fs.test.ts @@ -101,6 +101,21 @@ test("committed state recovers from the injected filesystem after a reopen", () second.close(); }); +test("opening a path without a filesystem throws a clear error", () => { + // The kernel carries no default filesystem: it is runtime-agnostic, so a + // path-backed open MUST be given an `fs`. (The node default lives at the + // package edge in index.ts, not in the kernel.) A pathless, in-memory open + // needs no filesystem and is unaffected. + expect(() => open({ path: "should-not-be-created" })).toThrow(/filesystem/i); +}); + +test("opening an empty path throws a clear error (before touching any filesystem)", () => { + // An empty string is a degenerate path: reject it with a clean libredb error + // rather than letting it reach an adapter and surface a raw failure. The check + // runs before the filesystem check, so no fs is needed to trigger it. + expect(() => open({ path: "" })).toThrow(/non-empty path/i); +}); + test("an in-memory database (no path) never touches the filesystem seam", () => { // A trivial filesystem whose open() throws: if the kernel touched it for a // pathless database, this would blow up. It must not. diff --git a/src/core.kv.test.ts b/src/core.kv.test.ts index e399bcc..c7f5335 100644 --- a/src/core.kv.test.ts +++ b/src/core.kv.test.ts @@ -15,7 +15,9 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { open } from "./core.ts"; +// The path-backed durability case opens through the Node entry for the default +// node:fs adapter; the in-memory cases work the same through it. +import { open } from "./index.ts"; /** Build a key/value from byte numbers — keeps the tests terse and readable. */ const bytes = (...b: number[]): Uint8Array => new Uint8Array(b); diff --git a/src/core.recovery.test.ts b/src/core.recovery.test.ts index 93e2247..4d97403 100644 --- a/src/core.recovery.test.ts +++ b/src/core.recovery.test.ts @@ -18,7 +18,10 @@ import { appendFileSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { open } from "./core.ts"; +// Recovery is exercised on a real disk, so it opens through the Node entry, +// whose `open` supplies the default node:fs adapter (the kernel itself carries +// no default filesystem). +import { open } from "./index.ts"; /** Build a key/value from byte numbers — keeps the tests terse and readable. */ const bytes = (...b: number[]): Uint8Array => new Uint8Array(b); diff --git a/src/core.test.ts b/src/core.test.ts index 15ba48a..503973d 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -17,6 +17,17 @@ test("the public entry re-exports the kernel version", () => { expect(exportedVersion).toBe(version); }); +test("the JSR manifest version matches package.json", async () => { + // jsr.json carries its own version field; sync-version.ts keeps it in step with + // package.json during a release. This backstops that, the way the core check + // above backstops the core.ts constant. + const [pkg, jsr] = (await Promise.all([import("../package.json"), import("../jsr.json")])) as [ + { version: string }, + { version: string }, + ]; + expect(jsr.version).toBe(pkg.version); +}); + test("the public entry exposes a usable kv lens over an opened database", () => { const db = open(); const store = kv(db); diff --git a/src/core.ts b/src/core.ts index dbd44e2..0a3ec03 100644 --- a/src/core.ts +++ b/src/core.ts @@ -20,10 +20,8 @@ * and discards any record a crash left half-written. */ -import { closeSync, fsyncSync, openSync, readFileSync, statSync, truncateSync, writeSync } from "node:fs"; - /** The LibreDB package version. Kept in sync with package.json. */ -export const version = "0.0.4"; +export const version = "0.1.3"; /** * A key in the kernel: an immutable sequence of bytes. @@ -103,9 +101,10 @@ export interface Database { * goes through this interface. That keeps the IO boundary explicit and in one * place (a readability gain), and it lets a test inject a simulated filesystem * to torture crash recovery without a real disk (DESIGN.md section 6.4). The - * default is a thin real-`node:fs` adapter, so production behaviour is - * unchanged. The interface is deliberately the SMALLEST set of operations the - * WAL performs and nothing more. + * kernel carries NO default filesystem: a path-backed open must be given one. + * The Node entry (index.ts) supplies a `node:fs`-backed adapter by default; the + * browser entry supplies none. The interface is deliberately the SMALLEST set + * of operations the WAL performs and nothing more. */ export interface FileSystem { /** Open the log file at `path` for reading and appending, creating it if it @@ -144,10 +143,13 @@ export interface WalFile { export interface OpenOptions { readonly path?: string; /** - * The filesystem the write-ahead log runs on. Defaults to a thin real - * `node:fs` adapter. Injecting one lets tests simulate crashes and IO faults - * deterministically (DESIGN.md section 6.4). Ignored when there is no `path` - * (an in-memory database never touches a filesystem). + * The filesystem the write-ahead log runs on. Optional at the type level, but a + * path-backed open needs one at runtime: the kernel and the browser entry throw + * when `path` is given without `fs`, while the Node entry (index.ts) supplies a + * `node:fs` adapter by default so `open({ path })` works there. Injecting one + * lets tests simulate crashes and IO faults deterministically (DESIGN.md section + * 6.4). Ignored when there is no `path` (an in-memory database never touches a + * filesystem). */ readonly fs?: FileSystem; } @@ -412,45 +414,6 @@ function recover(file: WalFile): StoredEntry[] { return entries; } -/** - * The default {@link FileSystem}: a thin adapter over `node:fs`. Each method is - * the obvious synchronous syscall, so the seam adds an interface, not behaviour. - * Appends go through one append-mode descriptor (creating the file if missing); - * reads, size and truncate work by path, matching how the WAL has always - * reached the disk. - */ -function nodeFileSystem(): FileSystem { - return { - open(path) { - const fd = openSync(path, "a"); // append-only; creates the file if missing - return { - size() { - return statSync(path).size; - }, - read(offset, length) { - // A fresh Uint8Array so the returned slice is an independent copy, not - // a view aliasing a shared Buffer pool. - return new Uint8Array(readFileSync(path)).subarray(offset, offset + length); - }, - append(bytes) { - for (let written = 0; written < bytes.length; ) { - written += writeSync(fd, bytes, written); - } - }, - fsync() { - fsyncSync(fd); - }, - truncate(length) { - truncateSync(path, length); - }, - close() { - closeSync(fd); - }, - }; - }, - }; -} - /** A durable backing store: append committed records, then release the file. */ interface Log { /** Append one transaction's ops and fsync before returning, so a successful @@ -495,7 +458,20 @@ export const open: Open = (options) => { let committed: StoredEntry[]; let log: Log | null; if (options?.path !== undefined) { - const opened = openLog(options.path, options.fs ?? nodeFileSystem()); + // A path must actually name a file: reject the degenerate empty string here + // with a clear error, rather than letting it reach the filesystem and + // surface a raw, adapter-specific failure (e.g. node's ENOENT for ""). + if (options.path === "") { + throw new Error("libredb: open({ path }) requires a non-empty path"); + } + // The kernel is runtime-agnostic: it carries no default filesystem, so a + // path-backed open MUST be given one. The default node:fs adapter lives at + // the package edge (index.ts wires it in); the browser entry has none. A + // pathless, in-memory open never reaches here and needs no filesystem. + if (options.fs === undefined) { + throw new Error("libredb: open({ path }) requires a filesystem; none was provided"); + } + const opened = openLog(options.path, options.fs); committed = opened.entries; log = opened.log; } else { diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..30a19ba --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,71 @@ +/** + * index.test.ts — the default (Node) package entry. + * + * The kernel (core.ts) is runtime-agnostic and carries no default filesystem. + * The Node entry is where the convenience lives: `open({ path })` with no `fs` + * defaults to the real `node:fs` adapter, so the long-standing ergonomic — open + * a file by path and get durability — is preserved. A pathless open stays + * in-memory, and an explicitly injected `fs` is passed straight through. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test } from "bun:test"; + +import { open } from "./index.ts"; + +const dirs: string[] = []; +const tempPath = (name: string): string => { + const dir = mkdtempSync(join(tmpdir(), "libredb-index-")); + dirs.push(dir); + return join(dir, name); +}; + +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop() as string, { recursive: true, force: true }); +}); + +const bytes = (...b: number[]): Uint8Array => new Uint8Array(b); + +test("open({ path }) with no fs defaults to node:fs and persists durably", () => { + const path = tempPath("db"); + const first = open({ path }); + first.transact((tx) => tx.set(bytes(1), bytes(42))); + first.close(); + + // Reopening reads the real file back through the default node:fs adapter. + const second = open({ path }); + expect(second.transact((tx) => tx.get(bytes(1)))).toEqual(bytes(42)); + second.close(); +}); + +test("open() with no path stays in-memory", () => { + const db = open(); + db.transact((tx) => tx.set(bytes(1), bytes(7))); + expect(db.transact((tx) => tx.get(bytes(1)))).toEqual(bytes(7)); + db.close(); +}); + +test("open({ path: '' }) throws the clean libredb error, not a raw node failure", () => { + // The empty path is rejected by the kernel before the node:fs default is ever + // exercised, so a public Node consumer sees the libredb message, not ENOENT. + expect(() => open({ path: "" })).toThrow(/non-empty path/i); +}); + +test("open({ path, fs }) passes the injected filesystem straight through", () => { + // An exploding filesystem proves the injected fs is used (not the node + // default): if the wrapper ignored it and fell back to node:fs, this would + // silently write to a real file instead of throwing. + const boom = new Error("injected filesystem was used"); + expect(() => + open({ + path: "db", + fs: { + open() { + throw boom; + }, + }, + }), + ).toThrow(boom); +}); diff --git a/src/index.ts b/src/index.ts index 5e79f25..3bce1af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,24 @@ * {@link isReservedKey} (with {@link RESERVED_MARKER} / {@link CATALOG_PREFIX}) * lets a raw-KV tool hide engine-internal keys instead of hardcoding the layout. */ -export { version, open } from "./core.ts"; -export type { Database, OpenOptions } from "./core.ts"; +import { open as openKernel, type Open } from "./core.ts"; +import { nodeFileSystem } from "./adapter/node-fs.ts"; + +export { version } from "./core.ts"; +export type { Database, FileSystem, OpenOptions, WalFile } from "./core.ts"; + +/** + * Open a LibreDB database on Node or Bun. Identical to the kernel's + * {@link import("./core.ts").open}, except a path-backed open with no `fs` + * defaults to the real `node:fs` adapter — so `open({ path })` is durable out of + * the box. A pathless open stays in-memory; an explicit `fs` is passed through. + * The browser entry (`@libredb/libredb/browser`) omits this default so it never + * imports `node:fs`. + */ +export const open: Open = (options) => + options?.path !== undefined && options.fs === undefined + ? openKernel({ ...options, fs: nodeFileSystem() }) + : openKernel(options); export { kv } from "./lens/kv.ts"; export type { Kv, KvEntry } from "./lens/kv.ts"; diff --git a/src/lens/catalog.test.ts b/src/lens/catalog.test.ts index 6d31d34..4162236 100644 --- a/src/lens/catalog.test.ts +++ b/src/lens/catalog.test.ts @@ -3,7 +3,9 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { open } from "../core.ts"; +// Persistence cases use a real path, so open through the Node entry (which +// defaults the node:fs adapter); the kernel carries no default filesystem. +import { open } from "../index.ts"; import { CATALOG_PREFIX, RESERVED_MARKER, assertUserName, catalog, isReservedKey } from "./catalog.ts"; import * as publicSurface from "../index.ts"; import { doc } from "./document.ts"; diff --git a/src/lens/document.test.ts b/src/lens/document.test.ts index 437fc9f..334174c 100644 --- a/src/lens/document.test.ts +++ b/src/lens/document.test.ts @@ -3,7 +3,9 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { open } from "../core.ts"; +// Persistence cases use a real path, so open through the Node entry (which +// defaults the node:fs adapter); the kernel carries no default filesystem. +import { open } from "../index.ts"; import { doc, encodeDoc, decodeDoc, type Doc, type DocEntry } from "./document.ts"; describe("document codec", () => { diff --git a/src/lens/kv.test.ts b/src/lens/kv.test.ts index 552f084..b445208 100644 --- a/src/lens/kv.test.ts +++ b/src/lens/kv.test.ts @@ -15,7 +15,9 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { open } from "../core.ts"; +// Durable reopen cases use a real path, so open through the Node entry (whose +// `open` defaults the node:fs adapter); the kernel itself has no default fs. +import { open } from "../index.ts"; import { kv, type KvEntry } from "./kv.ts"; /** Temp directories created during the run, cleaned up after each test. */ diff --git a/src/lens/relational.test.ts b/src/lens/relational.test.ts index 28c7f6f..9dd8fec 100644 --- a/src/lens/relational.test.ts +++ b/src/lens/relational.test.ts @@ -3,7 +3,9 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { open } from "../core.ts"; +// Persistence cases use a real path, so open through the Node entry (which +// defaults the node:fs adapter); the kernel carries no default filesystem. +import { open } from "../index.ts"; import { doc, type Doc } from "./document.ts"; import { table, type TableSchema, type Row } from "./relational.ts";