npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@allodium/admin

v0.5.0

Published

Schema-driven CRUD admin generated from your Drizzle schema — no parallel config language.

Readme

@allodium/admin

Admin screens described by a small JSON file, rendered by unstyled components you point CSS at.

Two lanes: a developer console for building the site, and an admin dashboard for running it — where the console generates the dashboard. This package is the dashboard half plus the metadata both lanes read.

npm install @allodium/admin
npm install react          # peer, only for the /react entry

Requires Node 20+ and PostgreSQL.

Status. Everything below is the 0.2.x line. Published 0.1.0 contained only createTableRegistry. The format is settling — pin exactly if you adopt it early.

Next.js: transpile this package, externalize the rest

The /react entry is client components. Next must compile them with your copy of React, or the server render throws Invalid hook call while the browser quietly recovers — tests that only look at the hydrated page pass, and the server logs fill up.

// next.config.ts
export default {
  transpilePackages: ['@allodium/admin'],
  serverExternalPackages: ['pg', 'argon2', '@allodium/db', '@allodium/auth', '@allodium/storage'],
};

The others carry native or Node-only code and must stay external. Putting @allodium/admin in that list works under npm's hoisting and breaks under pnpm's, which is the kind of difference that surfaces only on the second machine.

The idea

A view definition is a JSON file in your repo, committed and reviewed like code:

{
  "table": "posts",
  "title": "Blog posts",
  "fields": [
    { "column": "title", "label": "Headline", "help": "Shown in search results." },
    { "kind": "relation", "column": "author_id",
      "relation": { "table": "authors", "display": "name" } },
    { "kind": "m2m", "through": "post_tags", "near": "post_id",
      "far": "tag_id", "farTable": "tags", "display": "label" }
  ]
}

It carries only what a human decided. Column types, nullability, enum members, which table a foreign key points at — all read from the live database at render time and never duplicated here, because a definition that restated the schema would rot at your next migration.

Omission means sensible default, never off. This is a complete, working screen:

{ "table": "authors" }

Why files and not database rows: config in the database cannot be diffed, cannot be reviewed in a pull request, and drifts between environments. Schema changes are code; screens are too.

Rendering

Three layers. Enter wherever a screen needs you to.

Layer 1 — the definition

Written by hand or by the Allodium console's Admin Builder.

Layer 2 — headless hooks

All data and behavior, no markup at all.

import { useAdminForm } from '@allodium/admin/react';

const { fields, values, setValue, save, problems, dirty, optionsFor } =
  useAdminForm({ baseUrl: '/api/admin', view: 'posts', id });

Fetching, pagination, search, dirty tracking, validation, relation options, save and delete. Render whatever you like.

Layer 3 — unstyled components

Correct, accessible markup with zero visual opinion — no colours, no spacing, not one class of our own.

import { AdminForm, AdminList } from '@allodium/admin/react';

<AdminList config={{ baseUrl: '/api/admin', view: 'posts' }} onSelect={open} />
<AdminForm config={{ baseUrl: '/api/admin', view: 'posts' }} id={id} />

The entire styling contract is data attributes:

[data-allodium="field"]      /* one field wrapper        */
[data-field="title"]         /* that wrapper, by column  */
[data-widget="select"]       /* that wrapper, by input   */
[data-required] [data-invalid]

No theme prop, no class-name API, nothing to learn beyond the attribute names. When one screen needs to be genuinely different, drop it to the hooks — same data, same behavior, your markup.

Labels are tied to inputs, help text is wired through aria-describedby, and errors use aria-invalid. A generated admin that fails a screen reader would be worse than hand-written HTML.

Links out of the admin

A screen that edits published content needs a way to the published thing, and only your app knows the address. So a view carries URL templates over the row, and a condition:

"links": [
  { "label": "View on site", "href": "/blog/{slug}", "target": "_blank",
    "when": [{ "column": "status", "value": "published" }] },
  { "label": "Preview", "href": "/preview/posts/{id}", "in": ["form"] }
]

AdminList renders them in a trailing cell that does not open the row; AdminForm renders them under the heading, for saved records only. Values are URL-encoded as they are substituted, a row whose placeholder is empty gets no link, and the template must be a path or an absolute http(s) URL — a definition cannot put javascript: on an operator's screen. when is the same predicate shape as every other filter, so "one table, two public routes" is two links with opposite conditions:

{ "label": "View", "href": "/portfolio/{slug}", "when": { "kind": "professional" } },
{ "label": "View", "href": "/playground/{slug}", "when": { "kind": "playground" } }

Style them through [data-allodium="link"], [data-allodium="links"] and [data-allodium="links-header"].

The server

Framework-free: it takes anything with a pg-shaped query method, so it works with a Pool, a Client, or a proxy, under any HTTP framework.

import { createViewResolver, createFileViewStore, createAdminRoutes } from '@allodium/admin/server';

// Who may do what is REQUIRED. Pass a policy from @allodium/auth's grant loader,
// or the explicit 'unrestricted' for a caller that is already the trust boundary.
const resolver = createViewResolver(pool, { access: policy });

await resolver.resolve(def);                    // definition + live catalog → a full screen spec
await resolver.list(def, { page: 1, actor });   // rows, with relation labels already joined
await resolver.read(def, id, actor);            // one record, m2m members included; null outside the grant
await resolver.create(def, values, actor);
await resolver.update(def, id, values, actor);  // columns + m2m set reconciliation
await resolver.remove(def, id, actor);
await resolver.options(def, 'author_id', { search, actor });  // a relation picker's choices

Every call takes the actor — { userId, roles, claims }, or the public actor for nobody — and the grant decides what comes back. A refusal throws (Not permitted: …); a row outside a grant reads as null, the same as a row that does not exist, so ids cannot be enumerated. A write is checked again after it lands, inside its transaction, so an update cannot move a row out of the grant that allowed it, and a row-restricted create is possible: write it, then look. A grant's row filter may walk foreign keys (order_id.customer_id = $actor.customerId) when the owner is on another table; the resolver compiles one subquery per hop from the catalog, and a path it cannot follow refuses rather than widens. Relation labels and picker options are reads of the other table and are gated as such, row scope included: an operator who may see only their own customer is not shown every customer's name down an orders list.

Relation labels resolve in the same query as the rows, so a list does not become one query per row per relation.

Over HTTP

const views = createFileViewStore({ dir: 'admin/views' });   // reads *.view.json, live
const routes = createAdminRoutes({
  resolver,
  views,
  actorFor: async (request) => /* resolve the session cookie, or PUBLIC_ACTOR */,
  allowOrigins: ['https://admin.example.com'],               // omit for same-origin
  onWrite: (e) => audit(e),                                  // every create/update/delete
});

// Next.js: app/api/admin/[...path]/route.ts
const handler = (req, { params }) => routes.handle(req, params.path);
export { handler as GET, handler as POST, handler as PATCH, handler as PUT, handler as DELETE, handler as OPTIONS };

The URL contract @allodium/admin/react speaks, relative to the mount point:

| | | |---|---| | GET <view>/view | the resolved screen spec — gated as a read, because a table's shape is a leak | | GET <view>/list?page=&pageSize=&search=&sort=&direction=&filter=col:op:value&scope=… | rows | | GET <view>/record/<id> | one row, or 404 | | POST <view>/record | create, 201 | | PATCH <view>/record/<id> | update (PUT accepted) | | DELETE <view>/record/<id> | | | GET <view>/options/<field>?search= | a picker's choices |

<view> is a .view.json name, or a table name when no such file exists — a granted table with no curated screen renders its implicit one. Refusals map to 403, a row outside a grant to 404, a bad filter to 400, a constraint violation to 400 with Postgres's own detail. Writes from an origin that is neither this host nor allowlisted, or with a body not typed as JSON, are refused before anything else runs — CORS headers say who may read a response, not who may send a request, and the difference is a cross-site request forgery.

There is no separate content API. A public site mounts the same routes and the public actor gets what its role was granted — published posts, approved comments — through the one enforcement path.

Files

Uploads are a relation to a files table, edited by uploading. Give the routes a byte store and the serving headers from @allodium/storage:

import { createLocalDiskDriver, assetContentHeaders, diskExtension } from '@allodium/storage';

createAdminRoutes({
  …,
  uploads: {
    driver: createLocalDiskDriver({ root: () => process.env.UPLOADS_DIR! }),
    serve: assetContentHeaders,   // the stored-XSS policy lives there, not here
    extension: diskExtension,
    maxBytes: 20 * 1024 * 1024,   // default 10 MiB, enforced on the stream
    accept: ['image/*', 'application/pdf'],
  },
});

That adds three endpoints under the reserved name _files:

| | | |---|---| | POST _files | multipart with a file field (and optional title), or a raw body with an X-Filename header — gated as a create on the files table; 201 with the row | | GET _files/<id> | the bytes, with Content-Type, nosniff, an inline-or-attachment disposition, and a cache policy — gated as a read of the row, so a file the actor may not see is a 404; ?download forces an attachment | | DELETE _files/<id> | the row and the bytes |

A raster image is checked against its own first bytes, since the browser will render it inline; a "png" that is not one is a 415. The files table is the shape the console's Files silo uses (disk_name, filename, mime_type, filesize_bytes, title); table and columns rename it.

On the form, a relation field with "widget": "file" shows the current filename, an upload control and Remove; the upload's row id becomes the field's value, and the record saves a foreign key exactly as it would from a picker. uploadFile(config, file) from /react is the same call for a custom form. Serve a cover image on the public site from the same mount: <img src="/api/content/_files/{cover_image_id}">, with the public granted read on files.

Many-to-many

Detected as a composite primary key whose columns are all foreign keys — not "a table with two foreign keys", which misreads a join table carrying extra columns (granted_at, granted_by, sort_order). Writes reconcile to exactly the submitted set: delete what is gone, insert what is new, leave untouched rows alone so join-table payload survives an edit.

Help text comes from your schema

COMMENT ON COLUMN becomes a field's default help text; COMMENT ON TABLE becomes a view's description. Describe a column once, in the schema, and every screen showing it inherits that — no second copy to keep in sync.

Warnings, not silence

A definition that resolves but would render something useless — a select with no options, a list column that is not a field — comes back with warnings attached. "The dropdown is empty and I don't know why" is the failure mode this project exists to avoid.

Also here: the Drizzle table registry

import { createTableRegistry } from '@allodium/admin';

const registry = createTableRegistry({
  schema: appSchema,
  maskedColumns: { users: ['password'], sessions: ['access_hash', 'refresh_hash'] },
});

Runtime metadata from your Drizzle schema — names, SQL types, primary keys, the foreign key graph — with secret columns masked end to end: never selected, never editable, never on the wire. Masking is the one piece of judgment a deployment must supply.

License

MIT. No CLA.