@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 +333 -0
- package/package.json +40 -0
- package/src/client/index.ts +352 -0
- package/src/handler/index.ts +1545 -0
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
|
+
}
|