@everystack/api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,333 @@
1
+ # @everystack/api
2
+
3
+ PostgREST-compatible HTTP handler and typed fetch client for Drizzle ORM.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @everystack/api drizzle-orm
9
+ ```
10
+
11
+ ## Entry Points
12
+
13
+ | Import | Description |
14
+ |--------|-------------|
15
+ | `@everystack/api/handler` | Server-side PostgREST handler |
16
+ | `@everystack/api/client` | Client-side typed fetch client |
17
+
18
+ ## Handler
19
+
20
+ 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.
21
+
22
+ ```typescript
23
+ import { createHandler } from '@everystack/api/handler';
24
+ import { db } from './db';
25
+ import * as schema from './db/schema';
26
+
27
+ const handler = createHandler(db, schema);
28
+ ```
29
+
30
+ ### Mounting as an Expo Router API Route
31
+
32
+ ```typescript
33
+ // app/api/[...path]+api.ts
34
+ import { createHandler } from '@everystack/api/handler';
35
+ import { db } from '../../db';
36
+ import * as schema from '../../db/schema';
37
+
38
+ const handler = createHandler(db, schema, { basePath: '/api' });
39
+
40
+ export function GET(request: Request) { return handler(request); }
41
+ export function POST(request: Request) { return handler(request); }
42
+ export function PATCH(request: Request) { return handler(request); }
43
+ export function DELETE(request: Request) { return handler(request); }
44
+ ```
45
+
46
+ ### Handler Options
47
+
48
+ ```typescript
49
+ createHandler(db, schema, {
50
+ basePath: '/api',
51
+
52
+ auth: {
53
+ verifyToken: async (token) => verifyJWT(token),
54
+ publicRoutes: ['GET'],
55
+ publicRpc: ['health', 'search'],
56
+ onAuthenticated: (user) => {},
57
+
58
+ // Role-based access control
59
+ roles: {
60
+ posts: { read: 'public', write: 'authenticated' },
61
+ admin_logs: { read: 'admin', write: 'admin' },
62
+ '*': { read: 'authenticated', write: 'authenticated' },
63
+ },
64
+ roleHierarchy: ['public', 'authenticated', 'moderator', 'admin'],
65
+ roleField: 'role',
66
+
67
+ // Client credentials (two-tier auth)
68
+ verifyClient: async (clientId, clientSecret) => lookupClient(clientId, clientSecret),
69
+ clientHeaders: { id: 'X-Client-Id', secret: 'X-Client-Secret' },
70
+ onClientAuthenticated: (client) => {},
71
+ },
72
+
73
+ rpc: {
74
+ async timeline(body, user, client) {
75
+ return db.query.posts.findMany({ limit: 20 });
76
+ },
77
+ },
78
+
79
+ relations: {
80
+ posts: {
81
+ author: { table: 'users', from: 'authorId', to: 'id' },
82
+ comments: { table: 'comments', from: 'id', to: 'postId', many: true },
83
+ },
84
+ },
85
+
86
+ softDelete: {
87
+ column: 'deletedAt',
88
+ tables: ['posts', 'comments'],
89
+ },
90
+
91
+ pgSettings: (user, client) => ({
92
+ 'request.jwt.claims': JSON.stringify(user || { role: 'anon' }),
93
+ 'app.user_id': String(user?.sub || ''),
94
+ }),
95
+
96
+ hooks: {
97
+ posts: {
98
+ beforeCreate: async (body, user, client) => ({ ...body, authorId: user?.sub }),
99
+ afterCreate: async (row, user, client) => { /* notify */ },
100
+ beforeUpdate: async (body, user, client) => body,
101
+ afterUpdate: async (rows, user, client) => {},
102
+ beforeDelete: async (user, client) => {},
103
+ afterDelete: async (rows, user, client) => {},
104
+ },
105
+ },
106
+
107
+ naming: 'snake_case', // or 'camelCase' (default)
108
+ });
109
+ ```
110
+
111
+ ### Row-Level Security (pgSettings)
112
+
113
+ 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.
114
+
115
+ ### Role-Based Access Control (RBAC)
116
+
117
+ 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).
118
+
119
+ ### Naming Convention
120
+
121
+ The `naming` option controls how keys are transformed at the API boundary:
122
+ - `'snake_case'`: Accept snake_case inbound, return snake_case outbound. POST/PATCH bodies map snake_case → camelCase for Drizzle.
123
+ - `'camelCase'` or omitted: Use Drizzle property names as-is.
124
+
125
+ Column resolution is bidirectional: `?author_id=eq.5` resolves to `authorId` automatically, even without the `naming` option.
126
+
127
+ ### Query Protocol
128
+
129
+ The handler implements the PostgREST query protocol:
130
+
131
+ **Filters** (`?column=operator.value`):
132
+
133
+ | Operator | Description | Example |
134
+ |----------|-------------|---------|
135
+ | `eq` | Equals | `?status=eq.published` |
136
+ | `neq` | Not equals | `?role=neq.admin` |
137
+ | `gt` / `gte` | Greater than | `?age=gt.18` |
138
+ | `lt` / `lte` | Less than | `?price=lt.100` |
139
+ | `like` | Pattern match | `?name=like.*smith*` |
140
+ | `ilike` | Case-insensitive pattern | `?email=ilike.*@gmail.com` |
141
+ | `in` | In set | `?id=in.(1,2,3)` |
142
+ | `is` | Null check | `?deletedAt=is.null` |
143
+
144
+ **Negation** (`not.` prefix): `?status=not.eq.draft`, `?tags=not.in.(a,b)`, `?deletedAt=not.is.null`
145
+
146
+ **Logical groups**:
147
+ - `?or=(status.eq.draft,status.eq.pending)` — OR conditions
148
+ - `?and=(price.gte.10,price.lte.100)` — explicit AND group
149
+ - Combine with regular filters: `?category=eq.electronics&or=(status.eq.draft,status.eq.pending)`
150
+
151
+ **Aggregates** (PostgREST `column.function()` syntax):
152
+ - `?select=count()` — count all rows
153
+ - `?select=platform,total:amount.sum()` — GROUP BY with alias
154
+ - Supported: `count()`, `sum()`, `avg()`, `min()`, `max()`
155
+
156
+ **JSON path filtering**: `?metadata->>theme=eq.dark`, `?data->settings->>mode=eq.compact`
157
+
158
+ **Column selection**: `?select=id,title,author(name,email)`
159
+
160
+ **Relation embedding**: `?select=*,author(*),comments(body)`
161
+
162
+ **Ordering**: `?order=createdAt.desc`
163
+
164
+ **Pagination**: `?limit=10&offset=20`
165
+
166
+ **Exact count**: Send `Prefer: count=exact` header to get `Content-Range: 0-9/42`
167
+
168
+ **Mutations**:
169
+ - `POST /table` — Insert (returns created row, 201)
170
+ - `PATCH /table?id=eq.1` — Update matching rows (200)
171
+ - `DELETE /table?id=eq.1` — Delete matching rows (requires filter, 200)
172
+
173
+ **RPC**: `POST /rpc/function_name` with JSON body
174
+
175
+ ## Client
176
+
177
+ Pure fetch client with zero React dependency. Works in any JavaScript runtime.
178
+
179
+ ```typescript
180
+ import { createClient } from '@everystack/api/client';
181
+
182
+ const api = createClient({
183
+ baseUrl: 'https://myapp.com/api',
184
+ headers: { 'X-Custom': 'value' },
185
+ getToken: () => localStorage.getItem('token'),
186
+ onTokenExpired: async () => {
187
+ const { token } = await refreshToken();
188
+ return token;
189
+ },
190
+ clientCredentials: {
191
+ id: 'my-app-id',
192
+ secret: 'my-app-secret',
193
+ },
194
+ });
195
+ ```
196
+
197
+ ### Querying Data
198
+
199
+ ```typescript
200
+ // List with filters
201
+ const { data, error } = await api
202
+ .from('posts')
203
+ .eq('status', 'published')
204
+ .order('createdAt', 'desc')
205
+ .limit(10)
206
+ .execute();
207
+
208
+ // Single record
209
+ const { data: post } = await api
210
+ .from('posts')
211
+ .eq('id', '123')
212
+ .single();
213
+
214
+ // With exact count
215
+ const { data: posts, total } = await api
216
+ .from('posts')
217
+ .limit(20)
218
+ .executeWithCount();
219
+
220
+ // Column selection + relation embedding
221
+ const { data } = await api
222
+ .from('posts')
223
+ .select('id,title,author(name)')
224
+ .execute();
225
+
226
+ // Negation
227
+ const { data } = await api
228
+ .from('posts')
229
+ .notEq('status', 'draft')
230
+ .notIn('category', ['spam', 'archived'])
231
+ .execute();
232
+
233
+ // Logical groups
234
+ const { data } = await api
235
+ .from('posts')
236
+ .eq('active', 'true')
237
+ .or('status.eq.draft,status.eq.pending')
238
+ .execute();
239
+
240
+ // Aggregates
241
+ const { data } = await api
242
+ .from('transactions')
243
+ .select('platform,total:amount.sum()')
244
+ .execute();
245
+ ```
246
+
247
+ ### Mutations
248
+
249
+ ```typescript
250
+ // Insert
251
+ const { data } = await api.from('posts').insert({
252
+ title: 'Hello',
253
+ content: 'World',
254
+ });
255
+
256
+ // Update
257
+ const { data } = await api
258
+ .from('posts')
259
+ .eq('id', '123')
260
+ .update({ title: 'Updated' });
261
+
262
+ // Delete
263
+ const { data } = await api
264
+ .from('posts')
265
+ .eq('id', '123')
266
+ .remove();
267
+ ```
268
+
269
+ ### Filter Methods
270
+
271
+ All filter methods return the builder for chaining:
272
+
273
+ ```typescript
274
+ // Standard operators
275
+ .eq(column, value) // Equals
276
+ .neq(column, value) // Not equals
277
+ .gt(column, value) // Greater than
278
+ .gte(column, value) // Greater or equal
279
+ .lt(column, value) // Less than
280
+ .lte(column, value) // Less or equal
281
+ .like(column, value) // LIKE pattern
282
+ .ilike(column, value) // Case-insensitive LIKE
283
+ .is(column, value) // IS NULL check
284
+ .in(column, values[]) // IN array
285
+
286
+ // Negation operators
287
+ .notEq(column, value) // NOT equal
288
+ .notNeq(column, value) // NOT not-equal
289
+ .notGt(column, value) // NOT greater than
290
+ .notGte(column, value) // NOT greater or equal
291
+ .notLt(column, value) // NOT less than
292
+ .notLte(column, value) // NOT less or equal
293
+ .notLike(column, value) // NOT LIKE
294
+ .notIlike(column, value)// NOT ILIKE
295
+ .notIs(column, value) // IS NOT NULL
296
+ .notIn(column, values[])// NOT IN array
297
+
298
+ // Logical groups
299
+ .or(expression) // OR group: .or('status.eq.draft,status.eq.pending')
300
+ .and(expression) // AND group: .and('price.gte.10,price.lte.100')
301
+ ```
302
+
303
+ ### Token Refresh
304
+
305
+ The client automatically retries on 401 responses using `onTokenExpired`. Concurrent 401s are deduplicated — only one refresh runs at a time.
306
+
307
+ ## Exported Types
308
+
309
+ ```typescript
310
+ import type {
311
+ AuthConfig, RoleConfig, RelationConfig, SoftDeleteConfig,
312
+ MutationEvent, HookConfig, HandlerOptions,
313
+ } from '@everystack/api/handler';
314
+
315
+ import type {
316
+ ClientOptions, ClientError, ClientResponse,
317
+ CountResponse, QueryBuilder, Client,
318
+ } from '@everystack/api/client';
319
+ ```
320
+
321
+ ## Peer Dependencies
322
+
323
+ | Package | Version |
324
+ |---------|---------|
325
+ | `drizzle-orm` | `>=0.35.0` |
326
+
327
+ ---
328
+
329
+ Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
330
+
331
+ ## License
332
+
333
+ MIT
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@everystack/api",
3
+ "version": "0.1.0",
4
+ "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ "./handler": {
16
+ "types": "./src/handler/index.ts",
17
+ "default": "./src/handler/index.ts"
18
+ },
19
+ "./client": {
20
+ "types": "./src/client/index.ts",
21
+ "default": "./src/client/index.ts"
22
+ }
23
+ },
24
+ "peerDependencies": {
25
+ "drizzle-orm": ">=0.35.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/jest": "^29.5.14",
29
+ "@types/node": "^22.0.0",
30
+ "drizzle-orm": "^0.41.0",
31
+ "jest": "^29.7.0",
32
+ "ts-jest": "^29.3.0",
33
+ "typescript": "^5.7.0"
34
+ },
35
+ "scripts": {
36
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
37
+ "build": "tsc --build",
38
+ "lint": "tsc --noEmit"
39
+ }
40
+ }