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

drizzle-policy

v0.1.0

Published

Application-side policy enforcement for Drizzle ORM.

Readme

drizzle-policy

Application-side policy enforcement for Drizzle ORM.

Wrap your Drizzle client once, define the rules your app cares about, and keep writing normal Drizzle queries. Drizzle Policy adds policy predicates, injects safe values, or rejects unsafe operations before the query reaches the database.

It is useful for:

  • tenant, workspace, organization, or account isolation
  • soft-delete filtering
  • blocking direct raw execution by default
  • custom read, insert, update, and delete rules that live with your application code

Drizzle Policy is not a replacement for database-native row-level security. It protects code that uses the wrapped client.

Install

npm install drizzle-policy

drizzle-orm is a peer dependency.

The root import targets Drizzle v1 RC:

import { createPolicyClient, definePolicies } from 'drizzle-policy';

Use drizzle-policy/v0 if your app is still on Drizzle v0.

Quick Start

This example scopes every supported query to the current tenant when a table has a tenantId column.

import { createPolicyClient, definePolicies } from 'drizzle-policy';
import { scopeIsolationPolicy } from 'drizzle-policy/recipes/scope-isolation';
import { rawDb } from './db';

type PolicyContext = {
  tenantId: string;
};

export const policies = definePolicies<PolicyContext>()(() => [
  scopeIsolationPolicy({
    column: 'tenantId',
    getScopeValue: ctx => ctx.tenantId,
  }),
]);

export const { db, policyContext } = createPolicyClient(rawDb, {
  policies,
});

Run request or job code inside a policy context:

await policyContext.run({ tenantId: session.tenantId }, async () => {
  return handler(request);
});

For tests, scripts, and background jobs, you can also pass context directly:

await db.withPolicyContext({ tenantId: 'tenant_123' }, async db => {
  return db.query.projects.findMany();
});

Use the wrapped db everywhere you want policies enforced.

If your framework already owns request or job context storage, pass a reader and use the returned client:

export const { db } = createPolicyClient(rawDb, {
  policies,
  getContext: customContextReader,
});

For a local PostgreSQL client example that configures Drizzle Policy without opening a database connection on startup:

bun run example:client

With the policy above:

  • reads, updates, and deletes on scoped tables are limited to the current tenant
  • inserts into scoped tables get the current tenantId
  • inserts or updates for another tenant are rejected
  • tables without tenantId are left alone by this recipe
  • raw execute is hidden from the normal wrapped client unless you explicitly enter an unsafe execute scope

Recipes

Recipes are prebuilt policy factories for common app-level safeguards. They are just definePolicy(...) wrappers under the hood, so you can mix them with custom policies in the same policy set. Import only the recipes you need.

import { scopeIsolationPolicy } from 'drizzle-policy/recipes/scope-isolation';
import { softDeletePolicy } from 'drizzle-policy/recipes/soft-delete';

Scope Isolation

Use scopeIsolationPolicy when rows belong to an application scope such as a tenant, workspace, organization, account, or project.

type PolicyContext = {
  workspaceId: string;
};

scopeIsolationPolicy<PolicyContext>({
  column: 'workspaceId',
  getScopeValue: ctx => ctx.workspaceId,
});

For tables with workspaceId, the recipe constrains reads, updates, and deletes to the current workspace. Inserts get the current workspace value automatically.

If some tables should be treated differently, pass table-specific decisions:

import type * as schema from './schema';

scopeIsolationPolicy<PolicyContext, typeof schema>({
  column: 'workspaceId',
  getScopeValue: ctx => ctx.workspaceId,
  onTableWithoutScopeColumn: {
    countries: 'ignore',
    auditLogs: 'throw',
  },
});

The optional typeof schema generic gives TypeScript autocomplete for table names in table-keyed options. You do not pass your schema object at runtime.

Recipes accept custom names when you want domain-specific errors, trace events, or unsafe policy permissions. For unsafe({ policies: [...] }) autocomplete, put your context and schema generics on definePolicies, then let the recipe call infer its own name literal:

export const policies = definePolicies<PolicyContext, typeof schema>()(() => [
  scopeIsolationPolicy({
    name: 'tenant-isolation',
    column: 'tenantId',
    getScopeValue: ctx => ctx.tenantId,
    onTableWithoutScopeColumn: {
      auditLogs: 'ignore',
    },
  }),
]);

Avoid putting explicit generics on the recipe call when you need autocomplete for a custom name:

scopeIsolationPolicy<PolicyContext, typeof schema>({
  name: 'tenant-isolation',
  column: 'tenantId',
  getScopeValue: ctx => ctx.tenantId,
});

That form typechecks, but TypeScript widens the custom name to string, so tenant-isolation is still allowed as a custom policy permission but cannot be suggested in unsafe({ policies: [...] }).

Soft Delete

Use softDeletePolicy when deleted rows stay in the table.

import { softDeletePolicy } from 'drizzle-policy/recipes/soft-delete';

softDeletePolicy({
  column: 'deletedAt',
});

For tables with deletedAt, reads only return rows where deletedAt is null. Deletes are rejected by default.

To turn deletes into updates:

softDeletePolicy({
  column: 'deletedAt',
  deleteBehavior: 'softDelete',
  deletedValue: () => new Date(),
});

You can combine recipes in one policy set:

export const policies = definePolicies<PolicyContext>()(() => [
  scopeIsolationPolicy({
    column: 'tenantId',
    getScopeValue: ctx => ctx.tenantId,
  }),
  softDeletePolicy({
    column: 'deletedAt',
    deleteBehavior: 'softDelete',
  }),
]);

Custom Policies

Use policy.define(...) when a recipe is not enough.

