Package Exports
- @zanzojs/drizzle
Readme
@zanzojs/drizzle
The official Drizzle ORM adapter for ZanzoJS.
When to use this package
@zanzojs/drizzle serves two distinct purposes:
1. Write-time tuple materialization (materializeDerivedTuples / removeDerivedTuples)
When you grant or revoke access via nested permission paths (e.g. folder.admin), you must pre-materialize the derived tuples in the database. This is what makes read-time evaluation fast.
2. SQL-filtered queries for large datasets
When you need to fetch a filtered list of resources (e.g. "all documents this user can read") and the dataset is too large to load entirely into memory, the adapter generates parameterized EXISTS subqueries that push the permission filter directly to the database.
[!TIP] Performance Optimized: As of v0.3.0, the adapter automatically groups multiple permission paths into a single
EXISTSsubquery using anINclause, providing significant performance gains for complex schemas.
// Without adapter — loads everything into memory and filters (inefficient for large datasets)
const allDocs = await db.select().from(documents);
const myDocs = allDocs.filter(d => snapshot['Document:' + d.id]?.includes('read'));
// With adapter — filter goes directly to SQL (efficient at any scale)
const myDocs = await db.select().from(documents)
.where(withPermissions('User:alice', 'read', 'Document', documents.id));Note: For the frontend snapshot flow, you don't need this adapter. The snapshot is generated by loading the user's tuples with
engine.load()and callingcreateZanzoSnapshot(). The adapter is for backend queries that need SQL-level filtering.
Installation
pnpm add @zanzojs/core@latest @zanzojs/drizzle@latest drizzle-ormSetup
1. Create the Universal Tuple Table
All relationships live in a single table. This is the Zanzibar pattern.
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const zanzoTuples = sqliteTable('zanzo_tuples', {
id: integer('id').primaryKey({ autoIncrement: true }),
object: text('object').notNull(), // e.g. "Document:doc1"
relation: text('relation').notNull(), // e.g. "owner"
subject: text('subject').notNull(), // e.g. "User:alice"
});2. Create the adapter
import { createZanzoAdapter } from '@zanzojs/drizzle';
import { engine } from './zanzo.config';
import { zanzoTuples } from './schema';
export const withPermissions = createZanzoAdapter(engine, zanzoTuples);3. Query pushdown
async function getReadableDocuments(userId: string) {
return await db.select()
.from(documents)
.where(withPermissions(`User:${userId}`, 'read', 'Document', documents.id));
}Configuration Options
createZanzoAdapter accepts an optional options object:
export const withPermissions = createZanzoAdapter(engine, zanzoTuples, {
dialect: 'postgres', // 'postgres' (default), 'mysql', or 'sqlite'
debug: false, // Set to true to log generated SQL and AST
warnOnNestedConditions: true // Warns if materialized tuples are missing
});1. SQL Injection Prevention
As of v0.3.0, the adapter avoids sql.raw for all resource identifiers. All inputs are handled via Drizzle's secure parameter binding.
2. Dialect Support
The adapter is dialect-aware. If you use SQLite, specify { dialect: 'sqlite' } to use the || operator for string concatenation instead of CONCAT.
3. AST Caching
The adapter includes internal caching of structural permission trees (ASTs), minimizing CPU overhead for repeated queries on the same resource types.
Write Operations
Granting access with materializeDerivedTuples
When assigning a role that involves nested permission paths, use materializeDerivedTuples to materialize all derived tuples atomically.
import { materializeDerivedTuples } from '@zanzojs/core';
async function grantAccess(userId: string, relation: string, objectId: string) {
const baseTuple = {
subject: `User:${userId}`,
relation,
object: objectId,
};
const derived = await materializeDerivedTuples({
schema: engine.getSchema(),
newTuple: baseTuple,
fetchChildren: async (parentObject, relation) => {
const rows = await db.select({ object: zanzoTuples.object })
.from(zanzoTuples)
.where(and(
eq(zanzoTuples.subject, parentObject),
eq(zanzoTuples.relation, relation),
));
return rows.map(r => r.object);
},
});
await db.insert(zanzoTuples).values([baseTuple, ...derived]);
}Revoking access with removeDerivedTuples
removeDerivedTuples is the symmetric inverse of materializeDerivedTuples. It identifies all derived tuples to delete.
import { removeDerivedTuples } from '@zanzojs/core';
async function revokeAccess(userId: string, relation: string, objectId: string) {
const baseTuple = {
subject: `User:${userId}`,
relation,
object: objectId,
};
const derived = await removeDerivedTuples({
schema: engine.getSchema(),
revokedTuple: baseTuple,
fetchChildren: async (parentObject, relation) => {
const rows = await db.select({ object: zanzoTuples.object })
.from(zanzoTuples)
.where(and(
eq(zanzoTuples.subject, parentObject),
eq(zanzoTuples.relation, relation),
));
return rows.map(r => r.object);
},
});
for (const tuple of [baseTuple, ...derived]) {
await db.delete(zanzoTuples).where(and(
eq(zanzoTuples.subject, tuple.subject),
eq(zanzoTuples.relation, tuple.relation),
eq(zanzoTuples.object, tuple.object),
));
}
}materializeDerivedTuples and removeDerivedTuples are symmetric. If
materializeDerivedTuplesderived a tuple,removeDerivedTupleswill identify it for deletion. This guarantees no orphaned tuples.
Documentation
For full architecture details, see the ZanzoJS Monorepo.