Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1c7b54e
docs(playground): design spec for in-browser OPFS LibreDB editor (#19)
cevheri Jun 30, 2026
b45ec7c
docs(playground): implementation plan + add @libredb/libredb dep (#19)
cevheri Jun 30, 2026
3aabf11
feat(playground): command grammar + worker protocol parser (#19)
cevheri Jun 30, 2026
c3b44e3
feat(playground): lens-dispatch engine + sample seed (#19)
cevheri Jun 30, 2026
4824972
feat(playground): OPFS worker with in-memory fallback + reset (#19)
cevheri Jun 30, 2026
369ef84
feat(playground): main-thread worker bridge + XSS-safe grid renderer …
cevheri Jun 30, 2026
4efbc3b
feat(playground): editor pane, cheatsheet, route, nav link (#19)
cevheri Jun 30, 2026
70e6c06
fix(playground): rename param to avoid shadow warning; browser-verifi…
cevheri Jun 30, 2026
9e8b743
feat(playground): list in sidebar Explorer under database group (#19)
cevheri Jun 30, 2026
398f129
refactor(playground): mirror LibreDB's real grammar, drop the SQL/doc…
cevheri Jun 30, 2026
a5db289
feat(playground): cheatsheet click loads command into editor instead …
cevheri Jun 30, 2026
cccdfc8
feat(playground): add inspect/stats/import CLI commands (browser-supp…
cevheri Jun 30, 2026
3a2f217
fix(playground): import example uses a distinct session:* namespace (…
cevheri Jun 30, 2026
9afb0d0
feat(playground): result status echo + fade-in so instant runs are le…
cevheri Jun 30, 2026
e1de96e
docs(playground): note why a relational row repeats its pk (key + id …
cevheri Jun 30, 2026
269d452
feat(playground): turn the console into a full activity log (#19)
cevheri Jun 30, 2026
52e9a6e
fix(playground): address Copilot review — guard reserved-key writes +…
cevheri Jun 30, 2026
1d0ff40
fix(playground): seed marker = catalog, not a deletable kv key; align…
cevheri Jun 30, 2026
8afa7d1
polish(playground): address security-report follow-ups (#19)
cevheri Jun 30, 2026
f2d0d79
feat(playground): graded, non-blocking safeguards for put/delete (#19)
cevheri Jun 30, 2026
cbc3aff
fix(playground): serialize run/reset + worker ops; add a11y region ro…
cevheri Jun 30, 2026
c2d3c17
chore: release v0.6.0 — in-browser OPFS playground; ignore .qoder/ (#19)
cevheri Jun 30, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ pnpm-debug.log*

docs/designs/_ref

.qoder/

3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1,066 changes: 1,066 additions & 0 deletions docs/superpowers/plans/2026-06-30-playground-opfs-editor.md

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions docs/superpowers/specs/2026-06-30-playground-opfs-editor-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# /playground — in-browser, zero-backend LibreDB editor (OPFS-backed)

**Issue:** [libredb/libredb.github.io#19](https://github.com/libredb/libredb.github.io/issues/19)
**Date:** 2026-06-30
**Repo:** libredb-website (libredb.github.io)
**Engine guide (authoritative):** `libredb-database/docs/BROWSER.md`

## Goal

Add a public route `/playground` that runs a **real LibreDB database entirely in the
browser**, backed by OPFS persistence shipped in `@libredb/libredb@0.1.3`. A visitor
lands on a working database preloaded with a sample dataset and a clickable command
cheatsheet, and can operate the database without typing anything and without signing in.

This is a **separate route**, not a change to the marketing homepage. The homepage hero
"SQL editor" stays a stylized mock; `/playground` is the "use it for real, right now"
surface.

## Decisions (locked in brainstorming)

- **Editor approach:** native editor built on the site's existing IDE-shell components.
The "real Studio Monaco editor" lives in the separate `libredb-studio` repo and is not
packaged for browser use — out of scope for this repo. We reuse `StudioShell`,
`Explorer`, `Console`, `CommandPalette`, `StatusBar`, and the `Sql.astro` highlighter.
- **Route name:** `/playground`.
- **Scope:** full feature set (OPFS worker + 3-lens seed + clickable cheatsheet + Reset
sandbox + in-memory fallback + `db.close()` teardown + SSR-safe island).

## Constraints (from BROWSER.md / issue)

- Import the **browser entry**: `@libredb/libredb/browser` (no `node:` in its import graph).
- `open()` (no path) → in-memory, works anywhere. `open({ path, fs })` → durable, needs `fs`.
- **OPFS sync access handles exist only inside a dedicated Web Worker, secure context only**
(HTTPS or `localhost`). Acquire the handle async once, then `open` is synchronous.
- **Single-writer:** `createSyncAccessHandle()` takes an exclusive lock; a second tab cannot
open the same file. For a public demo, fall back to in-memory on contention.
- **No LibreDB code during SSR** — Astro renders on Node where `window`/`navigator.storage`/
`Worker` don't exist. All engine code runs in client-only `<script>` / the Worker.
- Call `db.close()` on teardown so the exclusive lock frees for the next reload.

## Architecture

```
/playground (Astro page, SSR-safe — no engine code at build/SSR time)
└─ StudioShell (reused chrome)
└─ Playground island ← plain client <script> (no React)
├─ Editor pane: textarea + Sql-style highlight + Run + result grid
├─ Cheatsheet sidebar: clickable ready-to-run commands per lens
└─ Toolbar: Reset sandbox · persistence badge (OPFS | in-memory)
│ postMessage({id, op, args}) ▲ onmessage({id, result|error})
▼ │
db.worker.ts (Web Worker, type: module)
- navigator.storage.getDirectory()
- getFileHandle("playground.libredb", {create:true})
- createSyncAccessHandle() (Worker-only, exclusive)
- open({ path, fs: opfsFileSystem(handle) }) durable path
- on any failure → open() in-memory fallback
- seed sample data on first open (kv + doc + table)
- runs commands, returns rows/result
- db.close() on "close" op
```

The Worker is loaded the bundler-friendly way:
`new Worker(new URL("./db.worker.ts", import.meta.url), { type: "module" })` — Vite (Astro's
bundler) understands this in dev and production builds.

## Files

### New

| File | Purpose |
| --- | --- |
| `src/pages/playground.astro` | The route. Wraps `Playground.astro` in `StudioShell`, sets SEO title/description. |
| `src/components/studio/Playground.astro` | Editor pane markup: command input, Run button, result grid container, persistence badge, Reset button, mounts `Cheatsheet`. Includes the client `<script>`. |
| `src/components/studio/Cheatsheet.astro` | Clickable command list grouped per lens (kv / document / relational / meta). Each button carries `data-cmd`. |
| `src/scripts/playground/protocol.ts` | **Pure, testable.** Message types (`WorkerRequest`/`WorkerResponse`), command grammar, parser. No DOM, no engine import. |
| `src/scripts/playground/engine.ts` | **Testable.** `seed(db)` (the sample dataset), `isSeeded(db)`, and `execute(db, cmd)` — lens dispatch over the browser entry. (The seed lives here, not a separate `seed.ts`.) |
| `src/scripts/playground/db.worker.ts` | The Worker. Owns the OPFS handle, opens db (durable→memory fallback), seeds on first open, dispatches parsed commands, and on `close` runs `db.close()` then `self.close()`. |
| `src/scripts/playground/client.ts` | Main-thread bridge: spawns worker, `call()` request/response with ids, wires Run + cheatsheet + Reset, renders the grid + status echo + activity log, shows fallback banner, and posts `close` on `pagehide` (the worker self-closes; the client does **not** `terminate()`). |
| `src/scripts/playground/protocol.test.ts`, `engine.test.ts` | `bun:test` units for the parser and the seed/execute engine (against an in-memory `open()`). |

### Reused as-is

`StudioShell`, `Console` + `studioConsole` toast API (`src/scripts/lib/console-copy.ts`),
`Sql.astro` highlighter, Tailwind design tokens (`bg-panel`, `border-edge`, `text-fg`,
`text-primary`, `bg-ok`, …).

### Modified

| File | Change |
| --- | --- |
| `package.json` | Add dependency `@libredb/libredb@0.1.3`. |
| `src/data/sections.ts` *(if needed for nav)* | Optionally add a `/playground` entry; otherwise link from TopBar only. Decide during implementation — keep homepage unaffected. |

## Command grammar (mirrors Studio's `LibreDBProvider` exactly)

LibreDB is an ordered key-value store; documents and relational tables are **conventions over
the keyspace** (`<table>:<pk>`, `<collection>:<id>`) recorded in the catalog — **not** separate
command dialects (see `libredb-studio/docs/providers/libredb.md` §1, §3.5). The playground exposes
**one** grammar — the five kv-lens verbs — never a SQL/document translator. Parsed in `protocol.ts`,
executed by `engine.ts` in the worker:

```
get <key> read one key → key/value row (JSON value pretty-printed); missing → (nil)
put <key> <value> write; quote-aware value tail → "OK · changed N"
delete <key> remove → "OK · changed N"
prefix <prefix> scan all keys under a prefix → rows
range <start> <end> half-open [start,end) keyspace scan → rows
```

Plus the CLI's database-level commands (`libredb-database/docs/CLI.md`), which the
browser build fully supports:

```
inspect list catalogued namespaces + kind + relational schema (reads catalog(db))
stats file size (OPFS handle.getSize()) + namespace counts by kind
import <json-object> bulk-set a JSON object of string values in ONE atomic db.transact()
```

`inspect`/`stats` read `catalog(db)`; `import` commits through `db.transact()` (byte-level
`tx.set` with UTF-8 encoding) and refuses reserved keys via `isReservedKey`. These appear in a
separate **Manage** group in the cheatsheet with a use-case line each. (CLI file concepts — a
`<path>` argument, `.lock` files, `--force` — have no browser analog: the worker owns the OPFS
exclusive sync-access handle, and a second tab falls back to in-memory.) The CLI verbs `scan`/`set`
are intentionally NOT aliased — the playground keeps Studio's `prefix`/`put` names as canonical.

Parser rules (mirroring the provider's `tokenize`):
- Verbs case-insensitive. Quote-aware tokenization: single/double quotes preserve internal
whitespace; an unmatched quote is a friendly error; consecutive unquoted whitespace collapses.
- Blank and `#`-comment lines are skipped; the first real line runs (a commented cheatsheet buffer
is directly runnable).
- Empty / unknown verb / wrong arg count → `{ op: "error" }` (never throws across the worker boundary).
- `prefix`/`range` results hide the reserved catalog namespace via the package's `isReservedKey`.
- Results normalize to `{ kind:"rows", columns, rows }` for the grid, or `{ kind:"message" }` for
writes / `(nil)` reads.

**Reset** is a host (sandbox) action, not a LibreDB verb: the toolbar button wipes the OPFS file and
reseeds. The seed still writes through the `doc()` and `table()` lenses so the catalog is real; the
visitor operates those namespaces through the same five kv verbs (`prefix users:`, `get users:1`,
`put users:4 {…}`, …). The `Cheatsheet` groups buttons by namespace (`users:*` relational, `articles:*`
document, `config:*` kv); clicking one fills the editor and runs it → **zero-typing**.

## Data flow

1. Page loads → client `<script>` runs only in browser → spawns Worker.
2. Worker acquires OPFS handle (or falls back), opens db, seeds if empty, posts `{ready, mode}`.
3. Client shows persistence badge from `mode` (`"opfs"` | `"memory"`); if `memory`, shows a
one-line banner: "Persistence disabled for this session — running in-memory."
4. Visitor clicks a cheatsheet command (or types + Run) → client `call(op:"run", {text})`.
5. Worker parses, executes against the right lens, posts `{id, result}` or `{id, error}`.
6. Client renders rows in the grid; errors → red console toast.
7. Reset → `call(op:"reset")` → worker truncates the relevant keys/tables and reseeds.
8. `pagehide` (non-bfcache) → `call(op:"close")`; the worker runs `db.close()` then `self.close()`. The client does **not** call `worker.terminate()` — that would race and usually win before the worker releases the OPFS handle.

## Error handling & edge cases

- **OPFS unavailable** (no `getDirectory`, no `createSyncAccessHandle`, insecure context,
unsupported browser) → `try/catch` around handle acquisition → `open()` in-memory →
`mode:"memory"`.
- **Second-tab single-writer lock** → `createSyncAccessHandle()` throws → same in-memory
fallback path → badge + banner explain it.
- **Bad command / bad JSON** → worker returns `{error}`; the grid is left unchanged; a red
toast appears. The worker never throws across `postMessage`.
- **Teardown** → `db.close()` on `close` op frees the exclusive lock so a quick reload
reacquires OPFS cleanly.
- **Eviction** → acceptable for a demo; reseed on next visit (seed runs when the db is empty).

## Testing

- **Unit (`bun:test`)** — `protocol.test.ts`: each command parses to the right op+args;
garbage rejected with a friendly error; JSON tail parsed safely; `select … limit N` parsed.
- **Gate** — `bun run gate` (typecheck + format + lint + knip + test) must pass.
- **Playwright (real browser, local `astro preview` or `dist` server)**:
1. Navigate to `/playground`; assert seed `users` rows render in the grid.
2. Click a cheatsheet `insert into users {…}` → new row appears.
3. Reload → the inserted row persists (OPFS durable path on Chromium/`localhost`).
4. Click **Reset sandbox** → grid returns to the seed set.
5. Assert the persistence badge reads OPFS on a secure context.
6. Confirm no `node:`/`node_fs` reference in the emitted `/playground` client bundle.

## Acceptance criteria (from issue #19)

- [ ] Public route serves a real editor with no login.
- [ ] Queries execute client-side against an OPFS-backed LibreDB in a Web Worker; no backend.
- [ ] Sample dataset (kv + document + relational) preloaded on first visit.
- [ ] Clickable command palette runs insert/get/update/delete/scan without typing.
- [ ] Data persists across reloads, is per-visitor isolated, and can be reset.
- [ ] Browser bundle does not pull `node:fs`; homepage/marketing build unaffected.
- [ ] Graceful in-memory fallback + notice where OPFS/sync handles unavailable or on
second-tab lock contention.
- [ ] Worker releases the handle (`db.close()`) on teardown.
- [ ] No LibreDB code runs during SSR (client-only island; engine touched only after mount).

## Non-goals

- Not replacing the homepage hero editor.
- Not the full multi-provider IDE (no Postgres/MySQL over the network).
- Not the cross-repo Monaco bundle from libredb-studio.
- Not cloud persistence or accounts.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "libredb-studio-website",
"type": "module",
"version": "0.5.5",
"version": "0.6.0",
"scripts": {
"dev": "astro dev",
"build": "node scripts/sync-docker-compose.mjs && astro build",
Expand All @@ -20,6 +20,7 @@
},
"dependencies": {
"@astrojs/sitemap": "3.7.3",
"@libredb/libredb": "0.1.3",
"@tailwindcss/vite": "4.3.1",
"astro": "7.0.3",
"tailwindcss": "4.3.1"
Expand Down
124 changes: 124 additions & 0 deletions src/components/studio/Cheatsheet.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
// Ready-to-run LibreDB commands, grouped by namespace. Every command is one of
// the five real kv-lens verbs (get/put/delete/prefix/range) — the same grammar
// as Studio's LibreDBProvider. "Tables" and "collections" are conventions over
// the keyspace (users:<pk>, articles:<id>), shown here as their key prefixes;
// the catalog kind is a label, not a separate command dialect.
interface Group {
label: string; // namespace prefix, e.g. "users:*"
kind: string; // catalog kind label
kindClass: string;
note?: string; // optional clarifying line under the group header
commands: string[];
}
const groups: Group[] = [
{
label: 'users:*',
kind: 'relational table',
kindClass: 'bg-warn/15 text-warn',
note: 'A relational row keeps its primary key both in the key (users:4) and as its id column.',
commands: [
'prefix users:',
'get users:1',
'put users:4 {"id":"4","name":"Lin","age":29,"active":true}',
'delete users:4',
],
},
{
label: 'articles:*',
kind: 'document collection',
kindClass: 'bg-ok/15 text-ok',
commands: [
'prefix articles:',
'get articles:a1',
'put articles:a3 {"title":"Local-first","published":true}',
'delete articles:a2',
],
},
{
label: 'config:*',
kind: 'key–value',
kindClass: 'bg-primary/15 text-primary',
commands: ['prefix config:', 'get config:theme', 'put config:theme light', 'range a z', 'delete config:theme'],
},
];

// Database-level "manage" commands (the CLI's inspect/stats/import), each with a
// use case. Clicking loads the command into the editor — press Run to execute.
interface AdminCommand {
cmd: string;
use: string;
}
const adminCommands: AdminCommand[] = [
{ cmd: 'inspect', use: 'List namespaces, their kind, and relational schemas (reads the catalog).' },
{ cmd: 'stats', use: 'One-glance summary: file size on disk + namespace counts by kind.' },
{
cmd: 'import {"session:a1":"token-abc","session:a2":"token-xyz"}',
use: 'Bulk-load many keys (here a new session:* namespace) in one atomic commit.',
},
];
---

<aside class="w-full shrink-0 overflow-y-auto border-l border-edge bg-canvas lg:w-72" aria-label="Command cheatsheet">
<div class="border-b border-edge px-4 py-3">
<p class="text-[11px] tracking-wider text-faint uppercase">Cheatsheet</p>
<p class="mt-1 text-[12px] text-dim">Click a command to load it into the editor, then press Run.</p>
<p class="mt-2 text-[11.5px] leading-relaxed text-faint">
LibreDB is an ordered key–value store. Tables and collections are conventions over keys (<code class="text-dim"
>users:1</code
>) — all operated with the same five verbs.
</p>
</div>
{
groups.map((g) => (
<div class="border-b border-edge px-3 py-3">
<p class="mb-2 flex items-center gap-2 px-1 font-mono text-[12.5px] text-muted">
{g.label}
<span class:list={['rounded px-1.5 font-sans text-[10px] tracking-wide uppercase', g.kindClass]}>
{g.kind}
</span>
</p>
{g.note && <p class="mb-2 px-1 text-[11px] leading-snug text-faint">{g.note}</p>}
<ul class="space-y-1">
{g.commands.map((cmd) => (
<li>
<button
type="button"
data-cmd={cmd}
class="block w-full truncate border border-edge bg-panel px-2.5 py-1.5 text-left font-mono text-[12px] text-fg hover:border-primary hover:text-primary focus:border-primary focus:outline-none"
title={cmd}
>
{cmd}
</button>
</li>
))}
</ul>
</div>
))
}

<!-- Database-level admin/manage commands -->
<div class="border-b border-edge px-3 py-3">
<p class="mb-2 flex items-center gap-2 px-1 text-[12.5px] text-muted">
Manage
<span class="rounded bg-ai/15 px-1.5 text-[10px] tracking-wide text-ai uppercase">admin</span>
</p>
<ul class="space-y-2">
{
adminCommands.map((c) => (
<li>
<button
type="button"
data-cmd={c.cmd}
class="block w-full truncate border border-edge bg-panel px-2.5 py-1.5 text-left font-mono text-[12px] text-fg hover:border-primary hover:text-primary focus:border-primary focus:outline-none"
title={c.cmd}
>
{c.cmd}
</button>
<p class="mt-1 px-0.5 text-[11px] leading-snug text-faint">{c.use}</p>
</li>
))
}
</ul>
</div>
</aside>
Loading
Loading