Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2a934c7
docs: add distribution channels research and design (issue #6)
cevheri Jun 29, 2026
8a1bd79
feat: make kernel runtime-agnostic and add browser entry point
cevheri Jun 29, 2026
ffaa1d9
feat: publish to JSR and document CDN/browser usage
cevheri Jun 29, 2026
78f3547
feat: add libredb CLI with read commands (inspect, stats, get, scan)
cevheri Jun 29, 2026
25e725d
feat: add libredb CLI write commands (set, delete, import) with advis…
cevheri Jun 29, 2026
57da743
ci: scope out SonarCloud S8707 in the filesystem and CLI layers
cevheri Jun 29, 2026
8d1bd97
feat: distribute standalone CLI binaries via GitHub Releases
cevheri Jun 29, 2026
f002b8e
feat: ship a multi-arch Docker image of the CLI to GHCR
cevheri Jun 29, 2026
1f823de
feat: add OPFS-backed browser persistence (opfsFileSystem)
cevheri Jun 29, 2026
e30ab5b
fix: harden OPFS adapter and release workflow per final review
cevheri Jun 29, 2026
7f67fa4
chore: bump version to 0.1.0
cevheri Jun 29, 2026
7c575da
ci: also publish the Docker image to Docker Hub; bump to 0.1.1
cevheri Jun 29, 2026
45fc278
ci: use vars.DOCKER_HUB_USERNAME for Docker Hub login; bump to 0.1.2
cevheri Jun 29, 2026
6010e46
docs: note the Docker image is published to Docker Hub too
cevheri Jun 29, 2026
b330ccf
fix: lock reports only EEXIST as locked; clarify fs/docs per review
cevheri Jun 29, 2026
6f040af
chore: add .qoder/ to .gitignore
cevheri Jun 29, 2026
a197813
feat: type browser open so fs is required when a path is given
cevheri Jun 29, 2026
37e4705
fix: harden CLI dispatch, import, and lock per Copilot review
cevheri Jun 29, 2026
0aef094
docs: sync-version manages the README CDN pin; document Docker Hub setup
cevheri Jun 29, 2026
028297c
fix: delete refuses reserved keys; clarify browser typing in README
cevheri Jun 29, 2026
dd36030
refactor: stop re-exporting OpenOptions from the browser entry
cevheri Jun 29, 2026
29b1865
fix: make the advisory lock robust to IO faults (per Copilot review)
cevheri Jun 29, 2026
25b51a5
build: pin base image digests; clarify --force is for stale locks
cevheri Jun 29, 2026
584c94e
docs: add JSR and Docker Hub badges and a Docker Hub link
cevheri Jun 29, 2026
498d47b
ci: publish to JSR via npx, not bunx
cevheri Jun 29, 2026
5c316b2
chore: release 0.1.3 (consume changesets, write CHANGELOG)
cevheri Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/cli-read-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@libredb/libredb": minor
---

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 <key>`, and
`scan <prefix>`. 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 <key> <value>`, `delete <key>`, and `import <file.json>`
(bulk-set from a JSON object in a single atomic commit). Writes take an advisory
`<path>.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`).
15 changes: 15 additions & 0 deletions .changeset/distribution-browser-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@libredb/libredb": minor
---

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.
37 changes: 35 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
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 and JSR 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.
on:
release:
types: [published]
Expand Down Expand Up @@ -45,3 +50,31 @@ jobs:
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
Comment thread
cevheri marked this conversation as resolved.
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
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
# JSR publishes from source and runs its own slow-types check; no build or
# dependency install is needed (the source has no external runtime deps).
- run: bunx 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"
5 changes: 5 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,54 @@ 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.0.4";
```

**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. a future OPFS adapter).

```ts
import { open, kv } from "@libredb/libredb/browser";

const db = open(); // in-memory; durable persistence is a roadmap item
kv(db).set("greeting", "hello");
```

A browser-targeting bundler resolves the browser build automatically via the package's `browser`
export condition, so importing the main `@libredb/libredb` entry works too.
Comment thread
cevheri marked this conversation as resolved.
Outdated

## Command-line tool

The package ships a `libredb` bin for inspecting and editing `.libredb` files — no code required:

Comment thread
cevheri marked this conversation as resolved.
```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 `<path>.lock` to refuse a second concurrent writer; pass `--force` to override a stale lock.

## How it works: one core, three lenses

<picture>
Expand Down
5 changes: 5 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
221 changes: 221 additions & 0 deletions docs/superpowers/specs/2026-06-29-distribution-channels-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# 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.** `core.ts` statically imports `node:fs` at module top
(`import { ... } from "node:fs"`). The `FileSystem` seam already exists and the
default `nodeFileSystem()` is only used when a `path` is given — but the static
import means importing the package at all drags `node:fs` into the import graph,
breaking browser use even for in-memory (`path`-less) databases. This is the
single linchpin blocking the browser channel.
Comment thread
cevheri marked this conversation as resolved.
Outdated
- **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:
- Read: `inspect` (catalog summary grouped by kind), `get <key>`, `scan <prefix>`,
`stats` (record count, file size, per-namespace breakdown), `repl` (read).
- Write: `set`, `delete`, `import`.
- **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 (`.libredb.lock`); if held,
they refuse unless `--force`.
- `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.
12 changes: 12 additions & 0 deletions jsr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@libredb/libredb",
"version": "0.0.4",
"exports": {
".": "./src/index.ts",
"./browser": "./src/browser.ts"
},
"publish": {
"include": ["src", "README.md", "LICENSE", "jsr.json"],
"exclude": ["src/**/*.test.ts", "src/sim"]
}
}
Loading
Loading