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

storium

v0.11.3

Published

Lightweight, database-agnostic storage abstraction built on Drizzle ORM

Readme

Storium

A lightweight, database-agnostic storage toolkit built on Drizzle and Zod.

I built Storium because Drizzle gives you a fantastic query builder, but every project still needs the same scaffolding on top of it: validation, sanitization, CRUD operations, migration workflows, and some coherent pattern tying it all together. I kept rebuilding that scaffolding. Poorly, at first. Then well enough that I stopped wanting to rewrite it, which felt like a milestone worth shipping.

Define your schema once with defineTable() and Storium generates TypeScript types, Zod schemas, and JSON Schema from the same column definitions. Wrap it with defineStore() to get standard CRUD, custom query hooks powered by Drizzle's query builder, and a validation pipeline that runs on every write. One source of truth, every layer covered. No more redeclaring the same shape in three different formats and hoping they don't drift apart over time.

The goal is a data-access layer that's structured enough to keep things consistent and predictable, but flexible enough that you're never fighting it. You define the stores, the queries, the transforms. Storium just makes it harder to stray from the pattern -- especially six months in when the codebase would have otherwise quietly rotted into three different ways of talking to the database.

Quick Start

npm install storium

# Plus your database driver:
npm install pg             # PostgreSQL
npm install mysql2         # MySQL
npm install better-sqlite3 # SQLite
import { storium } from 'storium'

const db = storium.connect({
  dialect: 'postgresql',
  url: process.env.DATABASE_URL,
})

const usersTable = db.defineTable('users').columns({
  id:    { type: 'uuid', primaryKey: true, default: 'uuid:v4' },
  email: { type: 'varchar', maxLength: 255, required: true },
  name:  { type: 'varchar', maxLength: 255 },
})

const users = db.defineStore(usersTable)

const user = await users.create({ email: '[email protected]', name: 'Alice' })
const found = await users.findById(user.id)
const updated = await users.update(user.id, { name: 'Alice B.' })

Features

  • Single source of truth — one schema definition drives TypeScript types, JSON Schema, Zod schemas, and database migrations
  • Repository pattern — default CRUD with extensible custom queries
  • Three-tier validation — JSON Schema for the HTTP edge, Zod for runtime, prep pipeline for business rules
  • Database agnostic — PostgreSQL, MySQL, and SQLite via Drizzle
  • Composable mixinswithBelongsTo, withMembers, withCache, transaction
  • Fastify integrationtoJsonSchema() for route validation
  • Migration tooling — thin CLI wrapping drizzle-kit
  • Stands back — Storium doesn't try to own your architecture. It gives you tools and gets out of the way.

Core Concepts

Column Definitions

Three modes for every column, depending on how much control you need:

// DSL (90% of cases — just declare what it is)
email: { type: 'varchar', maxLength: 255 }

// DSL + custom (one Drizzle tweak on top)
email: { type: 'varchar', maxLength: 255, custom: col => col.unique() }

// Raw (full Drizzle control — Storium steps aside entirely)
meta: { raw: () => jsonb('meta').default({}) }

Column metadata (readonly, hidden, required, transform, validate) works with all modes. The transform callback runs before validation and is where you'd put sanitization (trim, lowercase), enrichment, or any other pre-save logic. Basically anything you'd otherwise scatter across your route handlers ;)

Custom Queries

This is where Storium really earns its keep. Custom queries receive ctx with the database handle and all default CRUD operations. You can override defaults by name. ctx always has the originals, so you can compose on top of them rather than starting from scratch:

const usersTable = db.defineTable('users').columns(columns)

const users = db.defineStore(usersTable).queries({
  // Override create — hash password before insert
  create: (ctx) => async (input, opts) => {
    const hashed = { ...input, password: await hash(input.password) }
    return ctx.create(hashed, { ...opts, force: true })
  },

  // New query — just write Drizzle like you normally would
  findByEmail: (ctx) => async (email) =>
    ctx.drizzle.select(ctx.selectColumns)
      .from(ctx.table)
      .where(eq(ctx.table.email, email))
      .then(r => r[0] ?? null),
})

The pattern is always the same: (ctx) => async (...yourArgs) => result. Storium gives you the tools via ctx, you decide what to do with them.

Validation Schemas

Every store generates runtime validation schemas that you can use however you like. I find this especially handy for keeping validation consistent between my API layer and my business logic without duplicating definitions (and trying to keep them all in sync):

// Optionally destructure schemas from store
const { createSchema } = users.schemas

// Validate input (throws ValidationError)
createSchema.validate(data)

// Try without throwing
const result = createSchema.tryValidate(data)

// JSON Schema (e.g., as used by Fastify)
app.post('/users', {
  schema: { body: createSchema.toJsonSchema() },
})

// Zod for composition
const extended = createSchema.zod.extend({ extra: z.string() })

Index DSL

indexes: {
  email: { unique: true },                         // → users_email_unique
  school_id: {},                                   // → users_school_id_idx
  school_role: { columns: ['school_id', 'role'] }, // → users_school_role_idx
  active_email: {                                  // Partial index
    columns: ['email'],
    unique: true,
    where: (table) => isNull(table.deleted_at),
  },
  search: {                                        // Raw (full Drizzle control)
    raw: (table) => index('search_gin').using('gin', table.search_vector),
  },
}

Scaling Up

The Quick Start uses db.defineTable() + db.defineStore() — the simplest path. But as a project grows, you may want schema definitions separated from store logic. Three reasons:

  • Migration toolingdrizzle-kit imports schema files at module level, before any database connection exists. Standalone defineTable() files work at module scope; defineStore() can't because it requires a live db connection (think of the table as the blueprint, and the store as the actual construction).
  • Organization — Schemas, queries, and wiring live in separate files. Easier to navigate when you have 50+ tables.
  • Testability — Store definitions are inert DTOs. You can unit-test query functions or compose stores without a live database.

The pattern looks like this:

entities/
└── users/
    ├── user.schema.ts    ← defineTable (pure schema, no connection)
    ├── user.mixins.ts    ← reusable query patterns
    ├── user.queries.ts   ← query functions
    └── user.store.ts     ← defineStore (bundles schema + queries)
database.ts               ← connect + register all stores

Schema file — importable by drizzle-kit for migration generation. Uses validate and transform to enforce business rules at the data layer:

// entities/users/user.schema.ts
import { defineTable } from 'storium'

export const usersTable = defineTable('users').columns({
  id:    { type: 'uuid', primaryKey: true, default: 'uuid:v4' },
  email: {
    type: 'varchar', maxLength: 255, required: true,
    transform: (v) => String(v).trim().toLowerCase(),
    validate: (v, test) => {
      test(v, 'not_empty', 'Email cannot be empty')
      test(v, 'is_email')
    },
  },
  name:  { type: 'varchar', maxLength: 255 },
  slug:  {
    type: 'varchar', maxLength: 100, required: true,
    transform: (v) => String(v).trim().toLowerCase().replace(/\s+/g, '-'),
    validate: (v, test) => {
      test(v, 'is_slug', 'Slug must be lowercase letters, numbers, and hyphens')
    },
  },
}).indexes({ email: { unique: true } })

transform runs before validate — sanitize first, then check. Built-in assertions like is_email and not_empty are always available. Custom assertions like is_slug are registered at connect time (see database file below).

Mixins file — reusable query patterns you define yourself. Same (ctx) => (...args) => result shape as any custom query, so they compose naturally with built-in mixins like withBelongsTo:

// entities/users/user.mixins.ts
import { eq } from 'drizzle-orm'

