Skip to content

jvelo/tapemark

🎞️ tapemark

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).

Quick start

npx @jvelo/tapemark-cli serve ~/path/to/your.db

Opens an admin UI at http://localhost:3333. Requires Node.js ≥ 20.

Hacking on tapemark from a clone? Run pnpm install, then pnpm exec tsx packages/cli/src/index.ts serve … — the same CLI, straight from source.

CLI commands

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 relaxed

Inspect — 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.db

Embed in your app

Install 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.js

Hono (Cloudflare Workers / D1)

import { 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";
}

Any framework

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.redirect

Options

tapemark({
  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 ignores PRAGMA foreign_keys, so constraints stay on regardless of this setting.

Hooks and custom actions

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.

Packages

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

Development

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)

License

MPL-2.0 © Jerome Velociter

About

A database web admin panel and browser for SQLite

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors