@everystack/api
v0.2.0
Published
PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM
Readme
@everystack/api
PostgREST-compatible HTTP handler and typed fetch client for Drizzle ORM.
Install
pnpm add @everystack/api drizzle-ormEntry Points
| Import | Description |
|--------|-------------|
| @everystack/api/handler | Server-side PostgREST handler |
| @everystack/api/client | Client-side typed fetch client |
Handler
Creates a Web Standard (Request) => Promise<Response> handler from a Drizzle database instance and schema. Works with Expo Router API routes, Cloudflare Workers, Bun, Deno, or any platform supporting the Request/Response interface.
import { createHandler } from '@everystack/api/handler';
import { db } from './db';
import * as schema from './db/schema';
const handler = createHandler(db, schema);Mounting as an Expo Router API Route
// app/api/[...path]+api.ts
import { createHandler } from '@everystack/api/handler';
import { db } from '../../db';
import * as schema from '../../db/schema';
const handler = createHandler(db, schema, { basePath: '/api' });
export function GET(request: Request) { return handler(request); }
export function POST(request: Request) { return handler(request); }
export function PATCH(request: Request) { return handler(request); }
export function DELETE(request: Request) { return handler(request); }Handler Options
createHandler(db, schema, {
basePath: '/api',
auth: {
verifyToken: async (token) => verifyJWT(token),
publicRoutes: ['GET'],
publicRpc: ['health', 'search'],
onAuthenticated: (user) => {},
// Role-based access control
roles: {
posts: { read: 'public', write: 'authenticated' },
admin_logs: { read: 'admin', write: 'admin' },
'*': { read: 'authenticated', write: 'authenticated' },
},
roleHierarchy: ['public', 'authenticated', 'moderator', 'admin'],
roleField: 'role',
// Client credentials (two-tier auth)
verifyClient: async (clientId, clientSecret) => lookupClient(clientId, clientSecret),
clientHeaders: { id: 'X-Client-Id', secret: 'X-Client-Secret' },
onClientAuthenticated: (client) => {},
},
rpc: {
async timeline(body, user, client) {
return db.query.posts.findMany({ limit: 20 });
},
},
relations: {
posts: {
author: { table: 'users', from: 'authorId', to: 'id' },
comments: { table: 'comments', from: 'id', to: 'postId', many: true },
},
},
softDelete: {
column: 'deletedAt',
tables: ['posts', 'comments'],
},
pgSettings: (user, client) => ({
'request.jwt.claims': JSON.stringify(user || { role: 'anon' }),
'app.user_id': String(user?.sub || ''),
}),
hooks: {
posts: {
beforeCreate: async (body, user, client) => ({ ...body, authorId: user?.sub }),
afterCreate: async (row, user, client) => { /* notify */ },
beforeUpdate: async (body, user, client) => body,
afterUpdate: async (rows, user, client) => {},
beforeDelete: async (user, client) => {},
afterDelete: async (rows, user, client) => {},
},
},
naming: 'snake_case', // or 'camelCase' (default)
});Row-Level Security (pgSettings)
When pgSettings is provided, every CRUD query is wrapped in a PostgreSQL transaction with set_config() calls. This injects session variables that RLS policies can reference via current_setting(). The values are LOCAL to the transaction — they never leak across pooled connections. When not provided, queries execute directly without wrapper.
Role-Based Access Control (RBAC)
When auth.roles is configured, each table route checks the user's role against the required role for that HTTP method. Resolution: roles[tableName] → roles['*'] → null (falls back to publicRoutes). Special roles: 'public' (no token needed), 'authenticated' (any valid JWT). Error codes: 401 (no/invalid token), 403 (insufficient role).
Naming Convention
The naming option controls how keys are transformed at the API boundary:
'snake_case': Accept snake_case inbound, return snake_case outbound. POST/PATCH bodies map snake_case → camelCase for Drizzle.'camelCase'or omitted: Use Drizzle property names as-is.
Column resolution is bidirectional: ?author_id=eq.5 resolves to authorId automatically, even without the naming option.
Query Protocol
The handler implements the PostgREST query protocol:
Filters (?column=operator.value):
| Operator | Description | Example |
|----------|-------------|---------|
| eq | Equals | ?status=eq.published |
| neq | Not equals | ?role=neq.admin |
| gt / gte | Greater than | ?age=gt.18 |
| lt / lte | Less than | ?price=lt.100 |
| like | Pattern match | ?name=like.*smith* |
| ilike | Case-insensitive pattern | ?email=ilike.*@gmail.com |
| in | In set | ?id=in.(1,2,3) |
| is | Null check | ?deletedAt=is.null |
Negation (not. prefix): ?status=not.eq.draft, ?tags=not.in.(a,b), ?deletedAt=not.is.null
Logical groups:
?or=(status.eq.draft,status.eq.pending)— OR conditions?and=(price.gte.10,price.lte.100)— explicit AND group- Combine with regular filters:
?category=eq.electronics&or=(status.eq.draft,status.eq.pending)
Aggregates (PostgREST column.function() syntax):
?select=count()— count all rows?select=platform,total:amount.sum()— GROUP BY with alias- Supported:
count(),sum(),avg(),min(),max()
JSON path filtering: ?metadata->>theme=eq.dark, ?data->settings->>mode=eq.compact
Column selection: ?select=id,title,author(name,email)
Relation embedding: ?select=*,author(*),comments(body)
Ordering: ?order=createdAt.desc
Pagination: ?limit=10&offset=20
Exact count: Send Prefer: count=exact header to get Content-Range: 0-9/42
Mutations:
POST /table— Insert (returns created row, 201)PATCH /table?id=eq.1— Update matching rows (200)DELETE /table?id=eq.1— Delete matching rows (requires filter, 200)
RPC: POST /rpc/function_name with JSON body
Client
Pure fetch client with zero React dependency. Works in any JavaScript runtime.
import { createClient } from '@everystack/api/client';
const api = createClient({
baseUrl: 'https://myapp.com/api',
headers: { 'X-Custom': 'value' },
getToken: () => localStorage.getItem('token'),
onTokenExpired: async () => {
const { token } = await refreshToken();
return token;
},
clientCredentials: {
id: 'my-app-id',
secret: 'my-app-secret',
},
});Querying Data
// List with filters
const { data, error } = await api
.from('posts')
.eq('status', 'published')
.order('createdAt', 'desc')
.limit(10)
.execute();
// Single record
const { data: post } = await api
.from('posts')
.eq('id', '123')
.single();
// With exact count
const { data: posts, total } = await api
.from('posts')
.limit(20)
.executeWithCount();
// Column selection + relation embedding
const { data } = await api
.from('posts')
.select('id,title,author(name)')
.execute();
// Negation
const { data } = await api
.from('posts')
.notEq('status', 'draft')
.notIn('category', ['spam', 'archived'])
.execute();
// Logical groups
const { data } = await api
.from('posts')
.eq('active', 'true')
.or('status.eq.draft,status.eq.pending')
.execute();
// Aggregates
const { data } = await api
.from('transactions')
.select('platform,total:amount.sum()')
.execute();Mutations
// Insert
const { data } = await api.from('posts').insert({
title: 'Hello',
content: 'World',
});
// Update
const { data } = await api
.from('posts')
.eq('id', '123')
.update({ title: 'Updated' });
// Delete
const { data } = await api
.from('posts')
.eq('id', '123')
.remove();Filter Methods
All filter methods return the builder for chaining:
// Standard operators
.eq(column, value) // Equals
.neq(column, value) // Not equals
.gt(column, value) // Greater than
.gte(column, value) // Greater or equal
.lt(column, value) // Less than
.lte(column, value) // Less or equal
.like(column, value) // LIKE pattern
.ilike(column, value) // Case-insensitive LIKE
.is(column, value) // IS NULL check
.in(column, values[]) // IN array
// Negation operators
.notEq(column, value) // NOT equal
.notNeq(column, value) // NOT not-equal
.notGt(column, value) // NOT greater than
.notGte(column, value) // NOT greater or equal
.notLt(column, value) // NOT less than
.notLte(column, value) // NOT less or equal
.notLike(column, value) // NOT LIKE
.notIlike(column, value)// NOT ILIKE
.notIs(column, value) // IS NOT NULL
.notIn(column, values[])// NOT IN array
// Logical groups
.or(expression) // OR group: .or('status.eq.draft,status.eq.pending')
.and(expression) // AND group: .and('price.gte.10,price.lte.100')Token Refresh
The client automatically retries on 401 responses using onTokenExpired. Concurrent 401s are deduplicated — only one refresh runs at a time.
Exported Types
import type {
AuthConfig, RoleConfig, RelationConfig, SoftDeleteConfig,
MutationEvent, HookConfig, HandlerOptions,
} from '@everystack/api/handler';
import type {
ClientOptions, ClientError, ClientResponse,
CountResponse, QueryBuilder, Client,
} from '@everystack/api/client';Peer Dependencies
| Package | Version |
|---------|---------|
| drizzle-orm | >=0.35.0 |
Part of everystack — a self-hosted application stack for Expo apps on AWS.
License
MIT