export const withSoftDelete = {
  destroy: (ctx) => async (id: string, opts?) => {
    return ctx.update(id, { deleted_at: new Date() }, opts)
  },

  findActive: (ctx) => async () =>
    ctx.drizzle.select(ctx.selectColumns)
      .from(ctx.table)
      .where(eq(ctx.table.deleted_at, null)),
}

Store file — bundles the schema with queries and mixins into an inert DTO:

// entities/users/user.store.ts
import { defineStore } from 'storium'
import { usersTable } from './user.schema'
import { withSoftDelete } from './user.mixins'
import { eq, ilike } from 'drizzle-orm'

export const userStore = defineStore(usersTable).queries({
  ...withSoftDelete,

  findByEmail: (ctx) => async (email: string) =>
    ctx.drizzle.select(ctx.selectColumns)
      .from(ctx.table)
      .where(eq(ctx.table.email, email))
      .then(r => r[0] ?? null),

  search: (ctx) => async (query: string) =>
    ctx.drizzle.select(ctx.selectColumns)
      .from(ctx.table)
      .where(ilike(ctx.table.name, `%${query}%`)),
})

Database file — one composition point that wires everything together. Custom assertions are registered here so they're available to all stores:

// database.ts
import { storium } from 'storium'
import { userStore } from './entities/users/user.store'
import { postStore } from './entities/posts/post.store'

const db = storium.connect({
  dialect: 'postgresql',
  url: process.env.DATABASE_URL,
  assertions: {
    is_slug: (v) => typeof v === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v),
  },
})

export const { users, posts } = db.register({ users: userStore, posts: postStore })

Peer Dependencies

Storium declares drizzle-orm, drizzle-kit, and zod as peer dependencies — npm installs them automatically alongside storium. If you need to pin specific versions (or already have them in your project), just install them explicitly:

npm install storium [email protected] [email protected] zod@4

Database drivers (pg, mysql2, better-sqlite3) are optional peers — install whichever one matches your dialect.

defineTable Calling Conventions

defineTable has three calling conventions depending on how you manage the dialect:

// Explicit dialect (curried) — no config file needed
const usersTable = defineTable('postgresql')('users').columns(columns).indexes({...})

// Auto-detect dialect from drizzle.config.ts
const usersTable = defineTable('users').columns(columns)

// Bound function for reuse across multiple tables
const dt = defineTable('postgresql')
const usersTable = dt('users').columns(columns)
const postsTable = dt('posts').columns(columns)

Mixins

Storium ships a small set of composable mixins for common patterns. You can also write your own — a mixin is just an object of query functions that you spread into a store definition.

withBelongsTo

import { withBelongsTo } from 'storium'

const userStore = defineStore(usersTable).queries({
  ...withBelongsTo(schools, 'school_id', { alias: 'school', select: ['name'] }),
})

const { users } = db.register({ users: userStore })
const user = await users.findWithSchool(userId)

withMembers

import { withMembers } from 'storium'

const teamStore = defineStore(teamsTable).queries({
  ...withMembers(teamMembers, 'team_id'),
})

const { teams } = db.register({ teams: teamStore })
await teams.addMember(teamId, userId, { role: 'captain' })
await teams.isMember(teamId, userId)

withCache (Experimental)

Experimental — this API may change in future releases. See the source JSDoc for known limitations around cache key conventions and invalidation.

import { withCache } from 'storium'

const cachedUsers = withCache(users, redisAdapter, {
  findById: { ttl: 300, key: (id) => `user:${id}` },
})

Transactions

const result = await db.transaction(async tx => {
  const user = await users.create({ name: 'Alice' }, { tx })
  const team = await teams.create({ name: 'Alpha', owner_id: user.id }, { tx })
  return { user, team }
})

Your Own Mixins

A mixin is just a plain object whose values follow the (ctx) => (...args) => result pattern. Spread it into any store:

// mixins/withSoftDelete.ts
export const withSoftDelete = {
  destroy: (ctx) => async (id, opts?) =>
    ctx.update(id, { deleted_at: new Date() }, opts),

  findActive: (ctx) => async () =>
    ctx.drizzle.select(ctx.selectColumns)
      .from(ctx.table)
      .where(eq(ctx.table.deleted_at, null)),
}

