@everystack/api

中文文档
0.4.17发布于 2026/11/13AGPL-3.0-onlyTypeScriptCommonJS零依赖近 30 天下载 2273

PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM

原始使用文档
文档目录18 节
  1. Install
  2. Entry Points
  3. Handler
  4. Mounting as an Expo Router API Route
  5. Handler Options
  6. Row-Level Security (pgSettings)
  7. Role-Based Access Control (RBAC)
  8. Auth Refusal Codes
  9. Naming Convention
  10. Query Protocol
  11. Client
  12. Querying Data
  13. Mutations
  14. Filter Methods
  15. Token Refresh
  16. Exported Types
  17. Peer Dependencies
  18. License

@everystack/api

PostgREST-compatible HTTP handler and typed fetch client for Drizzle ORM.

Install

pnpm add @everystack/api drizzle-orm

Entry Points

ImportDescription
@everystack/api/handlerServer-side PostgREST handler
@everystack/api/clientClient-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 },
    },
  },

  // lifecycle: true tables, the one soft delete. deriveHandlerConfig(models) emits this list;
  // do not write it by hand. Restore is POST {basePath}/{table}/restore, client .restore(id).
  lifecycle: ['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) => {},
      // All models; veto from '@everystack/api/handler' rolls back the request:
      beforeDelete: (row, ctx) => (ctx.user ? undefined : veto('sign in to delete')), // in the transaction; veto -> 409
      afterDelete: async (rows, ctx) => {},  // after commit, once per request; ctx.batch is null
      afterRestore: async (rows, ctx) => {}, // after commit, ctx.batch and ctx.counts
    },
  },

  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).

Auth Refusal Codes

Authentication refusals keep their existing HTTP status and message. The JSON body adds a code:

CodeMeaning
token_refusedA presented access token was refused.
refresh_refusedA refresh token was refused, including reuse or an unavailable user.
auth_requiredA required access token was not presented.
client_refusedClient credentials were missing or refused.

Confirmation and password recovery token refusals have no refusal code. Wrong passwords, account suspension, validation errors, and service outages have no refusal code. A 401 without a recognized code does not identify an everystack session refusal.

The code key also carries other error values, including PostgreSQL SQLSTATEs on database errors and vetoed on a hook veto. Clients must match one of the four refusal values exactly, never just check whether code is present.

Custom auth servers can use the same response helper:

import { authRefusal, AUTH_REFUSAL_CODES, type AuthRefusalCode } from '@everystack/api/handler';

return authRefusal('token_refused', 'Invalid or expired token'); // status defaults to 401

authRefusal(code, message, status?) returns exactly { message, code } with Content-Type: application/json. It has no runtime dependencies. AuthRefusalCode is the union of the four entries in AUTH_REFUSAL_CODES.

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):

OperatorDescriptionExample
eqEquals?status=eq.published
neqNot equals?role=neq.admin
gt / gteGreater than?age=gt.18
lt / lteLess than?price=lt.100
likePattern match?name=like.*smith*
ilikeCase-insensitive pattern?email=ilike.*@gmail.com
inIn set?id=in.(1,2,3)
isNull 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';
import { session } from '@everystack/auth/api';

const api = createClient({
  baseUrl: '/api',
  session: session.forClient(),
  headers: { 'X-Custom': 'value' },
});

session takes precedence over the deprecated getToken, onTokenExpired and onAuthFailure callbacks. Use createSession from @everystack/auth/session for custom storage or auth. The API package accepts the structural ClientSession interface and does not import the auth package.

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 session refreshes once on 401 token_refused, then the client retries once. Concurrent requests share refresh. A refused refresh or a retry with token_refused ends the session. auth_required, client_refused and foreign 401s never trigger refresh or Sign Out. A failed refresh keeps the session and returns status 503 with code refresh_unavailable. session.forClient({ whenRefreshFails: 'anonymous' }) retries without a bearer token instead. Sign Out always wins over a late refresh.

legacyRefusals defaults to true: exact old everystack refusal messages still work when code is absent. Set it to false after deploying a server that sends codes. This option goes away at the next breaking release. See the client guide and session guide.

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

PackageVersion
drizzle-orm>=0.35.0

Part of everystack — a self-hosted application stack for Expo apps on AWS.

License

AGPL-3.0-only © Scalable Technology, Inc.

A commercial license is available for organizations that cannot or do not wish to comply with the AGPL-3.0 terms. For commercial licensing, contact [email protected].

版本与发布记录

@everystack/api 共发布 32 个版本,首次发布于 2026/05/15;过去 12 个月发布了 32 个版本。

版本发布日期距今
0.4.17latest2026/11/131 天前
0.4.162026/11/122 天前
0.4.152026/11/068 天前
0.4.142026/11/059 天前
0.4.132026/10/3114 天前
0.4.122026/10/2520 天前

关于 @everystack/api 的常见问题

@everystack/api 是什么?

PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM

如何安装 @everystack/api?

在项目目录运行 npm install @everystack/api,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:drizzle-orm(0.41.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@everystack/api 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@everystack/api 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@everystack/api 可以商用吗?

@everystack/api 使用 AGPL-3.0-only 许可。最严格的 Copyleft:除分发外,通过网络向用户提供基于它的服务也需要开源对应源码。闭源 SaaS 使用前务必评估。以上为概括,不构成法律意见,请以许可原文为准。

@everystack/api 的下载量有多少?

根据 npm 官方下载统计,@everystack/api 在 2026/10/14 – 2026/11/12 这 30 天内被下载 2,273 次,日均约 76 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@everystack/api 还在维护吗?

最新版本 0.4.17 发布于 2026/11/13(1 天前);至今共发布 32 个版本,其中过去 12 个月发布了 32 个;首次发布于 2026/05/15。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。