import { definePolicies } from 'drizzle-policy';
import { and, eq, isNull } from 'drizzle-orm';
import type * as schema from './schema';

type PolicyContext = {
  userId: string;
};

export const policies = definePolicies<PolicyContext>()(policy => [
  policy.define({
    name: 'visible-projects',
    onMissingContext: 'throw',
    appliesTo: ({ tableKey }) => tableKey === 'projects',
    read: ({ table, ctx }) => {
      const projects = table as typeof schema.projects;

      return and(eq(projects.ownerId, ctx.userId), isNull(projects.deletedAt));
    },
  }),
]);

Policy hooks use normal Drizzle expressions. Import eq, and, or, isNull, sql, and other helpers from drizzle-orm; Drizzle Policy does not introduce a separate predicate language.

Policies can define hooks for four operations:

  • read: return a Drizzle condition
  • insert: return inserted values, or transformed inserted values
  • update: return a condition, transformed set values, or both
  • delete: return a condition, reject the delete, or convert it into an update

appliesTo narrows which table operations a policy should consider. If you omit it, the policy applies anywhere it has a hook.

onMissingContext: 'throw' makes the policy fail closed when no context is available and lets TypeScript treat ctx as defined inside that policy's hooks.

Delete As Update

A custom delete hook can replace a delete with an update:

const policies = definePolicies<PolicyContext>()(policy => [
  policy.define({
    name: 'archive-project-deletes',
    onMissingContext: 'throw',
    appliesTo: ({ tableKey }) => tableKey === 'projects',
    delete: ({ ctx }) => ({
      action: 'update',
      set: {
        deletedAt: new Date(),
        deletedById: ctx.userId,
      },
    }),
  }),
]);

Configuration

The safest production setup usually fails closed for unclassified table operations and direct raw execution:

export const { db, policyContext } = createPolicyClient(rawDb, {
  policies,
  onNoPolicyMatched: 'throw',
  rawExecution: 'throw',
});

Defaults:

| Option | Default | Meaning | | ------------------- | ------- | ------------------------------------------------------------------------------------------ | | onNoPolicyMatched | allow | Operations that no policy handles continue unchanged. | | rawExecution | throw | Direct raw execution methods such as execute are rejected outside unsafe execute scopes. |

Recipe defaults:

| Option | Default | Meaning | | ------------------------------------------------ | -------- | --------------------------------------------------------------------- | | scopeIsolationPolicy.onTableWithoutScopeColumn | ignore | Tables without the configured scope column are ignored by the recipe. | | scopeIsolationPolicy.onMissingScopeValue | throw | Scoped operations without a scope value are rejected. | | scopeIsolationPolicy.onScopeValueMismatch | throw | Insert/update values for another scope are rejected. | | softDeletePolicy.deleteBehavior | throw | Deletes are rejected unless you opt into soft-delete updates. |

Raw Execution

Raw execution APIs bypass table-aware planning, so Drizzle Policy rejects them by default. The wrapped TypeScript client also omits execute unless you explicitly allow raw execution for the client or enter an unsafe scope that grants it:

const result = await db.unsafe({ execute: true }).execute(sql`select 1`);

If you set rawExecution: 'allow', execute is available on the normal client surface:

const { db } = createPolicyClient(rawDb, {
  policies,
  rawExecution: 'allow',
  getContext: policyContext.get,
});

await db.execute(sql`select 1`);

For legacy JavaScript, casts, or conditional access to execute, the rawExecution callback remains a runtime fallback:

type PolicyContext = {
  role: 'admin' | 'member';
};

const { db } = createPolicyClient(rawDb, {
  policies,
  getContext: policyContext.get,
  rawExecution({ ctx }) {
    return ctx?.role === 'admin' ? 'allow' : 'throw';
  },
});

You can still use Drizzle's sql template inside normal query builders and policy hooks.

Temporary Exceptions

Use unsafe for a narrow, intentional exception, such as an admin flow that needs to include soft-deleted rows.

const projects = await db
  .unsafe({ policies: ['soft-delete'] })
  .query.projects.findMany();

Custom recipe names inferred by definePolicies are suggested here too:

const allTenantProjects = await db
  .unsafe({ policies: ['tenant-isolation'] })
  .query.projects.findMany();

Only the named policies are skipped for that returned client. Other policies still run. You can combine permissions when one client needs both kinds of escape hatch:

await db
  .unsafe({ policies: ['soft-delete'], execute: true })
  .execute(sql`select refresh_admin_cache()`);

Tracing

Pass trace while developing a policy setup to see which calls were checked and which policy decisions were made.

const { db } = createPolicyClient(rawDb, {
  policies,
  getContext: policyContext.get,
  trace(event) {
    console.debug(event);
  },
});

Trace events are for debugging configuration, not for enforcing security.

Supported Drizzle Calls

Both Drizzle versions currently cover:

  • SQL-like select, insert, update, and delete builders
  • joined tables in select builders
  • relational db.query.*.findMany(...) and findFirst(...)
  • nested relational with configs
  • transactions, with the transaction client wrapped in the same policies
  • raw execute, when granted through unsafe({ execute: true }) or the rawExecution runtime fallback

Drizzle v0

The policy definition API is shared. Only the client import changes:

import { definePolicies } from 'drizzle-policy';
import { createPolicyClient } from 'drizzle-policy/v0';

For a tiny local v0 playground:

bun run example:v0:minimal

Protection Model

Drizzle Policy protects operations that go through the wrapped client. Queries that bypass it are outside its control, including:

  • direct access to the unwrapped Drizzle client
  • raw execution that your app explicitly allows
  • migrations and maintenance scripts that do not use the policy client
  • database users or tools that connect outside your application

Use database-native permissions or row-level security as the final boundary when you need protection outside application code.