// user.store.ts
const userStore = defineStore(usersTable).queries({
  ...withSoftDelete,
  ...withBelongsTo(schools, 'school_id'),
  findByEmail: (ctx) => async (email) => { ... },
})

Because mixins are plain objects, they compose with each other and with Storium's built-in mixins via spread. No special API — just JavaScript.

Fastify Integration

Storium's JSON Schema output plugs directly into Fastify's route validation via toJsonSchema(). Extra properties, required fields, and OpenAPI metadata can be passed as options:

const { createSchema, selectSchema } = users.schemas

app.post('/users', {
  schema: {
    body: createSchema.toJsonSchema({
      title: 'CreateUser',
      properties: { invite_code: { type: 'string', minLength: 8 } },
      required: ['invite_code'],
    }),
    response: { 201: selectSchema.toJsonSchema() },
  },
}, handler)

Migrations

Storium can use the same drizzle.config.ts that drizzle-kit already reads — no separate config file. If you already have a drizzle-kit setup, storium slots right in. Storium-specific keys like seeds sit alongside drizzle-kit keys; drizzle-kit ignores what it doesn't recognize. But you can always name this file whatever you want. If you want to keep things simple, just call it storium.config.ts instead (same structure, still works).

import type { StoriumConfig } from 'storium'

export default {
  dialect: 'postgresql',
  dbCredentials: { url: process.env.DATABASE_URL! },
  out: './migrations',
  schema: ['./src/entities/**/*.schema.ts'],
  stores: ['./src/entities/**/*.store.ts'], // storium-only — drizzle-kit ignores this  
  seeds: './seeds',                         // storium-only — drizzle-kit ignores this
} satisfies StoriumConfig

Storium ships a thin CLI wrapper for convenience:

npx storium generate   # Diff schemas → create SQL migration
npx storium migrate    # Apply pending migrations
npx storium push       # Push directly (dev only)
npx storium status     # Check migration state
npx storium seed       # Run seed files

This is purely a convenience — you can use npx drizzle-kit generate, npx drizzle-kit migrate, etc. directly with the same config. The storium CLI just adds the seed command on top.

Escape Hatches

Storium is not trying to be a walled garden. Every abstraction has a way out.

Drizzle

Direct Drizzle access is always available. If you need to drop down a level, nothing is stopping you:

// Typed Drizzle instance — dialect inferred from config, full autocomplete
db.drizzle.execute(sql`SELECT 1`)

// Bring your own Drizzle (dialect auto-detected from instance type)
import { storium } from 'storium'
const db = storium.fromDrizzle(myDrizzleInstance)

// Raw columns bypass the DSL entirely
meta: { raw: () => jsonb('meta').default({}) }

// Raw indexes bypass the index DSL entirely
search: { raw: (table) => index('search_gin').using('gin', table.search_vector) }

// Custom queries give you the full Drizzle query builder
findByEmail: (ctx) => async (email) =>
  ctx.drizzle.select(ctx.selectColumns)
    .from(ctx.table)
    .where(eq(ctx.table.email, email))
    .then(r => r[0] ?? null)

Zod

Every generated schema exposes its underlying Zod schema directly. Use it to compose, extend, or integrate with any Zod-aware library:

// Extract schema for easier re-use
const { createSchema } = users.schemas

// Extend a generated schema
const signupSchema = createSchema.zod.extend({
  password: z.string().min(8),
  invite_code: z.string().optional(),
})

// Compose schemas
const loginSchema = z.object({
  email: createSchema.zod.shape.email,
  password: z.string(),
})

// Use with any Zod-compatible library (tRPC, react-hook-form, etc.)
const router = t.router({
  createUser: t.procedure.input(createSchema.zod).mutation(...)
})

License

MIT