A generic, self-contained database admin panel for SQLite. Browse and edit your data with a dark, monospace UI. Zero-config — point it at a .db file and go.
Framework-agnostic core with thin adapters. Runs on Cloudflare Workers (D1) and Node.js (better-sqlite3).
npx @jvelo/tapemark-cli serve ~/path/to/your.dbOpens an admin UI at http://localhost:3333. Requires Node.js ≥ 20.
Hacking on tapemark from a clone? Run
pnpm install, thenpnpm exec tsx packages/cli/src/index.ts serve …— the same CLI, straight from source.
Serve — browse and edit a database in the browser:
# Single database
npx @jvelo/tapemark-cli serve ./data.db
# Multiple databases (each gets its own section)
npx @jvelo/tapemark-cli serve ./users.db ./content.db
# Options
npx @jvelo/tapemark-cli serve ./data.db --port 4000 --readonly --theme depart --constraints relaxedInspect — quick schema overview from the terminal:
# List tables and row counts
npx @jvelo/tapemark-cli inspect ./data.db
# Show columns for a specific table or view
npx @jvelo/tapemark-cli inspect ./data.db --show users
# Compare schemas between two databases
npx @jvelo/tapemark-cli inspect ./local.db --diff ./production.dbInstall the core plus the adapters you need:
npm i @jvelo/tapemark @jvelo/tapemark-hono @jvelo/tapemark-d1 # Cloudflare Workers + D1
npm i @jvelo/tapemark @jvelo/tapemark-better-sqlite3 better-sqlite3 # Node.jsimport { tapemark } from "@jvelo/tapemark-hono";
import { createD1Adapter } from "@jvelo/tapemark-d1";
app.route("/admin", tapemark({
db: (c) => createD1Adapter(c.env.DB),
prefix: "/admin",
authorize: async (c) => checkAdmin(c),
}));authorize may also return a Response to override the default 403 — for
example, redirecting unauthenticated users to a login page:
authorize: async (c) => {
const user = await getSession(c);
if (!user) return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
return user.role === "admin";
}The core returns plain HTML strings — pipe them into any response:
import { createTapemark } from "@jvelo/tapemark";
import { createSqliteAdapter } from "@jvelo/tapemark-better-sqlite3";
import Database from "better-sqlite3";
const db = createSqliteAdapter(new Database("app.db"));
const core = createTapemark({ db, prefix: "/admin" });
// In your request handler:
const res = await core.handle({
method: req.method,
path: "/users",
params: {},
query: {},
});
// res.status, res.headers, res.html or res.redirecttapemark({
db: (c) => createD1Adapter(c.env.DB),
prefix: "/admin",
name: "admin", // display name in the top bar
siteUrl: "/", // "← site" link
siteName: "myapp", // label for the site link
theme: "hubot", // "hubot" (default), "plex", or "depart"
bundleFonts: true, // false if the host app already serves the fonts
readonly: false, // global read-only mode
constraints: "enforce", // "enforce" (default) or "relaxed"
authorize: async (c) => {}, // auth callback
tables: { // per-table options
sites: { readonly: true }, // or { hidden: true }, or hooks/actions (below)
// hidden: true hides the table from the listing and makes every route for it
// return 404 (so it can't be reached by direct URL).
},
});D1 and
constraints:"relaxed"only takes effect on native SQLite (better-sqlite3), where foreign keys are off by default and tapemark turns them on for"enforce". Cloudflare D1 enforces foreign keys unconditionally and ignoresPRAGMA foreign_keys, so constraints stay on regardless of this setting.
Per-table hooks run automatically after a row is inserted, updated, or deleted through the admin UI. Handlers receive a HookContext with the request's DB, framework env, and a background() helper for fire-and-forget work.
tapemark({
db: (c) => createD1Adapter(c.env.DB),
tables: {
sites: {
hooks: {
afterInsert: async (row, ctx) => {
// Slow work — defer past the response on runtimes that support it
// (Workers, Vercel); awaited inline elsewhere.
await ctx.background(fetchAndStoreMetadata(row, ctx));
},
afterUpdate: async (pk, patch, ctx) => { /* … */ },
afterDelete: async (pk, ctx) => { /* … */ },
},
},
},
});Hook failures don't roll back the write — the row operation has already committed by the time the hook runs. Synchronous hook errors surface as a warning flash on the admin page; errors inside ctx.background() work are visible only when running inline (logged by the runtime when running via waitUntil).
Custom row actions render as extra buttons on the row detail page, separated visually from the form's save button. The handler receives the primary key and a HookContext, and returns an ActionResult that becomes the flash message.
tables: {
sites: {
actions: {
refetch: {
label: "re-fetch metadata",
handler: async (pk, ctx) => {
await refetchSiteMetadata(pk, ctx);
return { success: true, message: "metadata refreshed" };
},
},
mark_done: {
label: "mark done",
display: { list: true }, // also expose per-row in the list
visible: (row) => row.status !== "done", // hide once it's already done
handler: async (pk, ctx) => { /* … */ },
},
},
},
}Where each action renders is controlled by display: defaults are { detail: true, list: false }. Set display.list: true to expose the button per-row in the list view (invocations from there redirect back to the list); set display.detail: false to hide it from the row form. Actions are gated by the same readonly rules as updates and deletes.
The optional visible(row) => boolean predicate hides the button when the action wouldn't make sense for the current row (e.g. "mark done" on a task that's already done). It's a UI hint only — handlers are still reachable by direct POST and should validate their own invariants if they need to. A predicate that throws is treated as "not visible" so a buggy condition can't break the page render.
Set group: "<label>" to collapse several actions into one dropdown labeled by that string; actions sharing a group render together, ungrouped ones stay standalone, and the dropdown takes the position of the group's first member. The menu uses the native popover API with CSS anchor positioning — no client JavaScript. Browsers without anchor positioning (Firefox, as of early 2026) center the menu instead of anchoring it under the trigger; it still opens, dismisses, and works.
actions: {
export_csv: { label: "CSV", group: "export", handler: async (pk, ctx) => { /* … */ } },
export_json: { label: "JSON", group: "export", handler: async (pk, ctx) => { /* … */ } },
}See examples/hooks-and-actions for a runnable walk-through — a task list whose writes feed an audit log via hooks, plus mark done and duplicate row actions.
| Package | Description |
|---|---|
@jvelo/tapemark |
Core — router, schema introspection, CRUD, rendering, display types |
@jvelo/tapemark-cli |
CLI — serve and inspect commands |
@jvelo/tapemark-hono |
Hono sub-app adapter |
@jvelo/tapemark-d1 |
Cloudflare D1 database adapter |
@jvelo/tapemark-better-sqlite3 |
better-sqlite3 database adapter |
pnpm install
pnpm run lint # eslint
pnpm run test # all tests
pnpm run build # build all packages
pnpm run prerelease # lint + test + build
pnpm run release:patch # bump, tag, push (triggers publish on CI)MPL-2.0 © Jerome Velociter