@hypequery/serve 0.2.0 → 0.2.1
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 +53 -931
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
# @hypequery/serve
|
|
2
2
|
|
|
3
|
-
Code-first runtime for
|
|
3
|
+
Code-first runtime for turning hypequery queries into reusable contracts, direct execution helpers, and HTTP routes.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Use it when a local query should become something the rest of your app can call consistently.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
npm install @hypequery/serve zod
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
`tsx` is an optional peer dependency used by the local dev workflow.
|
|
12
14
|
|
|
13
15
|
## Quick Start
|
|
14
16
|
|
|
15
|
-
Recommended path:
|
|
16
|
-
|
|
17
|
-
1. Build a typed ClickHouse query
|
|
18
|
-
2. Wrap it with `query({ ... })` when it becomes a reusable contract
|
|
19
|
-
3. Add `serve({ queries })` when you need HTTP routes, docs, or adapters
|
|
20
|
-
|
|
21
17
|
```ts
|
|
22
|
-
// analytics/queries.ts
|
|
23
18
|
import { initServe } from '@hypequery/serve';
|
|
24
19
|
import { z } from 'zod';
|
|
25
|
-
import { db } from './client';
|
|
20
|
+
import { db } from './client.js';
|
|
26
21
|
|
|
27
22
|
const { query, serve } = initServe({
|
|
28
23
|
context: () => ({ db }),
|
|
@@ -34,10 +29,9 @@ const weeklyRevenue = query({
|
|
|
34
29
|
input: z.object({ startDate: z.string() }),
|
|
35
30
|
query: ({ ctx, input }) =>
|
|
36
31
|
ctx.db
|
|
37
|
-
.table('
|
|
38
|
-
.
|
|
39
|
-
.
|
|
40
|
-
.sum('total_amount', 'total')
|
|
32
|
+
.table('orders')
|
|
33
|
+
.where('created_at', 'gte', input.startDate)
|
|
34
|
+
.sum('total', 'revenue')
|
|
41
35
|
.execute(),
|
|
42
36
|
});
|
|
43
37
|
|
|
@@ -45,948 +39,76 @@ export const api = serve({
|
|
|
45
39
|
queries: { weeklyRevenue },
|
|
46
40
|
});
|
|
47
41
|
|
|
48
|
-
// Register an HTTP route
|
|
49
|
-
api.route('/weeklyRevenue', api.queries.weeklyRevenue);
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
// analytics/server.ts
|
|
54
|
-
import { api } from './queries';
|
|
55
|
-
|
|
56
|
-
const server = await api.start({ port: 4000 });
|
|
57
|
-
|
|
58
|
-
process.on('SIGTERM', async () => {
|
|
59
|
-
await server.stop();
|
|
60
|
-
});
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
With the server running:
|
|
64
|
-
- **Endpoint**: `POST http://localhost:4000/api/analytics/weeklyRevenue`
|
|
65
|
-
- **Docs**: `http://localhost:4000/api/analytics/docs`
|
|
66
|
-
- **OpenAPI**: `http://localhost:4000/api/analytics/openapi.json`
|
|
67
|
-
|
|
68
|
-
---
|
|
69
|
-
|
|
70
|
-
## Core Concepts
|
|
71
|
-
|
|
72
|
-
### 1. Create Queries And A Runtime
|
|
73
|
-
|
|
74
|
-
#### `initServe<TContext, TAuth>(options)`
|
|
75
|
-
|
|
76
|
-
Main entry point for creating typed query definitions and a serve runtime.
|
|
77
|
-
|
|
78
|
-
**Parameters:**
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
interface ServeInitializerOptions<TContext, TAuth> {
|
|
82
|
-
// Context factory (runs per-request to inject dependencies)
|
|
83
|
-
context: TContext | ((opts: { request: ServeRequest; auth: TAuth | null }) => TContext | Promise<TContext>);
|
|
84
|
-
|
|
85
|
-
// Base path for all routes
|
|
86
|
-
basePath?: string;
|
|
87
|
-
|
|
88
|
-
// Authentication strategy
|
|
89
|
-
auth?: AuthStrategy<TAuth> | AuthStrategy<TAuth>[];
|
|
90
|
-
|
|
91
|
-
// Global middlewares (run before every endpoint)
|
|
92
|
-
middlewares?: ServeMiddleware<any, any, TContext, TAuth>[];
|
|
93
|
-
|
|
94
|
-
// Multi-tenancy configuration
|
|
95
|
-
tenant?: TenantConfig<TAuth>;
|
|
96
|
-
|
|
97
|
-
// Lifecycle hooks
|
|
98
|
-
hooks?: ServeLifecycleHooks<TAuth>;
|
|
99
|
-
|
|
100
|
-
// OpenAPI configuration
|
|
101
|
-
openapi?: OpenApiOptions;
|
|
102
|
-
|
|
103
|
-
// Docs UI configuration
|
|
104
|
-
docs?: DocsOptions;
|
|
105
|
-
}
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
**Returns:**
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
interface ServeInitializer<TContext, TAuth> {
|
|
112
|
-
query: QueryFactory<TContext, TAuth>;
|
|
113
|
-
serve<TQueries>(config: ServeConfig<TQueries, TContext, TAuth>): ServeBuilder<TQueries, TContext, TAuth>;
|
|
114
|
-
}
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Use `query({ ... })` to define a typed contract:
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
const weeklyRevenue = query({
|
|
121
|
-
description: 'Calculate weekly revenue totals',
|
|
122
|
-
input: z.object({ startDate: z.string() }),
|
|
123
|
-
query: async ({ ctx, input }) => {
|
|
124
|
-
return ctx.db
|
|
125
|
-
.table('sales')
|
|
126
|
-
.where('date', 'gte', input.startDate)
|
|
127
|
-
.sum('amount', 'total')
|
|
128
|
-
.execute();
|
|
129
|
-
},
|
|
130
|
-
});
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
Use `serve({ queries })` to expose a typed runtime and optional HTTP surface:
|
|
134
|
-
|
|
135
|
-
```ts
|
|
136
|
-
interface ServeConfig<TQueries, TContext, TAuth> {
|
|
137
|
-
queries: TQueries;
|
|
138
|
-
basePath?: string;
|
|
139
|
-
auth?: AuthStrategy<TAuth> | AuthStrategy<TAuth>[];
|
|
140
|
-
middlewares?: ServeMiddleware<any, any, TContext, TAuth>[];
|
|
141
|
-
tenant?: TenantConfig<TAuth>;
|
|
142
|
-
hooks?: ServeLifecycleHooks<TAuth>;
|
|
143
|
-
openapi?: OpenApiOptions;
|
|
144
|
-
docs?: DocsOptions;
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
**Example:**
|
|
149
|
-
|
|
150
|
-
```ts
|
|
151
|
-
const { query, serve } = initServe({
|
|
152
|
-
basePath: '/api',
|
|
153
|
-
context: async ({ auth }) => ({
|
|
154
|
-
db: createDatabase(),
|
|
155
|
-
userId: auth?.userId,
|
|
156
|
-
}),
|
|
157
|
-
auth: createBearerTokenStrategy({
|
|
158
|
-
validate: async (token) => {
|
|
159
|
-
const user = await verifyJWT(token);
|
|
160
|
-
return user ? { userId: user.id, role: user.role } : null;
|
|
161
|
-
},
|
|
162
|
-
}),
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const getUser = query({
|
|
166
|
-
input: z.object({ id: z.string() }),
|
|
167
|
-
query: async ({ ctx, input }) => {
|
|
168
|
-
return ctx.db.query.users.findFirst({
|
|
169
|
-
where: eq(users.id, input.id),
|
|
170
|
-
});
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
const weeklyRevenue = query({
|
|
175
|
-
description: 'Calculate weekly revenue totals',
|
|
176
|
-
input: z.object({ startDate: z.string() }),
|
|
177
|
-
query: async ({ ctx, input }) => {
|
|
178
|
-
return ctx.db
|
|
179
|
-
.table('sales')
|
|
180
|
-
.where('date', 'gte', input.startDate)
|
|
181
|
-
.sum('amount', 'total')
|
|
182
|
-
.execute();
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
export const api = serve({
|
|
187
|
-
queries: { getUser, weeklyRevenue },
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
api.route('/users/:id', api.queries.getUser, { method: 'GET' });
|
|
191
42
|
api.route('/weeklyRevenue', api.queries.weeklyRevenue);
|
|
192
43
|
```
|
|
193
44
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
### 2. HTTP Adapters
|
|
197
|
-
|
|
198
|
-
Adapters convert the framework-agnostic `ServeHandler` into platform-specific handlers.
|
|
199
|
-
|
|
200
|
-
#### `createNodeHandler(handler)`
|
|
201
|
-
|
|
202
|
-
Creates a Node.js HTTP handler for use with `http.createServer()` or Express.
|
|
203
|
-
|
|
204
|
-
```ts
|
|
205
|
-
import { createNodeHandler } from '@hypequery/serve';
|
|
206
|
-
import { createServer } from 'http';
|
|
207
|
-
|
|
208
|
-
const nodeHandler = createNodeHandler(api.handler);
|
|
209
|
-
const server = createServer(nodeHandler);
|
|
210
|
-
server.listen(3000);
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
---
|
|
214
|
-
|
|
215
|
-
#### `createFetchHandler(handler)`
|
|
216
|
-
|
|
217
|
-
Creates a Web Fetch API handler for modern runtimes (Cloudflare Workers, Deno, Bun).
|
|
218
|
-
|
|
219
|
-
```ts
|
|
220
|
-
import { createFetchHandler } from '@hypequery/serve';
|
|
221
|
-
|
|
222
|
-
const fetchHandler = createFetchHandler(api.handler);
|
|
223
|
-
|
|
224
|
-
// Cloudflare Workers
|
|
225
|
-
export default {
|
|
226
|
-
fetch: fetchHandler,
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
// Deno
|
|
230
|
-
Deno.serve(fetchHandler);
|
|
231
|
-
|
|
232
|
-
// Bun
|
|
233
|
-
Bun.serve({
|
|
234
|
-
fetch: fetchHandler,
|
|
235
|
-
port: 3000,
|
|
236
|
-
});
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
#### `createVercelEdgeHandler(handler)`
|
|
242
|
-
|
|
243
|
-
Creates a Vercel Edge Runtime handler (uses Fetch API).
|
|
244
|
-
|
|
245
|
-
```ts
|
|
246
|
-
// pages/api/analytics.ts
|
|
247
|
-
import { createVercelEdgeHandler } from '@hypequery/serve';
|
|
248
|
-
import { api } from '@/analytics/server';
|
|
249
|
-
|
|
250
|
-
export const config = { runtime: 'edge' };
|
|
251
|
-
export default createVercelEdgeHandler(api.handler);
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
---
|
|
255
|
-
|
|
256
|
-
#### `createVercelNodeHandler(handler)`
|
|
257
|
-
|
|
258
|
-
Creates a Vercel Node.js handler (uses Node HTTP).
|
|
259
|
-
|
|
260
|
-
```ts
|
|
261
|
-
// pages/api/analytics.ts
|
|
262
|
-
import { createVercelNodeHandler } from '@hypequery/serve';
|
|
263
|
-
import { api } from '@/analytics/server';
|
|
264
|
-
|
|
265
|
-
export default createVercelNodeHandler(api.handler);
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
---
|
|
269
|
-
|
|
270
|
-
### 3. Authentication
|
|
271
|
-
|
|
272
|
-
#### `createApiKeyStrategy<TAuth>(options)`
|
|
273
|
-
|
|
274
|
-
Creates an authentication strategy that validates API keys from headers or query parameters.
|
|
275
|
-
|
|
276
|
-
**Parameters:**
|
|
277
|
-
|
|
278
|
-
```ts
|
|
279
|
-
interface ApiKeyStrategyOptions<TAuth> {
|
|
280
|
-
header?: string; // Header name (default: "authorization")
|
|
281
|
-
queryParam?: string; // Query param name (optional)
|
|
282
|
-
validate: (key: string, request: ServeRequest) => Promise<TAuth | null> | TAuth | null;
|
|
283
|
-
}
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
**Example:**
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
import { createApiKeyStrategy, initServe } from '@hypequery/serve';
|
|
290
|
-
|
|
291
|
-
const apiKeyAuth = createApiKeyStrategy({
|
|
292
|
-
header: 'x-api-key',
|
|
293
|
-
queryParam: 'apiKey',
|
|
294
|
-
validate: async (key) => {
|
|
295
|
-
const user = await db.query.apiKeys.findFirst({
|
|
296
|
-
where: eq(apiKeys.key, key),
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
if (!user || user.revoked) return null;
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
userId: user.userId,
|
|
303
|
-
scopes: user.scopes,
|
|
304
|
-
};
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
const { query, serve } = initServe({
|
|
309
|
-
auth: apiKeyAuth,
|
|
310
|
-
context: () => ({ db }),
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const revenue = query({
|
|
314
|
-
query: ({ ctx }) => ctx.db.table('sales').sum('amount', 'total').execute(),
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
const api = serve({
|
|
318
|
-
queries: { revenue },
|
|
319
|
-
});
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
**Usage:**
|
|
323
|
-
|
|
324
|
-
```bash
|
|
325
|
-
# Header (preferred)
|
|
326
|
-
curl -H "x-api-key: sk_live_abc123" http://localhost:3000/revenue
|
|
327
|
-
|
|
328
|
-
# Query param (development only)
|
|
329
|
-
curl http://localhost:3000/revenue?apiKey=sk_live_abc123
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
---
|
|
333
|
-
|
|
334
|
-
#### `createBearerTokenStrategy<TAuth>(options)`
|
|
335
|
-
|
|
336
|
-
Creates an authentication strategy that validates Bearer tokens (JWT, OAuth).
|
|
337
|
-
|
|
338
|
-
**Parameters:**
|
|
339
|
-
|
|
340
|
-
```ts
|
|
341
|
-
interface BearerTokenStrategyOptions<TAuth> {
|
|
342
|
-
header?: string; // Header name (default: "authorization")
|
|
343
|
-
prefix?: string; // Token prefix (default: "Bearer ")
|
|
344
|
-
validate: (token: string, request: ServeRequest) => Promise<TAuth | null> | TAuth | null;
|
|
345
|
-
}
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
**Example:**
|
|
349
|
-
|
|
350
|
-
```ts
|
|
351
|
-
import { createBearerTokenStrategy, initServe } from '@hypequery/serve';
|
|
352
|
-
import jwt from 'jsonwebtoken';
|
|
353
|
-
|
|
354
|
-
const jwtAuth = createBearerTokenStrategy({
|
|
355
|
-
validate: async (token) => {
|
|
356
|
-
try {
|
|
357
|
-
const payload = jwt.verify(token, process.env.JWT_SECRET) as {
|
|
358
|
-
sub: string;
|
|
359
|
-
role: string;
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
userId: payload.sub,
|
|
364
|
-
role: payload.role,
|
|
365
|
-
};
|
|
366
|
-
} catch {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
const { query, serve } = initServe({
|
|
373
|
-
auth: jwtAuth,
|
|
374
|
-
context: () => ({ db: createDatabase() }),
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
const revenue = query({
|
|
378
|
-
query: ({ ctx }) => ctx.db.table('sales').sum('amount', 'total').execute(),
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
const api = serve({
|
|
382
|
-
queries: { revenue },
|
|
383
|
-
});
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
**Usage:**
|
|
387
|
-
|
|
388
|
-
```bash
|
|
389
|
-
curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3000/revenue
|
|
390
|
-
```
|
|
391
|
-
|
|
392
|
-
---
|
|
393
|
-
|
|
394
|
-
### 4. Query Definition Options
|
|
395
|
-
|
|
396
|
-
`query({ ... })` accepts query logic plus optional metadata used for validation, docs, caching, and routing.
|
|
397
|
-
|
|
398
|
-
```ts
|
|
399
|
-
interface QueryConfig<TInput, TOutput, TContext, TAuth> {
|
|
400
|
-
query: (args: QueryResolverArgs<TInput, TContext, TAuth>) => Promise<TOutput> | TOutput;
|
|
401
|
-
input?: ZodTypeAny;
|
|
402
|
-
output?: ZodTypeAny;
|
|
403
|
-
description?: string;
|
|
404
|
-
summary?: string;
|
|
405
|
-
tags?: string[];
|
|
406
|
-
method?: HttpMethod;
|
|
407
|
-
cache?: number | null;
|
|
408
|
-
auth?: AuthStrategy<TAuth>;
|
|
409
|
-
tenant?: Partial<TenantConfig<TAuth>>;
|
|
410
|
-
middlewares?: ServeMiddleware<any, any, TContext, TAuth>[];
|
|
411
|
-
metadata?: Record<string, unknown>;
|
|
412
|
-
}
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
**Example:**
|
|
416
|
-
|
|
417
|
-
```ts
|
|
418
|
-
const getAnalytics = query({
|
|
419
|
-
description: 'Returns aggregated analytics for the selected metric and date range.',
|
|
420
|
-
summary: 'Analytics totals by date range',
|
|
421
|
-
input: z.object({
|
|
422
|
-
startDate: z.string(),
|
|
423
|
-
endDate: z.string(),
|
|
424
|
-
metric: z.enum(['revenue', 'users', 'sessions']),
|
|
425
|
-
}),
|
|
426
|
-
tags: ['Analytics'],
|
|
427
|
-
cache: 300000,
|
|
428
|
-
query: async ({ ctx, input }) => {
|
|
429
|
-
const result = await ctx.db
|
|
430
|
-
.table('analytics')
|
|
431
|
-
.where('date', 'gte', input.startDate)
|
|
432
|
-
.where('date', 'lte', input.endDate)
|
|
433
|
-
.sum(input.metric, 'total')
|
|
434
|
-
.execute();
|
|
435
|
-
|
|
436
|
-
return result[0];
|
|
437
|
-
},
|
|
438
|
-
});
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
---
|
|
45
|
+
Now you can:
|
|
442
46
|
|
|
443
|
-
|
|
47
|
+
- call `api.execute('weeklyRevenue', { input: ... })` in process
|
|
48
|
+
- expose the same query over HTTP
|
|
49
|
+
- consume it from `@hypequery/react`
|
|
50
|
+
- describe it for tools and agents
|
|
444
51
|
|
|
445
|
-
|
|
52
|
+
## Main Ideas
|
|
446
53
|
|
|
447
|
-
|
|
54
|
+
### `query({ ... })`
|
|
448
55
|
|
|
449
|
-
|
|
450
|
-
interface TenantConfig<TAuth> {
|
|
451
|
-
// Extract tenant ID from auth context
|
|
452
|
-
extract: (auth: TAuth) => string | null;
|
|
56
|
+
Defines a typed contract:
|
|
453
57
|
|
|
454
|
-
|
|
455
|
-
|
|
58
|
+
- description
|
|
59
|
+
- optional input schema
|
|
60
|
+
- query implementation
|
|
456
61
|
|
|
457
|
-
|
|
458
|
-
column?: string;
|
|
62
|
+
### `serve({ queries })`
|
|
459
63
|
|
|
460
|
-
|
|
461
|
-
required?: boolean;
|
|
64
|
+
Builds a runtime around those contracts:
|
|
462
65
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
66
|
+
- direct execution
|
|
67
|
+
- route registration
|
|
68
|
+
- docs and OpenAPI support
|
|
69
|
+
- hooks, auth, and tenancy features when needed
|
|
467
70
|
|
|
468
|
-
|
|
71
|
+
## Common Example
|
|
469
72
|
|
|
470
73
|
```ts
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const getUsers = query({
|
|
484
|
-
query: async ({ ctx }) => {
|
|
485
|
-
// Manually filter by tenant
|
|
486
|
-
return ctx.db
|
|
487
|
-
.table('users')
|
|
488
|
-
.where('tenant_id', 'eq', ctx.tenantId)
|
|
489
|
-
.execute();
|
|
490
|
-
},
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
export const api = serve({
|
|
494
|
-
queries: { getUsers },
|
|
495
|
-
});
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
**Example (Auto-Inject Mode):**
|
|
499
|
-
|
|
500
|
-
```ts
|
|
501
|
-
const { query, serve } = initServe({
|
|
502
|
-
tenant: {
|
|
503
|
-
extract: (auth) => auth?.organizationId ?? null,
|
|
504
|
-
mode: 'auto-inject',
|
|
505
|
-
column: 'organization_id', // Column to filter on
|
|
506
|
-
},
|
|
507
|
-
context: async () => ({
|
|
508
|
-
db: createDatabase(),
|
|
509
|
-
}),
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
const getUsers = query({
|
|
513
|
-
query: async ({ ctx }) => {
|
|
514
|
-
// Tenant filter is automatically injected
|
|
515
|
-
return ctx.db
|
|
516
|
-
.table('users')
|
|
517
|
-
.select(['id', 'name'])
|
|
518
|
-
.execute();
|
|
519
|
-
// Equivalent to: SELECT id, name FROM users WHERE organization_id = <tenant_id>
|
|
520
|
-
},
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
export const api = serve({
|
|
524
|
-
queries: { getUsers },
|
|
525
|
-
});
|
|
526
|
-
```
|
|
527
|
-
|
|
528
|
-
**Per-query override (optional tenant, no auto-inject):**
|
|
529
|
-
|
|
530
|
-
```ts
|
|
531
|
-
const adminStats = query
|
|
532
|
-
.tenantOptional({ mode: 'manual' })
|
|
533
|
-
.query(async ({ ctx }) => {
|
|
534
|
-
if (ctx.tenantId) {
|
|
535
|
-
return ctx.db.table('stats').where('tenant_id', 'eq', ctx.tenantId).execute();
|
|
536
|
-
}
|
|
537
|
-
return ctx.db.table('stats').execute();
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
export const api = serve({
|
|
541
|
-
queries: { adminStats },
|
|
542
|
-
});
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
---
|
|
546
|
-
|
|
547
|
-
### 6. Client Configuration
|
|
548
|
-
|
|
549
|
-
#### `extractClientConfig(api)`
|
|
550
|
-
|
|
551
|
-
Extracts serializable client configuration from a `ServeBuilder`. Returns HTTP method information for each query, used by `@hypequery/react` to configure hooks.
|
|
552
|
-
|
|
553
|
-
**Example:**
|
|
554
|
-
|
|
555
|
-
```ts
|
|
556
|
-
// Server-side API route
|
|
557
|
-
import { api } from '@/analytics/server';
|
|
558
|
-
import { extractClientConfig } from '@hypequery/serve';
|
|
559
|
-
|
|
560
|
-
export async function GET() {
|
|
561
|
-
return Response.json(extractClientConfig(api));
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Returns:
|
|
565
|
-
// {
|
|
566
|
-
// "weeklyRevenue": { "method": "GET" },
|
|
567
|
-
// "createSale": { "method": "POST" }
|
|
568
|
-
// }
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
**Client-side usage:**
|
|
572
|
-
|
|
573
|
-
```ts
|
|
574
|
-
// lib/analytics.ts
|
|
575
|
-
import { createHooks } from '@hypequery/react';
|
|
576
|
-
import type { Api } from '@/analytics/server';
|
|
577
|
-
|
|
578
|
-
const config = await fetch('/api/config').then(r => r.json());
|
|
579
|
-
|
|
580
|
-
export const { useQuery, useMutation } = createHooks<Api>({
|
|
581
|
-
baseUrl: '/api/analytics',
|
|
582
|
-
config, // Auto-configures HTTP methods
|
|
583
|
-
});
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
---
|
|
587
|
-
|
|
588
|
-
#### `defineClientConfig(config)`
|
|
589
|
-
|
|
590
|
-
Type-safe helper to manually define client configuration when you can't access the API object.
|
|
591
|
-
|
|
592
|
-
**Example:**
|
|
593
|
-
|
|
594
|
-
```ts
|
|
595
|
-
import { defineClientConfig } from '@hypequery/serve';
|
|
596
|
-
|
|
597
|
-
const config = defineClientConfig({
|
|
598
|
-
weeklyRevenue: { method: 'GET' },
|
|
599
|
-
createSale: { method: 'POST' },
|
|
600
|
-
updateProduct: { method: 'PUT' },
|
|
601
|
-
deleteOrder: { method: 'DELETE' },
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
export const { useQuery, useMutation } = createHooks<Api>({
|
|
605
|
-
baseUrl: '/api',
|
|
606
|
-
config,
|
|
607
|
-
});
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
---
|
|
611
|
-
|
|
612
|
-
### 7. Development Server
|
|
613
|
-
|
|
614
|
-
#### `serveDev(api, options?)`
|
|
615
|
-
|
|
616
|
-
Starts a development server with enhanced logging and automatic documentation.
|
|
617
|
-
|
|
618
|
-
**Parameters:**
|
|
619
|
-
|
|
620
|
-
```ts
|
|
621
|
-
interface ServeDevOptions {
|
|
622
|
-
port?: number; // Default: 4000 or process.env.PORT
|
|
623
|
-
hostname?: string; // Default: 'localhost'
|
|
624
|
-
quiet?: boolean; // Suppress logs (default: false)
|
|
625
|
-
signal?: AbortSignal; // Graceful shutdown signal
|
|
626
|
-
logger?: (message: string) => void; // Custom logger
|
|
627
|
-
}
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
**Example:**
|
|
631
|
-
|
|
632
|
-
```ts
|
|
633
|
-
import { serveDev } from '@hypequery/serve';
|
|
634
|
-
import { api } from './analytics/server';
|
|
635
|
-
|
|
636
|
-
await serveDev(api, {
|
|
637
|
-
port: 4000,
|
|
638
|
-
logger: (msg) => console.log(`[API] ${msg}`),
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
// Output:
|
|
642
|
-
// [API] hypequery dev server running at http://localhost:4000
|
|
643
|
-
// [API] Docs available at http://localhost:4000/docs
|
|
644
|
-
```
|
|
645
|
-
|
|
646
|
-
---
|
|
647
|
-
|
|
648
|
-
## Advanced Features
|
|
649
|
-
|
|
650
|
-
### Middleware
|
|
651
|
-
|
|
652
|
-
Middlewares run before query handlers and can modify context, validate permissions, or log requests.
|
|
653
|
-
|
|
654
|
-
**Signature:**
|
|
655
|
-
|
|
656
|
-
```ts
|
|
657
|
-
type ServeMiddleware<TInput, TOutput, TContext, TAuth> = (
|
|
658
|
-
ctx: EndpointContext<TInput, TContext, TAuth>,
|
|
659
|
-
next: () => Promise<TOutput>
|
|
660
|
-
) => Promise<TOutput>;
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
**Example:**
|
|
664
|
-
|
|
665
|
-
```ts
|
|
666
|
-
// Logging middleware
|
|
667
|
-
const logMiddleware: ServeMiddleware<any, any, any, any> = async (ctx, next) => {
|
|
668
|
-
console.log(`[${ctx.request.method}] ${ctx.request.path}`);
|
|
669
|
-
const start = Date.now();
|
|
670
|
-
const result = await next();
|
|
671
|
-
console.log(`Completed in ${Date.now() - start}ms`);
|
|
672
|
-
return result;
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
// Permission middleware
|
|
676
|
-
const requireAdmin: ServeMiddleware<any, any, any, { role: string }> = async (ctx, next) => {
|
|
677
|
-
if (ctx.auth?.role !== 'admin') {
|
|
678
|
-
throw new Error('Admin access required');
|
|
679
|
-
}
|
|
680
|
-
return next();
|
|
681
|
-
};
|
|
682
|
-
|
|
683
|
-
const { query, serve } = initServe({
|
|
684
|
-
context: () => ({ db: createDatabase() }),
|
|
685
|
-
middlewares: [logMiddleware],
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
const deleteUser = query({
|
|
689
|
-
input: z.object({ id: z.string() }),
|
|
690
|
-
middlewares: [requireAdmin],
|
|
691
|
-
query: async ({ input, ctx }) => {
|
|
692
|
-
// Only admins can reach here
|
|
693
|
-
return ctx.db.table('users').where('id', 'eq', input.id).execute();
|
|
694
|
-
},
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
const api = serve({
|
|
698
|
-
queries: { deleteUser },
|
|
699
|
-
});
|
|
700
|
-
```
|
|
701
|
-
|
|
702
|
-
---
|
|
703
|
-
|
|
704
|
-
### Lifecycle Hooks
|
|
705
|
-
|
|
706
|
-
Hooks provide observability into the request lifecycle.
|
|
707
|
-
|
|
708
|
-
**Available Hooks:**
|
|
709
|
-
|
|
710
|
-
```ts
|
|
711
|
-
interface ServeLifecycleHooks<TAuth> {
|
|
712
|
-
// Before request processing
|
|
713
|
-
onRequestStart?: (event: {
|
|
714
|
-
requestId: string;
|
|
715
|
-
queryKey: string;
|
|
716
|
-
metadata: EndpointMetadata;
|
|
717
|
-
request: ServeRequest;
|
|
718
|
-
auth: TAuth | null;
|
|
719
|
-
}) => void | Promise<void>;
|
|
720
|
-
|
|
721
|
-
// After successful request
|
|
722
|
-
onRequestEnd?: (event: {
|
|
723
|
-
requestId: string;
|
|
724
|
-
queryKey: string;
|
|
725
|
-
metadata: EndpointMetadata;
|
|
726
|
-
request: ServeRequest;
|
|
727
|
-
auth: TAuth | null;
|
|
728
|
-
durationMs: number;
|
|
729
|
-
result: unknown;
|
|
730
|
-
}) => void | Promise<void>;
|
|
731
|
-
|
|
732
|
-
// On authentication failure
|
|
733
|
-
onAuthFailure?: (event: {
|
|
734
|
-
requestId: string;
|
|
735
|
-
queryKey: string;
|
|
736
|
-
metadata: EndpointMetadata;
|
|
737
|
-
request: ServeRequest;
|
|
738
|
-
auth: TAuth | null;
|
|
739
|
-
reason: 'MISSING' | 'INVALID';
|
|
740
|
-
}) => void | Promise<void>;
|
|
741
|
-
|
|
742
|
-
// On any error
|
|
743
|
-
onError?: (event: {
|
|
744
|
-
requestId: string;
|
|
745
|
-
queryKey: string;
|
|
746
|
-
metadata: EndpointMetadata;
|
|
747
|
-
request: ServeRequest;
|
|
748
|
-
auth: TAuth | null;
|
|
749
|
-
durationMs: number;
|
|
750
|
-
error: unknown;
|
|
751
|
-
}) => void | Promise<void>;
|
|
752
|
-
}
|
|
753
|
-
```
|
|
754
|
-
|
|
755
|
-
**Example:**
|
|
756
|
-
|
|
757
|
-
```ts
|
|
758
|
-
const api = serve({
|
|
759
|
-
hooks: {
|
|
760
|
-
onRequestStart: async (event) => {
|
|
761
|
-
await analytics.track({
|
|
762
|
-
event: 'api_request_start',
|
|
763
|
-
queryKey: event.queryKey,
|
|
764
|
-
userId: event.auth?.userId,
|
|
765
|
-
});
|
|
766
|
-
},
|
|
767
|
-
|
|
768
|
-
onError: async (event) => {
|
|
769
|
-
await errorReporting.captureException(event.error, {
|
|
770
|
-
queryKey: event.queryKey,
|
|
771
|
-
requestId: event.requestId,
|
|
772
|
-
});
|
|
773
|
-
},
|
|
774
|
-
},
|
|
775
|
-
queries: { /* ... */ },
|
|
776
|
-
});
|
|
777
|
-
```
|
|
778
|
-
|
|
779
|
-
---
|
|
780
|
-
|
|
781
|
-
### OpenAPI Configuration
|
|
782
|
-
|
|
783
|
-
**Options:**
|
|
784
|
-
|
|
785
|
-
```ts
|
|
786
|
-
interface OpenApiOptions {
|
|
787
|
-
enabled?: boolean; // Enable OpenAPI endpoint (default: true)
|
|
788
|
-
path?: string; // OpenAPI JSON path (default: '/openapi.json')
|
|
789
|
-
info?: {
|
|
790
|
-
title?: string; // API title (default: 'Hypequery API')
|
|
791
|
-
version?: string; // API version (default: '1.0.0')
|
|
792
|
-
description?: string; // API description
|
|
793
|
-
};
|
|
794
|
-
servers?: Array<{
|
|
795
|
-
url: string;
|
|
796
|
-
description?: string;
|
|
797
|
-
}>;
|
|
798
|
-
}
|
|
799
|
-
```
|
|
800
|
-
|
|
801
|
-
**Example:**
|
|
802
|
-
|
|
803
|
-
```ts
|
|
804
|
-
const api = serve({
|
|
805
|
-
openapi: {
|
|
806
|
-
path: '/api-schema.json',
|
|
807
|
-
info: {
|
|
808
|
-
title: 'Analytics API',
|
|
809
|
-
version: '2.0.0',
|
|
810
|
-
description: 'Real-time analytics and reporting API',
|
|
811
|
-
},
|
|
812
|
-
servers: [
|
|
813
|
-
{ url: 'https://api.example.com', description: 'Production' },
|
|
814
|
-
{ url: 'http://localhost:4000', description: 'Development' },
|
|
815
|
-
],
|
|
816
|
-
},
|
|
817
|
-
queries: { /* ... */ },
|
|
818
|
-
});
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
---
|
|
822
|
-
|
|
823
|
-
### Documentation UI
|
|
824
|
-
|
|
825
|
-
**Options:**
|
|
826
|
-
|
|
827
|
-
```ts
|
|
828
|
-
interface DocsOptions {
|
|
829
|
-
enabled?: boolean; // Enable docs UI (default: true)
|
|
830
|
-
path?: string; // Docs UI path (default: '/docs')
|
|
831
|
-
title?: string; // Page title (default: 'API Documentation')
|
|
832
|
-
}
|
|
833
|
-
```
|
|
834
|
-
|
|
835
|
-
**Example:**
|
|
836
|
-
|
|
837
|
-
```ts
|
|
838
|
-
const api = serve({
|
|
839
|
-
docs: {
|
|
840
|
-
path: '/api-docs',
|
|
841
|
-
title: 'Analytics API Reference',
|
|
842
|
-
},
|
|
843
|
-
queries: { /* ... */ },
|
|
844
|
-
});
|
|
845
|
-
```
|
|
846
|
-
|
|
847
|
-
---
|
|
848
|
-
|
|
849
|
-
## Deployment Examples
|
|
850
|
-
|
|
851
|
-
### Vercel (Edge Runtime)
|
|
852
|
-
|
|
853
|
-
```ts
|
|
854
|
-
// pages/api/analytics/[...path].ts
|
|
855
|
-
import { createVercelEdgeHandler } from '@hypequery/serve';
|
|
856
|
-
import { api } from '@/analytics/server';
|
|
857
|
-
|
|
858
|
-
export const config = { runtime: 'edge' };
|
|
859
|
-
export default createVercelEdgeHandler(api.handler);
|
|
860
|
-
```
|
|
861
|
-
|
|
862
|
-
---
|
|
863
|
-
|
|
864
|
-
### Cloudflare Workers
|
|
865
|
-
|
|
866
|
-
```ts
|
|
867
|
-
// src/index.ts
|
|
868
|
-
import { createFetchHandler } from '@hypequery/serve';
|
|
869
|
-
import { api } from './analytics/server';
|
|
870
|
-
|
|
871
|
-
const handler = createFetchHandler(api.handler);
|
|
872
|
-
|
|
873
|
-
export default {
|
|
874
|
-
fetch: handler,
|
|
875
|
-
};
|
|
876
|
-
```
|
|
877
|
-
|
|
878
|
-
---
|
|
879
|
-
|
|
880
|
-
### Express.js
|
|
881
|
-
|
|
882
|
-
```ts
|
|
883
|
-
// server.ts
|
|
884
|
-
import express from 'express';
|
|
885
|
-
import { createNodeHandler } from '@hypequery/serve';
|
|
886
|
-
import { api } from './analytics/server';
|
|
887
|
-
|
|
888
|
-
const app = express();
|
|
889
|
-
const analyticsHandler = createNodeHandler(api.handler);
|
|
890
|
-
|
|
891
|
-
app.use('/api/analytics', analyticsHandler);
|
|
892
|
-
app.listen(3000);
|
|
893
|
-
```
|
|
894
|
-
|
|
895
|
-
---
|
|
896
|
-
|
|
897
|
-
### Next.js App Router
|
|
898
|
-
|
|
899
|
-
```ts
|
|
900
|
-
// app/api/analytics/[...path]/route.ts
|
|
901
|
-
import { createFetchHandler } from '@hypequery/serve';
|
|
902
|
-
import { api } from '@/analytics/server';
|
|
903
|
-
|
|
904
|
-
const handler = createFetchHandler(api.handler);
|
|
905
|
-
|
|
906
|
-
export { handler as GET, handler as POST, handler as PUT, handler as DELETE };
|
|
907
|
-
```
|
|
908
|
-
|
|
909
|
-
---
|
|
910
|
-
|
|
911
|
-
## TypeScript
|
|
912
|
-
|
|
913
|
-
All functions are fully typed with automatic inference:
|
|
914
|
-
|
|
915
|
-
```ts
|
|
916
|
-
import { initServe } from '@hypequery/serve';
|
|
917
|
-
import { z } from 'zod';
|
|
918
|
-
|
|
919
|
-
const { query, serve } = initServe({
|
|
920
|
-
context: async ({ auth }) => ({
|
|
921
|
-
db: createDatabase(),
|
|
922
|
-
userId: auth?.userId,
|
|
923
|
-
}),
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
const getUser = query({
|
|
927
|
-
input: z.object({ id: z.string() }),
|
|
928
|
-
query: async ({ ctx, input }) => {
|
|
929
|
-
// input: { id: string }
|
|
930
|
-
// ctx: { db: Database; userId: string | undefined }
|
|
931
|
-
return ctx.db
|
|
932
|
-
.table('users')
|
|
933
|
-
.where('id', 'eq', input.id)
|
|
934
|
-
.select(['name', 'email'])
|
|
935
|
-
.limit(1)
|
|
936
|
-
.execute();
|
|
937
|
-
},
|
|
74
|
+
const topCustomers = query({
|
|
75
|
+
description: 'Top customers by revenue',
|
|
76
|
+
input: z.object({ limit: z.number().int().positive().default(10) }),
|
|
77
|
+
query: ({ ctx, input }) =>
|
|
78
|
+
ctx.db
|
|
79
|
+
.table('orders')
|
|
80
|
+
.select(['customer_id'])
|
|
81
|
+
.sum('total', 'revenue')
|
|
82
|
+
.groupBy('customer_id')
|
|
83
|
+
.orderBy('revenue', 'DESC')
|
|
84
|
+
.limit(input.limit)
|
|
85
|
+
.execute(),
|
|
938
86
|
});
|
|
939
87
|
|
|
940
88
|
export const api = serve({
|
|
941
|
-
queries: {
|
|
89
|
+
queries: { topCustomers },
|
|
942
90
|
});
|
|
943
91
|
|
|
944
|
-
|
|
945
|
-
const result = await api.run('getUser', { input: { id: '123' } });
|
|
946
|
-
const user = result[0];
|
|
947
|
-
// user: { name: string; email: string }
|
|
92
|
+
api.route('/topCustomers', api.queries.topCustomers);
|
|
948
93
|
```
|
|
949
94
|
|
|
950
|
-
|
|
95
|
+
## Adapters And Runtimes
|
|
951
96
|
|
|
952
|
-
|
|
97
|
+
`@hypequery/serve` can be used behind different runtimes and adapters, but most users should start with the standard `initServe(...).serve(...)` path and the CLI dev server.
|
|
953
98
|
|
|
954
|
-
|
|
99
|
+
If you need framework-specific integration, see the docs for:
|
|
955
100
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
message: string;
|
|
961
|
-
details?: Record<string, unknown>;
|
|
962
|
-
};
|
|
963
|
-
}
|
|
964
|
-
```
|
|
101
|
+
- Node handlers
|
|
102
|
+
- Fetch handlers
|
|
103
|
+
- OpenAPI generation
|
|
104
|
+
- auth and middleware
|
|
965
105
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
```json
|
|
969
|
-
{
|
|
970
|
-
"error": {
|
|
971
|
-
"type": "VALIDATION_ERROR",
|
|
972
|
-
"message": "Request validation failed",
|
|
973
|
-
"details": {
|
|
974
|
-
"issues": [
|
|
975
|
-
{
|
|
976
|
-
"code": "invalid_type",
|
|
977
|
-
"expected": "string",
|
|
978
|
-
"received": "number",
|
|
979
|
-
"path": ["startDate"],
|
|
980
|
-
"message": "Expected string, received number"
|
|
981
|
-
}
|
|
982
|
-
]
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
```
|
|
106
|
+
## Docs
|
|
987
107
|
|
|
988
|
-
|
|
108
|
+
- [Core concepts](https://hypequery.com/docs/core-concepts)
|
|
109
|
+
- [Serve runtime reference](https://hypequery.com/docs/reference/runtime)
|
|
110
|
+
- [CLI reference](https://hypequery.com/docs/reference/api/cli)
|
|
989
111
|
|
|
990
112
|
## License
|
|
991
113
|
|
|
992
|
-
Apache-2.0
|
|
114
|
+
Apache-2.0.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/serve",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Declarative HTTP server for exposing hypequery analytics endpoints",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"dist"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"openapi-typescript": "^7.
|
|
20
|
+
"openapi-typescript": "^7.13.0",
|
|
21
21
|
"zod": "^3.23.8",
|
|
22
22
|
"zod-to-json-schema": "^3.23.5"
|
|
23
23
|
},
|