Skip to content

Writing a custom adapter

An adapter is the only persistence boundary in VeguiPerms. Implementing one means providing six methods over subjects and grants. Everything else — validation, matching, inheritance, resolution — is handled by the engine.

import type { PermissionGrant, Subject } from "vperms";
import { VeguiPermsAdapter } from "vperms";
export class MyAdapter extends VeguiPermsAdapter {
async findSubject(workspaceId: string, subjectId: string): Promise<Subject | null> {
// load, or return null
}
async saveSubject(workspaceId: string, subject: Subject): Promise<Subject> {
// create or update, then return the stored subject
}
async deleteSubject(workspaceId: string, subjectId: string): Promise<boolean> {
// return true when a record was removed
}
async findSubjectGrants(workspaceId: string, subjectId: string): Promise<PermissionGrant[]> {
// return [] when the subject has no grants
}
async grantPermission(
workspaceId: string,
subjectId: string,
permission: string,
value: boolean,
): Promise<PermissionGrant> {
// MUST upsert and return the stored grant
}
async ungrantPermission(
workspaceId: string,
subjectId: string,
permission: string,
): Promise<boolean> {
// return true when a grant was removed
}
}
  • Upsert grants. For a given (workspaceId, subjectId, permission) there is at most one grant; a new value replaces the previous one.
  • Do no validation. Every value has already been validated by VeguiPermsService with Zod. PermissionGrant includes subjectId and workspaceId.
  • Do no resolution. Never traverse parents, match patterns or evaluate permissions in the adapter.
  • Return plain data. Subjects and grants are JSON-safe objects.
  • Be idempotent where it matters. deleteSubject and ungrantPermission return false when there was nothing to remove.

VeguiPerms has no implicit schema management. Database adapters expose an explicit migrate() that the application calls once at startup:

const adapter = new VeguiPermsSqliteAdapter({ db });
await adapter.migrate(); // explicit, idempotent

Follow the same convention for a database-backed custom adapter: dependency-inject an already-created client, never open or close connections inside the adapter, and apply schema changes only when migrate() is called.

If your backend is SQL, extend VeguiPermsSqlAdapter from @vperms/sql-adapter instead of writing everything from scratch — it maps rows to the public types and adds migrate(). The Drizzle adapters are built on it.

The private @vperms/adapter-contract package holds the shared contract suite every official adapter must pass. Run it against your adapter to guarantee identical behavior across backends. Official adapters are also tested against real databases via the integration suite (docker compose up -d, then bun run test:integration).