@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
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed PostgREST client — pure fetch, zero React dependency.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { createClient } from 'everystack/client';
|
|
6
|
+
* const api = createClient({ baseUrl: 'https://myapp.com/api' });
|
|
7
|
+
* const { data } = await api.from('posts').eq('status', 'published').limit(10).execute();
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ClientOptions {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
14
|
+
/** Called on 401 — return a new token to retry, or null to fail */
|
|
15
|
+
onTokenExpired?: () => Promise<string | null>;
|
|
16
|
+
/** Client credentials sent with every request */
|
|
17
|
+
clientCredentials?: {
|
|
18
|
+
id: string;
|
|
19
|
+
secret: string;
|
|
20
|
+
/** Custom header names. Defaults: { id: 'X-Client-Id', secret: 'X-Client-Secret' } */
|
|
21
|
+
headers?: { id: string; secret: string };
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ClientError {
|
|
26
|
+
status: number;
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClientResponse<T> {
|
|
31
|
+
data: T | null;
|
|
32
|
+
error: ClientError | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CountResponse<T> extends ClientResponse<T> {
|
|
36
|
+
/** Total count from Content-Range header (0 if unavailable) */
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface QueryBuilder {
|
|
41
|
+
eq(column: string, value: string): QueryBuilder;
|
|
42
|
+
neq(column: string, value: string): QueryBuilder;
|
|
43
|
+
gt(column: string, value: string): QueryBuilder;
|
|
44
|
+
gte(column: string, value: string): QueryBuilder;
|
|
45
|
+
lt(column: string, value: string): QueryBuilder;
|
|
46
|
+
lte(column: string, value: string): QueryBuilder;
|
|
47
|
+
like(column: string, value: string): QueryBuilder;
|
|
48
|
+
ilike(column: string, value: string): QueryBuilder;
|
|
49
|
+
is(column: string, value: string): QueryBuilder;
|
|
50
|
+
in(column: string, values: string[]): QueryBuilder;
|
|
51
|
+
/** Negation: ?column=not.eq.value */
|
|
52
|
+
notEq(column: string, value: string): QueryBuilder;
|
|
53
|
+
notNeq(column: string, value: string): QueryBuilder;
|
|
54
|
+
notGt(column: string, value: string): QueryBuilder;
|
|
55
|
+
notGte(column: string, value: string): QueryBuilder;
|
|
56
|
+
notLt(column: string, value: string): QueryBuilder;
|
|
57
|
+
notLte(column: string, value: string): QueryBuilder;
|
|
58
|
+
notLike(column: string, value: string): QueryBuilder;
|
|
59
|
+
notIlike(column: string, value: string): QueryBuilder;
|
|
60
|
+
notIs(column: string, value: string): QueryBuilder;
|
|
61
|
+
notIn(column: string, values: string[]): QueryBuilder;
|
|
62
|
+
/** Logical groups: ?or=(status.eq.draft,status.eq.pending) */
|
|
63
|
+
or(expression: string): QueryBuilder;
|
|
64
|
+
and(expression: string): QueryBuilder;
|
|
65
|
+
select(columns: string): QueryBuilder;
|
|
66
|
+
order(column: string, direction?: 'asc' | 'desc'): QueryBuilder;
|
|
67
|
+
limit(count: number): QueryBuilder;
|
|
68
|
+
offset(count: number): QueryBuilder;
|
|
69
|
+
execute(): Promise<ClientResponse<unknown[]>>;
|
|
70
|
+
executeWithCount(): Promise<CountResponse<unknown[]>>;
|
|
71
|
+
single(): Promise<ClientResponse<unknown>>;
|
|
72
|
+
insert(data: Record<string, unknown>): Promise<ClientResponse<unknown>>;
|
|
73
|
+
update(data: Record<string, unknown>): Promise<ClientResponse<unknown[]>>;
|
|
74
|
+
remove(): Promise<ClientResponse<unknown[]>>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Client {
|
|
78
|
+
from(table: string): QueryBuilder;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Token refresh deduplication — only one refresh happens at a time.
|
|
83
|
+
* Concurrent 401s wait for the same refresh promise.
|
|
84
|
+
*/
|
|
85
|
+
let activeRefresh: Promise<string | null> | null = null;
|
|
86
|
+
|
|
87
|
+
export function createClient(options: ClientOptions): Client {
|
|
88
|
+
return {
|
|
89
|
+
from(table: string): QueryBuilder {
|
|
90
|
+
return createQueryBuilder(options, table);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder {
|
|
96
|
+
const filters: Array<[string, string]> = [];
|
|
97
|
+
let selectParam: string | null = null;
|
|
98
|
+
let orderParam: string | null = null;
|
|
99
|
+
let limitParam: number | null = null;
|
|
100
|
+
let offsetParam: number | null = null;
|
|
101
|
+
|
|
102
|
+
const builder: QueryBuilder = {
|
|
103
|
+
eq(column, value) { filters.push([column, `eq.${value}`]); return builder; },
|
|
104
|
+
neq(column, value) { filters.push([column, `neq.${value}`]); return builder; },
|
|
105
|
+
gt(column, value) { filters.push([column, `gt.${value}`]); return builder; },
|
|
106
|
+
gte(column, value) { filters.push([column, `gte.${value}`]); return builder; },
|
|
107
|
+
lt(column, value) { filters.push([column, `lt.${value}`]); return builder; },
|
|
108
|
+
lte(column, value) { filters.push([column, `lte.${value}`]); return builder; },
|
|
109
|
+
like(column, value) { filters.push([column, `like.${value}`]); return builder; },
|
|
110
|
+
ilike(column, value) { filters.push([column, `ilike.${value}`]); return builder; },
|
|
111
|
+
is(column, value) { filters.push([column, `is.${value}`]); return builder; },
|
|
112
|
+
in(column, values) { filters.push([column, `in.(${values.join(',')})`]); return builder; },
|
|
113
|
+
notEq(column, value) { filters.push([column, `not.eq.${value}`]); return builder; },
|
|
114
|
+
notNeq(column, value) { filters.push([column, `not.neq.${value}`]); return builder; },
|
|
115
|
+
notGt(column, value) { filters.push([column, `not.gt.${value}`]); return builder; },
|
|
116
|
+
notGte(column, value) { filters.push([column, `not.gte.${value}`]); return builder; },
|
|
117
|
+
notLt(column, value) { filters.push([column, `not.lt.${value}`]); return builder; },
|
|
118
|
+
notLte(column, value) { filters.push([column, `not.lte.${value}`]); return builder; },
|
|
119
|
+
notLike(column, value) { filters.push([column, `not.like.${value}`]); return builder; },
|
|
120
|
+
notIlike(column, value) { filters.push([column, `not.ilike.${value}`]); return builder; },
|
|
121
|
+
notIs(column, value) { filters.push([column, `not.is.${value}`]); return builder; },
|
|
122
|
+
notIn(column, values) { filters.push([column, `not.in.(${values.join(',')})`]); return builder; },
|
|
123
|
+
or(expression) { filters.push(['or', `(${expression})`]); return builder; },
|
|
124
|
+
and(expression) { filters.push(['and', `(${expression})`]); return builder; },
|
|
125
|
+
select(columns) { selectParam = columns; return builder; },
|
|
126
|
+
order(column, direction = 'asc') { orderParam = `${column}.${direction}`; return builder; },
|
|
127
|
+
limit(count) { limitParam = count; return builder; },
|
|
128
|
+
offset(count) { offsetParam = count; return builder; },
|
|
129
|
+
|
|
130
|
+
async execute(): Promise<ClientResponse<unknown[]>> {
|
|
131
|
+
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam);
|
|
132
|
+
const headers = await buildHeaders(options);
|
|
133
|
+
const { response, retried } = await fetchWithRetry(url, { method: 'GET', headers }, options);
|
|
134
|
+
|
|
135
|
+
if (retried) return retried as ClientResponse<unknown[]>;
|
|
136
|
+
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
return { data: null, error: await parseError(response) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const data = await response.json();
|
|
142
|
+
return { data, error: null };
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async executeWithCount(): Promise<CountResponse<unknown[]>> {
|
|
146
|
+
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam);
|
|
147
|
+
const headers = await buildHeaders(options);
|
|
148
|
+
headers['Prefer'] = 'count=exact';
|
|
149
|
+
|
|
150
|
+
const { response, retried } = await fetchWithRetry(url, { method: 'GET', headers }, options);
|
|
151
|
+
|
|
152
|
+
if (retried) return { ...retried as ClientResponse<unknown[]>, total: 0 };
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
return { data: null, error: await parseError(response), total: 0 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
const total = parseContentRange(response.headers.get('Content-Range'));
|
|
160
|
+
return { data, error: null, total };
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async single(): Promise<ClientResponse<unknown>> {
|
|
164
|
+
const result = await builder.execute();
|
|
165
|
+
|
|
166
|
+
if (result.error) return { data: null, error: result.error };
|
|
167
|
+
|
|
168
|
+
const rows = result.data as unknown[];
|
|
169
|
+
if (!rows || rows.length === 0) {
|
|
170
|
+
return { data: null, error: { status: 406, message: 'No rows returned' } };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { data: rows[0], error: null };
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async insert(data: Record<string, unknown>): Promise<ClientResponse<unknown>> {
|
|
177
|
+
const url = buildUrl(options.baseUrl, table, [], null, null, null, null);
|
|
178
|
+
const headers = await buildHeaders(options);
|
|
179
|
+
headers['Content-Type'] = 'application/json';
|
|
180
|
+
|
|
181
|
+
const { response, retried } = await fetchWithRetry(
|
|
182
|
+
url,
|
|
183
|
+
{ method: 'POST', headers, body: JSON.stringify(data) },
|
|
184
|
+
options,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (retried) return retried as ClientResponse<unknown>;
|
|
188
|
+
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
return { data: null, error: await parseError(response) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await response.json();
|
|
194
|
+
return { data: result, error: null };
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async update(data: Record<string, unknown>): Promise<ClientResponse<unknown[]>> {
|
|
198
|
+
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, null, null);
|
|
199
|
+
const headers = await buildHeaders(options);
|
|
200
|
+
headers['Content-Type'] = 'application/json';
|
|
201
|
+
|
|
202
|
+
const { response, retried } = await fetchWithRetry(
|
|
203
|
+
url,
|
|
204
|
+
{ method: 'PATCH', headers, body: JSON.stringify(data) },
|
|
205
|
+
options,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (retried) return retried as ClientResponse<unknown[]>;
|
|
209
|
+
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
return { data: null, error: await parseError(response) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result = await response.json();
|
|
215
|
+
return { data: result, error: null };
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async remove(): Promise<ClientResponse<unknown[]>> {
|
|
219
|
+
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, null, null);
|
|
220
|
+
const headers = await buildHeaders(options);
|
|
221
|
+
|
|
222
|
+
const { response, retried } = await fetchWithRetry(
|
|
223
|
+
url,
|
|
224
|
+
{ method: 'DELETE', headers },
|
|
225
|
+
options,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (retried) return retried as ClientResponse<unknown[]>;
|
|
229
|
+
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
return { data: null, error: await parseError(response) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const result = await response.json();
|
|
235
|
+
return { data: result, error: null };
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return builder;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ensures only one token refresh is in-flight at a time.
|
|
244
|
+
* Concurrent 401 responses all wait for the same refresh promise.
|
|
245
|
+
*/
|
|
246
|
+
async function deduplicatedRefresh(refreshFn: () => Promise<string | null>): Promise<string | null> {
|
|
247
|
+
if (activeRefresh) {
|
|
248
|
+
return activeRefresh;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
activeRefresh = refreshFn().finally(() => {
|
|
252
|
+
activeRefresh = null;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return activeRefresh;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Fetch with automatic 401 retry using token refresh.
|
|
260
|
+
* Returns either the final response or a pre-built error result from retry failure.
|
|
261
|
+
*/
|
|
262
|
+
async function fetchWithRetry(
|
|
263
|
+
url: string,
|
|
264
|
+
init: RequestInit,
|
|
265
|
+
options: ClientOptions,
|
|
266
|
+
): Promise<{ response: Response; retried?: never } | { response?: never; retried: ClientResponse<unknown> }> {
|
|
267
|
+
const response = await fetch(url, init);
|
|
268
|
+
|
|
269
|
+
if (response.status === 401 && options.onTokenExpired) {
|
|
270
|
+
const newToken = await deduplicatedRefresh(options.onTokenExpired);
|
|
271
|
+
if (newToken) {
|
|
272
|
+
const retryHeaders = { ...(init.headers as Record<string, string>), 'Authorization': `Bearer ${newToken}` };
|
|
273
|
+
const retryResponse = await fetch(url, { ...init, headers: retryHeaders });
|
|
274
|
+
|
|
275
|
+
if (!retryResponse.ok) {
|
|
276
|
+
return { retried: { data: null, error: await parseError(retryResponse) } };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Return the successful retry response for the caller to parse
|
|
280
|
+
return { response: retryResponse };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Refresh failed
|
|
284
|
+
return { retried: { data: null, error: await parseError(response) } };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { response };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Parse total count from Content-Range header.
|
|
292
|
+
* Formats: "0-9/42", "* /0", "0-0/*"
|
|
293
|
+
*/
|
|
294
|
+
function parseContentRange(header: string | null): number {
|
|
295
|
+
if (!header) return 0;
|
|
296
|
+
const parts = header.split('/');
|
|
297
|
+
if (parts.length !== 2) return 0;
|
|
298
|
+
const total = parseInt(parts[1], 10);
|
|
299
|
+
return isNaN(total) ? 0 : total;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function parseError(response: Response): Promise<ClientError> {
|
|
303
|
+
const body = await response.json().catch(() => ({}));
|
|
304
|
+
return { status: response.status, message: body.message || response.statusText };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildUrl(
|
|
308
|
+
baseUrl: string,
|
|
309
|
+
table: string,
|
|
310
|
+
filters: Array<[string, string]>,
|
|
311
|
+
select: string | null,
|
|
312
|
+
order: string | null,
|
|
313
|
+
limit: number | null,
|
|
314
|
+
offset: number | null,
|
|
315
|
+
): string {
|
|
316
|
+
const path = `${baseUrl}/${table}`;
|
|
317
|
+
const base = typeof globalThis.location !== 'undefined' ? globalThis.location.origin : undefined;
|
|
318
|
+
const url = new URL(path, base);
|
|
319
|
+
|
|
320
|
+
for (const [key, value] of filters) {
|
|
321
|
+
url.searchParams.set(key, value);
|
|
322
|
+
}
|
|
323
|
+
if (select) url.searchParams.set('select', select);
|
|
324
|
+
if (order) url.searchParams.set('order', order);
|
|
325
|
+
if (limit !== null) url.searchParams.set('limit', String(limit));
|
|
326
|
+
if (offset !== null) url.searchParams.set('offset', String(offset));
|
|
327
|
+
|
|
328
|
+
return url.toString();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function buildHeaders(options: ClientOptions): Promise<Record<string, string>> {
|
|
332
|
+
const headers: Record<string, string> = {
|
|
333
|
+
'Accept': 'application/json',
|
|
334
|
+
...options.headers,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (options.getToken) {
|
|
338
|
+
const token = await Promise.resolve(options.getToken());
|
|
339
|
+
if (token) {
|
|
340
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (options.clientCredentials) {
|
|
345
|
+
const idHeader = options.clientCredentials.headers?.id ?? 'X-Client-Id';
|
|
346
|
+
const secretHeader = options.clientCredentials.headers?.secret ?? 'X-Client-Secret';
|
|
347
|
+
headers[idHeader] = options.clientCredentials.id;
|
|
348
|
+
headers[secretHeader] = options.clientCredentials.secret;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return headers;
|
|
352
|
+
}
|