A server-rendered admin panel for Drizzle ORM, PostgreSQL-backed Knex, and Persistence ORM applications inspired by RoR Active Admin. Provides automatic CRUD interfaces for your database tables with minimal configuration.
- Zero frontend build step - server-rendered HTML with Tailwind CSS via CDN
- Dark mode UI inspired by shadcn
- JWT authentication with bcrypt password hashing
- File-based resource registration
- Custom member and collection actions
- Mount into existing Hono or Express apps, or run standalone
- Sidebar folder grouping for organizing resources
- Works with Node.js and Deno
- PostgreSQL support for Drizzle, Knex, and Persistence ORM (more dialects planned)
pnpm add drizzle-admin
# or
npm install drizzle-adminimport { DrizzleAdmin } from '@dafu/drizzle-admin'Or add to your import map:
{
"imports": {
"drizzle-admin": "jsr:@dafu/drizzle-admin"
}
}DrizzleAdmin includes drizzle-orm for the default Drizzle path and expects you to provide a database driver such as pg.
Knex support is optional. If you use Knex, install it in your application too:
pnpm add knex pgPersistence support is optional. If @dannyfuf/persistence is not available from your registry, install it from the private GitHub repo over SSH:
pnpm add git+ssh://git@github.com/dannyfuf/persistence.gitDrizzleAdmin requires an admin users table with specific columns. Add this to your Drizzle schema:
// db/schema/admin-users.ts
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
export const adminUsers = pgTable('admin_users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})The table must have these columns (TypeScript property names): id, email, passwordHash, createdAt, updatedAt.
Create a directory for your admin resources. Each file exports a resource definition using defineResource():
admin/
resources/
posts.ts
users.ts
categories.ts
// admin/resources/posts.ts
import { defineResource } from 'drizzle-admin'
import { posts } from '../../db/schema/posts'
export default defineResource(posts)// admin/index.ts
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { db } from '../db'
import { adminUsers } from '../db/schema/admin-users'
const admin = new DrizzleAdmin(
defineConfig({
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
port: 3001,
})
)
// Seed the first admin user
await admin.seed({
email: 'admin@example.com',
password: 'changeme',
})
// Start the admin server
await admin.start()Run it:
npx tsx admin/index.tsThen open http://localhost:3001 and sign in.
Knex support is PostgreSQL-only. Because Knex does not expose table metadata like Drizzle, Knex resources use explicit column metadata.
// admin/tables.ts
import { defineKnexAdminUsers, defineKnexTable } from 'drizzle-admin'
export const adminUsers = defineKnexAdminUsers('admin_users', [
{ name: 'id', sqlName: 'id', dataType: 'integer', isNullable: false, isPrimaryKey: true, hasDefault: true },
{ name: 'email', sqlName: 'email', dataType: 'text', isNullable: false, isPrimaryKey: false, hasDefault: false },
{ name: 'passwordHash', sqlName: 'password_hash', dataType: 'text', isNullable: false, isPrimaryKey: false, hasDefault: false },
{ name: 'createdAt', sqlName: 'created_at', dataType: 'timestamp', isNullable: false, isPrimaryKey: false, hasDefault: true },
{ name: 'updatedAt', sqlName: 'updated_at', dataType: 'timestamp', isNullable: false, isPrimaryKey: false, hasDefault: true },
])
export const postsTable = defineKnexTable('posts', [
{ name: 'id', sqlName: 'id', dataType: 'integer', isNullable: false, isPrimaryKey: true, hasDefault: true },
{ name: 'title', sqlName: 'title', dataType: 'text', isNullable: false, isPrimaryKey: false, hasDefault: false },
{ name: 'body', sqlName: 'body', dataType: 'text', isNullable: false, isPrimaryKey: false, hasDefault: false },
{ name: 'createdAt', sqlName: 'created_at', dataType: 'timestamp', isNullable: false, isPrimaryKey: false, hasDefault: true },
{ name: 'updatedAt', sqlName: 'updated_at', dataType: 'timestamp', isNullable: false, isPrimaryKey: false, hasDefault: true },
])The admin users metadata must include these logical column names: id, email, passwordHash, createdAt, and updatedAt. sqlName maps each logical name to the actual database column.
// admin/resources/posts.ts
import { defineKnexResource } from 'drizzle-admin'
import { createCsvExportAction } from 'drizzle-admin/actions/csv'
import { postsTable } from '../tables'
export default defineKnexResource(postsTable, {
permitParams: ['title', 'body'],
index: {
filters: ['title'],
},
collectionActions: [
createCsvExportAction(postsTable),
],
})// admin/index.ts
import knex from 'knex'
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { adminUsers } from './tables'
const db = knex({
client: 'pg',
connection: process.env.DATABASE_URL,
})
const admin = new DrizzleAdmin(
defineConfig({
backend: 'knex',
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
})
)
await admin.seed({ email: 'admin@example.com', password: 'changeme' })
await admin.start()Persistence support is PostgreSQL-only. DrizzleAdmin reads table names, primary keys, column types, nullability, defaults, and enum values from Persistence generated schema metadata, so Persistence resources do not accept hand-written column arrays.
// persistence.ts
import knex from 'knex'
import { Model } from '@dannyfuf/persistence'
import { persistenceSchema } from './generated/persistenceSchema'
export const connection = knex({
client: 'pg',
connection: process.env.DATABASE_URL,
})
Model.configure({ connection, schema: persistenceSchema })// models.ts
import { Model, defineModel } from '@dannyfuf/persistence'
class AdminUserRecord extends Model<'admin_users'> {
static tableName = 'admin_users' as const
}
class UserRecord extends Model<'users'> {
static tableName = 'users' as const
}
export const AdminUser = defineModel(AdminUserRecord)
export const User = defineModel(UserRecord)Persistence admin-user tables use database column names directly and must include id, email, password_hash, created_at, and updated_at. DrizzleAdmin normalizes password_hash to passwordHash internally for authentication.
// admin/resources/users.ts
import { definePersistenceResource } from 'drizzle-admin'
import { createCsvExportAction } from 'drizzle-admin/actions/csv'
import { User } from '../../models'
export default definePersistenceResource(User, {
permitParams: ['email', 'name'],
index: {
filters: ['email'],
},
collectionActions: [
createCsvExportAction(User),
],
})// admin/index.ts
import '../persistence'
import { DrizzleAdmin, defineConfig, definePersistenceAdminUsers } from 'drizzle-admin'
import { AdminUser } from '../models'
const admin = new DrizzleAdmin(
defineConfig({
backend: 'persistence',
dialect: 'postgresql',
adminUsers: definePersistenceAdminUsers(AdminUser),
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
})
)
await admin.seed({ email: 'admin@example.com', password: 'changeme' })
await admin.start()Call Model.configure({ connection, schema }) before DrizzleAdmin.build() or DrizzleAdmin.start(). Regenerate your Persistence schema after migrations so persistenceSchema includes columnMetadata; DrizzleAdmin uses that generated metadata as the source of truth for forms, filters, formatting, CSV export, and CRUD.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
db |
Drizzle DB or Knex instance | Drizzle/Knex only | - | Your configured database connection. Persistence uses Model.configure() instead. |
dialect |
'postgresql' |
Yes | - | Database dialect (only PostgreSQL supported currently) |
adminUsers |
Drizzle table, Knex metadata, or Persistence repository factory | Yes | - | Table/model for admin user authentication |
backend |
'drizzle' | 'knex' | 'persistence' |
No | 'drizzle' |
Use 'knex' for Knex mode or 'persistence' for Persistence ORM mode |
sessionSecret |
string |
Yes | - | Secret key for signing JWT tokens (use a strong random string) |
resourcesDir |
string |
Yes | - | Path to directory containing resource definition files |
port |
number |
No | 3001 |
Port to run the admin server on |
basePath |
string |
No | '' |
Base URL path where the admin panel is mounted (e.g. '/admin') |
Use basePath when you want the admin panel to live under a sub-path. All routes, redirects, sidebar links, and form actions are automatically prefixed:
defineConfig({
// ...
basePath: '/admin',
})With basePath: '/admin', the login page is served at /admin/login, resources at /admin/posts, etc. Trailing slashes are stripped automatically.
Instead of running a standalone server with start(), you can use build() to get a handler and mount it into your existing application.
Returns a DrizzleAdminHandler with two properties:
app- the internal Hono app instancefetch- a standard Webfetchhandler:(request: Request) => Response | Promise<Response>
const admin = new DrizzleAdmin(
defineConfig({
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
basePath: '/admin',
})
)
const handler = await admin.build()Mount DrizzleAdmin into an existing Hono application:
import { Hono } from 'hono'
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { honoAdapter } from 'drizzle-admin/hono'
const app = new Hono()
const admin = new DrizzleAdmin(defineConfig({ /* ... */ basePath: '/admin' }))
const handler = await admin.build()
app.route('/admin', honoAdapter(handler))
export default appMount DrizzleAdmin into an existing Express application:
import express from 'express'
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { expressAdapter } from 'drizzle-admin/express'
const app = express()
const admin = new DrizzleAdmin(defineConfig({ /* ... */ basePath: '/admin' }))
const handler = await admin.build()
app.use('/admin', expressAdapter(handler))
app.listen(3000)For any framework that supports the Web fetch API (Deno, Bun, Cloudflare Workers, etc.):
const handler = await admin.build()
Deno.serve({ port: 3000 }, handler.fetch)The simplest resource just wraps a Drizzle table:
import { defineResource } from 'drizzle-admin'
import { posts } from '../../db/schema/posts'
export default defineResource(posts)DrizzleAdmin will automatically:
- Derive the URL path from the table name (
posts->/posts) - Derive the display name (
posts->Post) - Extract all columns and render appropriate form inputs
- Hide password columns from views
- Skip auto-managed columns (primary keys,
createdAt,updatedAt) in create forms - Show auto-managed columns as disabled (read-only) fields on edit forms
Knex resources use defineKnexResource() with metadata from defineKnexTable():
import { defineKnexResource } from 'drizzle-admin'
import { postsTable } from '../tables'
export default defineKnexResource(postsTable, {
index: { filters: ['title'] },
})The same resource options are available for Drizzle and Knex resources. Knex column dataType values should use the admin metadata types: text, integer, boolean, enum, timestamp, or json.
Persistence resources use definePersistenceResource() with a repository factory returned by Persistence defineModel():
import { definePersistenceResource } from 'drizzle-admin'
import { User } from '../models'
export default definePersistenceResource(User, {
index: { filters: ['email'] },
})Do not pass column metadata. Column names and admin metadata come from the generated Persistence schema and use database column names directly, including snake_case columns such as created_at.
Pass a second argument to defineResource() to customize behavior:
import { defineResource } from 'drizzle-admin'
import { posts } from '../../db/schema/posts'
export default defineResource(posts, {
folder: 'Content',
permitParams: ['title', 'body', 'status'],
index: {
perPage: 50,
exclude: ['body'], // hide 'body' column from the listing
filters: ['title', 'status'],
},
show: {
exclude: ['internalNotes'],
},
form: {
columns: ['title', 'body', 'status'], // only show these fields in forms
},
})export default defineResource(posts, {
folder: 'Content',
})Groups resources under collapsible folders in the sidebar. Resources without a folder appear at the top level. Folders auto-expand when the active resource is inside them. Resources are sorted alphabetically within each group.
export default defineResource(posts, {
permitParams: ['title', 'body', 'status'],
})| Option | Type | Description |
|---|---|---|
permitParams |
string[] |
Only these columns are editable in forms and accepted in form submissions |
When set, only the listed columns appear as editable fields. Auto-managed columns (id, createdAt, updatedAt) still appear as disabled fields on edit forms. This also acts as a server-side allowlist -- columns not in the list are ignored during form processing.
| Option | Type | Description |
|---|---|---|
perPage |
number |
Records per page (default: 20) |
columns |
string[] |
Whitelist - only show these columns |
exclude |
string[] |
Blacklist - hide these columns |
filters |
string[] |
Optional, order-sensitive list of filterable columns |
Filters are opt-in. When index.filters is omitted or set to [], the index page renders no filter UI.
export default defineResource(posts, {
index: {
columns: ['id', 'title', 'status', 'featured'],
filters: ['title', 'status', 'featured'],
},
})Declared filters are rendered in the order provided and round-trip through the index URL as namespaced query params such as filter_title. Supported filter types are:
- text: case-insensitive contains matching
- integer: exact matching
- boolean: exact matching
- enum: exact matching
- timestamp: exact matching
| Option | Type | Description |
|---|---|---|
columns |
string[] |
Whitelist - only show these columns |
exclude |
string[] |
Blacklist - hide these columns |
| Option | Type | Description |
|---|---|---|
columns |
string[] |
Whitelist - only show these fields |
exclude |
string[] |
Blacklist - hide these fields |
export default defineResource(posts, {
memberActions: [
{
name: 'Archive',
destructive: true, // shows confirmation modal (default: true)
handler: async (id, db) => {
await db
.update(posts)
.set({ status: 'archived' })
.where(eq(posts.id, Number(id)))
},
},
{
name: 'Publish',
destructive: false, // submits directly without confirmation
handler: async (id, db) => {
await db
.update(posts)
.set({ status: 'published', publishedAt: new Date() })
.where(eq(posts.id, Number(id)))
},
},
],
})| Option | Type | Description |
|---|---|---|
name |
string |
Button label for the action |
handler |
(id, db) => Promise<void> |
Function that receives the record ID and configured db instance |
destructive |
boolean |
If true (default), shows a confirmation modal before executing |
Member actions appear on the show page for each record. Drizzle resource actions receive the Drizzle database instance. Knex resource actions receive the Knex instance.
export default defineResource(posts, {
collectionActions: [
{
name: 'Publish All Drafts',
handler: async (c, db) => {
await db
.update(posts)
.set({ status: 'published' })
.where(eq(posts.status, 'draft'))
},
},
],
})| Option | Type | Description |
|---|---|---|
name |
string |
Button label for the action |
handler |
(c, db) => Promise<void | Response> |
Function that receives Hono context and the configured db. Can return a Response for downloads. |
Collection actions appear on the index page alongside the "Create New" button.
DrizzleAdmin ships with a CSV export collection action:
import { defineResource } from 'drizzle-admin'
import { createCsvExportAction } from 'drizzle-admin/actions/csv'
import { posts } from '../../db/schema/posts'
export default defineResource(posts, {
collectionActions: [
createCsvExportAction(posts),
],
})This adds an "Export CSV" button to the index page that downloads all records as a CSV file.
For Knex resources, pass the same KnexTableDefinition used by defineKnexResource():
createCsvExportAction(postsTable)For Persistence resources, pass the same repository factory used by definePersistenceResource():
createCsvExportAction(User)DrizzleAdmin automatically maps Drizzle column types to appropriate form inputs. Knex users provide the same admin metadata type explicitly, and Persistence users get it from generated columnMetadata:
| Drizzle Type | Admin Input | Notes |
|---|---|---|
text, varchar |
Text input | |
integer, serial |
Number input | |
boolean |
Checkbox | |
timestamp, date |
Datetime picker | |
json, jsonb |
Textarea | Displays formatted JSON |
pgEnum |
Select dropdown | Options derived from enum values |
| Password columns | Password input | Detected by column name containing "password" |
These columns are automatically detected as "auto-managed":
- Primary key columns
createdAt/created_at(when they have a default value)updatedAt/updated_at(when they have a default value)
On create forms, auto-managed columns are hidden entirely.
On edit forms, auto-managed columns are shown as disabled (read-only) fields with a muted visual style. This lets users see the values without accidentally modifying them.
Password columns are automatically hidden from index and show views.
DrizzleAdmin uses JWT-based authentication stored in HTTP-only cookies:
- Passwords are hashed with bcrypt (12 salt rounds)
- Sessions expire after 24 hours
- CSRF protection on all form submissions
- Cookies are
HttpOnly,SameSite=Strict, andSecurein production
Use the seed() method to create admin users. It's safe to call on every startup - it skips if the email already exists:
await admin.seed({ email: 'admin@example.com', password: 'changeme' })You can pass additional fields if your admin users table has extra columns:
await admin.seed({
email: 'admin@example.com',
password: 'changeme',
name: 'Admin User',
role: 'super_admin',
})The hashPassword function is exported for use outside of DrizzleAdmin (e.g., in custom scripts or seeders):
import { hashPassword } from 'drizzle-admin'
const hash = await hashPassword('my-password')For each resource, DrizzleAdmin generates these routes (prefixed with basePath if configured):
| Method | Path | Description |
|---|---|---|
| GET | /:resource |
Index - paginated table listing |
| GET | /:resource/new |
Create form |
| POST | /:resource |
Create record |
| GET | /:resource/:id |
Show record details |
| GET | /:resource/:id/edit |
Edit form |
| POST | /:resource/:id?_method=PUT |
Update record |
| POST | /:resource/:id?_method=DELETE |
Delete record |
| POST | /:resource/:id/actions/:name |
Execute member action |
| POST | /:resource/actions/:name |
Execute collection action |
Authentication routes:
| Method | Path | Description |
|---|---|---|
| GET | /login |
Login page |
| POST | /login |
Authenticate |
| GET/POST | /logout |
Sign out |
The root path (/) redirects to the first resource's index page.
DrizzleAdmin derives URL paths and display names from your SQL table names:
| SQL Table Name | URL Path | Display Name |
|---|---|---|
posts |
/posts |
Post |
sale_orders |
/sale-orders |
Sale Order |
user_profiles |
/user-profiles |
User Profile |
Here's a complete example with a blog schema using Hono integration:
// db/schema.ts
import { pgTable, serial, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core'
export const statusEnum = pgEnum('post_status', ['draft', 'published', 'archived'])
export const adminUsers = pgTable('admin_users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
body: text('body').notNull(),
status: statusEnum('status').default('draft').notNull(),
featured: boolean('featured').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const categories = pgTable('categories', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})// admin/resources/posts.ts
import { defineResource } from 'drizzle-admin'
import { createCsvExportAction } from 'drizzle-admin/actions/csv'
import { eq } from 'drizzle-orm'
import { posts } from '../../db/schema'
export default defineResource(posts, {
folder: 'Content',
permitParams: ['title', 'body', 'status', 'featured'],
index: {
perPage: 25,
exclude: ['body'],
filters: ['title', 'status', 'featured'],
},
memberActions: [
{
name: 'Publish',
destructive: false,
handler: async (id, db) => {
await db.update(posts).set({ status: 'published' }).where(eq(posts.id, Number(id)))
},
},
{
name: 'Archive',
handler: async (id, db) => {
await db.update(posts).set({ status: 'archived' }).where(eq(posts.id, Number(id)))
},
},
],
collectionActions: [
createCsvExportAction(posts),
],
})// admin/resources/categories.ts
import { defineResource } from 'drizzle-admin'
import { categories } from '../../db/schema'
export default defineResource(categories, {
folder: 'Content',
})// admin/index.ts
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { drizzle } from 'drizzle-orm/node-postgres'
import { adminUsers } from '../db/schema'
const db = drizzle(process.env.DATABASE_URL!)
const admin = new DrizzleAdmin(
defineConfig({
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
})
)
await admin.seed({ email: 'admin@example.com', password: 'changeme' })
await admin.start()// server.ts
import { Hono } from 'hono'
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { honoAdapter } from 'drizzle-admin/hono'
import { drizzle } from 'drizzle-orm/node-postgres'
import { adminUsers } from './db/schema'
const db = drizzle(process.env.DATABASE_URL!)
const admin = new DrizzleAdmin(
defineConfig({
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
basePath: '/admin',
})
)
await admin.seed({ email: 'admin@example.com', password: 'changeme' })
const handler = await admin.build()
const app = new Hono()
app.get('/', (c) => c.text('Hello!'))
app.route('/admin', honoAdapter(handler))
export default app// server.ts
import express from 'express'
import { DrizzleAdmin, defineConfig } from 'drizzle-admin'
import { expressAdapter } from 'drizzle-admin/express'
import { drizzle } from 'drizzle-orm/node-postgres'
import { adminUsers } from './db/schema'
const db = drizzle(process.env.DATABASE_URL!)
const admin = new DrizzleAdmin(
defineConfig({
db,
dialect: 'postgresql',
adminUsers,
sessionSecret: process.env.ADMIN_SESSION_SECRET!,
resourcesDir: './admin/resources',
basePath: '/admin',
})
)
await admin.seed({ email: 'admin@example.com', password: 'changeme' })
const handler = await admin.build()
const app = express()
app.get('/', (req, res) => res.send('Hello!'))
app.use('/admin', expressAdapter(handler))
app.listen(3000)Creates a new admin instance. Validates the admin users table schema and dialect at construction time.
Builds the admin panel without starting a server. Returns a DrizzleAdminHandler that can be mounted into an existing application via the Hono or Express adapters, or used directly with its fetch method.
interface DrizzleAdminHandler {
/** The internal Hono app */
app: Hono
/** Standard Web fetch handler */
fetch: (request: Request) => Response | Promise<Response>
}Loads resources, sets up all routes, and starts the HTTP server. Auto-detects Node.js vs Deno at runtime.
Creates an admin user if one with that email doesn't already exist. Safe to call on every startup. Accepts additional fields beyond email and password that are passed through to the insert.
Loads and validates resources without starting the server. Called automatically by build() and start().
Returns the loaded resource definitions. Only available after initialize(), build(), or start() has been called.
Type-safe helper for creating configuration objects. Provides TypeScript inference for your admin users table type.
Creates a resource definition for DrizzleAdmin to load. Must be the default export of a file in your resourcesDir.
Creates explicit table metadata for Knex resources and admin users. Every column must include name, sqlName, dataType, isNullable, isPrimaryKey, and hasDefault.
Creates Knex admin-user table metadata. The logical column names must include id, email, passwordHash, createdAt, and updatedAt.
Creates a Knex resource definition for DrizzleAdmin to load. Must be the default export of a file in your resourcesDir.
Marks a Persistence repository factory as the admin-user model. The underlying table must have id, email, password_hash, created_at, and updated_at columns.
Creates a Persistence resource definition for DrizzleAdmin to load. Must be the default export of a file in your resourcesDir. Column metadata is inferred from Persistence generated schema metadata.
Factory function that creates a collection action for exporting all records as CSV. Import from drizzle-admin/actions/csv.
Hashes a plaintext password using bcrypt with 12 salt rounds. Useful for creating admin users in custom scripts or seeders.
Returns the Hono sub-app from a handler. Import from drizzle-admin/hono.
Converts a handler into Express/Connect-compatible middleware. Import from drizzle-admin/express.
pnpm install
pnpm test # run tests
pnpm typecheck # type check without emitting
pnpm build # compile TypeScriptMIT