@proseql/rest 0.2.3 → 0.2.4

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.
Files changed (2) hide show
  1. package/README.md +419 -0
  2. package/package.json +2 -2
package/README.md ADDED
@@ -0,0 +1,419 @@
1
+ # @proseql/rest
2
+
3
+ Framework-agnostic REST API handlers for ProseQL databases. Generate HTTP endpoints for CRUD, queries, and aggregations from your database config.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @proseql/rest
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```ts
14
+ import { Effect, Schema } from "effect"
15
+ import { createEffectDatabase } from "@proseql/core"
16
+ import { createRestHandlers } from "@proseql/rest"
17
+
18
+ const BookSchema = Schema.Struct({
19
+ id: Schema.String,
20
+ title: Schema.String,
21
+ author: Schema.String,
22
+ year: Schema.Number,
23
+ })
24
+
25
+ const config = {
26
+ books: {
27
+ schema: BookSchema,
28
+ relationships: {},
29
+ },
30
+ } as const
31
+
32
+ const program = Effect.gen(function* () {
33
+ const db = yield* createEffectDatabase(config, {
34
+ books: [{ id: "1", title: "Dune", author: "Frank Herbert", year: 1965 }],
35
+ })
36
+
37
+ const routes = createRestHandlers(config, db)
38
+
39
+ // routes is an array of { method, path, handler }
40
+ // Adapt to your framework of choice
41
+ })
42
+
43
+ await Effect.runPromise(program)
44
+ ```
45
+
46
+ For the full query and mutation API, see [`@proseql/core`](https://www.npmjs.com/package/@proseql/core).
47
+
48
+ ## Handler Generation
49
+
50
+ `createRestHandlers` generates framework-agnostic route descriptors for all collections in your database config.
51
+
52
+ ```ts
53
+ import { createRestHandlers } from "@proseql/rest"
54
+
55
+ const routes = createRestHandlers(config, db)
56
+ // routes: ReadonlyArray<RouteDescriptor>
57
+ ```
58
+
59
+ Each route descriptor contains:
60
+
61
+ ```ts
62
+ interface RouteDescriptor {
63
+ readonly method: "GET" | "POST" | "PUT" | "DELETE"
64
+ readonly path: string
65
+ readonly handler: RestHandler
66
+ }
67
+ ```
68
+
69
+ ### Generated Routes
70
+
71
+ For each collection, the following routes are generated:
72
+
73
+ | Method | Path | Description |
74
+ |--------|------|-------------|
75
+ | `GET` | `/:collection` | Query with filters, sort, pagination |
76
+ | `GET` | `/:collection/:id` | Find entity by ID |
77
+ | `POST` | `/:collection` | Create entity |
78
+ | `PUT` | `/:collection/:id` | Update entity |
79
+ | `DELETE` | `/:collection/:id` | Delete entity |
80
+ | `POST` | `/:collection/batch` | Create multiple entities |
81
+ | `GET` | `/:collection/aggregate` | Aggregation queries |
82
+
83
+ For a database with `books` and `authors` collections, this generates:
84
+
85
+ ```
86
+ GET /books
87
+ GET /books/:id
88
+ POST /books
89
+ PUT /books/:id
90
+ DELETE /books/:id
91
+ POST /books/batch
92
+ GET /books/aggregate
93
+
94
+ GET /authors
95
+ GET /authors/:id
96
+ POST /authors
97
+ PUT /authors/:id
98
+ DELETE /authors/:id
99
+ POST /authors/batch
100
+ GET /authors/aggregate
101
+ ```
102
+
103
+ ## Request and Response Types
104
+
105
+ Handlers use framework-agnostic request/response types.
106
+
107
+ ```ts
108
+ interface RestRequest {
109
+ readonly params: Record<string, string> // URL path params
110
+ readonly query: Record<string, string | string[]> // Query string params
111
+ readonly body: unknown // Parsed JSON body
112
+ }
113
+
114
+ interface RestResponse {
115
+ readonly status: number
116
+ readonly body: unknown
117
+ readonly headers?: Record<string, string>
118
+ }
119
+
120
+ type RestHandler = (req: RestRequest) => Promise<RestResponse>
121
+ ```
122
+
123
+ ## Query Parameter Parsing
124
+
125
+ Use `parseQueryParams` to convert URL query strings into ProseQL query configurations.
126
+
127
+ ```ts
128
+ import { parseQueryParams } from "@proseql/rest"
129
+
130
+ // Simple equality
131
+ parseQueryParams({ genre: "sci-fi" })
132
+ // → { where: { genre: "sci-fi" } }
133
+
134
+ // Operator syntax
135
+ parseQueryParams({ "year[$gte]": "1970", "year[$lt]": "2000" })
136
+ // → { where: { year: { $gte: 1970, $lt: 2000 } } }
137
+
138
+ // Sorting
139
+ parseQueryParams({ sort: "year:desc,title:asc" })
140
+ // → { sort: { year: "desc", title: "asc" } }
141
+
142
+ // Pagination
143
+ parseQueryParams({ limit: "10", offset: "20" })
144
+ // → { limit: 10, offset: 20 }
145
+
146
+ // Field selection
147
+ parseQueryParams({ select: "title,year" })
148
+ // → { select: ["title", "year"] }
149
+
150
+ // Combined
151
+ parseQueryParams({
152
+ genre: "sci-fi",
153
+ "year[$gte]": "1970",
154
+ sort: "year:desc",
155
+ limit: "10",
156
+ select: "title,year"
157
+ })
158
+ // → {
159
+ // where: { genre: "sci-fi", year: { $gte: 1970 } },
160
+ // sort: { year: "desc" },
161
+ // limit: 10,
162
+ // select: ["title", "year"]
163
+ // }
164
+ ```
165
+
166
+ ### Supported Filter Operators
167
+
168
+ | URL Syntax | ProseQL Operator |
169
+ |------------|------------------|
170
+ | `field=value` | `{ field: value }` (equality) |
171
+ | `field[$eq]=value` | `{ field: { $eq: value } }` |
172
+ | `field[$ne]=value` | `{ field: { $ne: value } }` |
173
+ | `field[$gt]=value` | `{ field: { $gt: value } }` |
174
+ | `field[$gte]=value` | `{ field: { $gte: value } }` |
175
+ | `field[$lt]=value` | `{ field: { $lt: value } }` |
176
+ | `field[$lte]=value` | `{ field: { $lte: value } }` |
177
+ | `field[$in]=a,b,c` | `{ field: { $in: ["a", "b", "c"] } }` |
178
+ | `field[$nin]=a,b` | `{ field: { $nin: ["a", "b"] } }` |
179
+ | `field[$startsWith]=val` | `{ field: { $startsWith: "val" } }` |
180
+ | `field[$endsWith]=val` | `{ field: { $endsWith: "val" } }` |
181
+ | `field[$contains]=val` | `{ field: { $contains: "val" } }` |
182
+ | `field[$search]=term` | `{ field: { $search: "term" } }` |
183
+ | `field[$all]=a,b` | `{ field: { $all: ["a", "b"] } }` |
184
+ | `field[$size]=3` | `{ field: { $size: 3 } }` |
185
+
186
+ Type coercion is applied automatically:
187
+ - Numeric strings become numbers for numeric operators
188
+ - `"true"` and `"false"` become booleans
189
+ - Comma-separated values in `$in`/`$nin`/`$all` become arrays
190
+
191
+ ## Aggregate Parameter Parsing
192
+
193
+ Use `parseAggregateParams` for aggregation endpoint query strings.
194
+
195
+ ```ts
196
+ import { parseAggregateParams } from "@proseql/rest"
197
+
198
+ // Simple count
199
+ parseAggregateParams({ count: "true" })
200
+ // → { count: true }
201
+
202
+ // Count with filter
203
+ parseAggregateParams({ count: "true", genre: "sci-fi" })
204
+ // → { count: true, where: { genre: "sci-fi" } }
205
+
206
+ // Grouped aggregate
207
+ parseAggregateParams({ count: "true", groupBy: "genre" })
208
+ // → { count: true, groupBy: "genre" }
209
+
210
+ // Multiple aggregations
211
+ parseAggregateParams({
212
+ count: "true",
213
+ sum: "pages",
214
+ avg: "rating",
215
+ groupBy: "genre"
216
+ })
217
+ // → { count: true, sum: "pages", avg: "rating", groupBy: "genre" }
218
+ ```
219
+
220
+ ### Aggregate Query Parameters
221
+
222
+ | Parameter | Description |
223
+ |-----------|-------------|
224
+ | `count=true` | Count entities |
225
+ | `sum=field` | Sum a numeric field |
226
+ | `avg=field` | Average a numeric field |
227
+ | `min=field` | Find minimum value |
228
+ | `max=field` | Find maximum value |
229
+ | `groupBy=field` | Group results by field |
230
+ | `sum=a,b` | Sum multiple fields |
231
+ | `groupBy=a,b` | Group by multiple fields |
232
+
233
+ Filter parameters (same syntax as query endpoint) are also supported for filtered aggregation.
234
+
235
+ ## Error Mapping
236
+
237
+ Use `mapErrorToResponse` to convert ProseQL errors to HTTP responses.
238
+
239
+ ```ts
240
+ import { mapErrorToResponse } from "@proseql/rest"
241
+
242
+ try {
243
+ await db.books.findById("nonexistent").runPromise
244
+ } catch (error) {
245
+ const response = mapErrorToResponse(error)
246
+ // response = {
247
+ // status: 404,
248
+ // body: {
249
+ // _tag: "NotFoundError",
250
+ // error: "Not found",
251
+ // details: { collection: "books", id: "nonexistent" }
252
+ // }
253
+ // }
254
+ }
255
+ ```
256
+
257
+ ### Error Status Codes
258
+
259
+ | Error | Status | Description |
260
+ |-------|--------|-------------|
261
+ | `NotFoundError` | 404 | Entity doesn't exist |
262
+ | `ValidationError` | 400 | Invalid input data |
263
+ | `DuplicateKeyError` | 409 | ID already taken |
264
+ | `UniqueConstraintError` | 409 | Unique field collision |
265
+ | `ForeignKeyError` | 422 | Referenced entity doesn't exist |
266
+ | `HookError` | 422 | Lifecycle hook rejected |
267
+ | `OperationError` | 400 | Invalid operation (e.g., update on append-only) |
268
+ | `ConcurrencyError` | 409 | Concurrent modification conflict |
269
+ | `CollectionNotFoundError` | 404 | Collection doesn't exist |
270
+ | `PopulationError` | 422 | Relationship population failed |
271
+ | `DanglingReferenceError` | 422 | Dangling reference |
272
+ | `StorageError` | 500 | Storage adapter error |
273
+ | `SerializationError` | 500 | Codec error |
274
+ | `UnsupportedFormatError` | 400 | Unsupported file format |
275
+ | `TransactionError` | 500 | Transaction error |
276
+ | `MigrationError` | 500 | Migration error |
277
+ | `PluginError` | 500 | Plugin error |
278
+
279
+ ## Relationship Routes
280
+
281
+ Use `createRelationshipRoutes` to generate sub-routes for relationship navigation.
282
+
283
+ ```ts
284
+ import { createRelationshipRoutes } from "@proseql/rest"
285
+
286
+ const config = {
287
+ books: {
288
+ schema: BookSchema,
289
+ relationships: {
290
+ author: { type: "ref" as const, target: "authors" as const, foreignKey: "authorId" },
291
+ },
292
+ },
293
+ authors: {
294
+ schema: AuthorSchema,
295
+ relationships: {
296
+ books: { type: "inverse" as const, target: "books" as const, foreignKey: "authorId" },
297
+ },
298
+ },
299
+ } as const
300
+
301
+ const relationshipRoutes = createRelationshipRoutes(config, db)
302
+ // Generates:
303
+ // GET /books/:id/author — returns the author of a book
304
+ // GET /authors/:id/books — returns all books by an author
305
+ ```
306
+
307
+ ### Route Patterns
308
+
309
+ | Relationship Type | Pattern | Behavior |
310
+ |-------------------|---------|----------|
311
+ | `ref` | `GET /:collection/:id/:relationship` | Returns single related entity |
312
+ | `inverse` | `GET /:collection/:id/:relationship` | Returns array of related entities |
313
+
314
+ Use `extractRelationships` to inspect relationships in your config:
315
+
316
+ ```ts
317
+ import { extractRelationships } from "@proseql/rest"
318
+
319
+ const relationships = extractRelationships(config)
320
+ // [
321
+ // { sourceCollection: "books", relationshipName: "author", relationship: { type: "ref", ... } },
322
+ // { sourceCollection: "authors", relationshipName: "books", relationship: { type: "inverse", ... } }
323
+ // ]
324
+ ```
325
+
326
+ ## Framework Integration
327
+
328
+ ### Express
329
+
330
+ ```ts
331
+ import express from "express"
332
+ import { createRestHandlers, createRelationshipRoutes } from "@proseql/rest"
333
+
334
+ const app = express()
335
+ app.use(express.json())
336
+
337
+ const routes = [
338
+ ...createRestHandlers(config, db),
339
+ ...createRelationshipRoutes(config, db),
340
+ ]
341
+
342
+ for (const { method, path, handler } of routes) {
343
+ app[method.toLowerCase() as "get" | "post" | "put" | "delete"](
344
+ path,
345
+ async (req, res) => {
346
+ const response = await handler({
347
+ params: req.params,
348
+ query: req.query as Record<string, string | string[]>,
349
+ body: req.body,
350
+ })
351
+ res.status(response.status).json(response.body)
352
+ }
353
+ )
354
+ }
355
+
356
+ app.listen(3000)
357
+ ```
358
+
359
+ ### Hono
360
+
361
+ ```ts
362
+ import { Hono } from "hono"
363
+ import { createRestHandlers, createRelationshipRoutes } from "@proseql/rest"
364
+
365
+ const app = new Hono()
366
+
367
+ const routes = [
368
+ ...createRestHandlers(config, db),
369
+ ...createRelationshipRoutes(config, db),
370
+ ]
371
+
372
+ for (const { method, path, handler } of routes) {
373
+ // Convert :param to Hono's :param syntax (same format)
374
+ app[method.toLowerCase() as "get" | "post" | "put" | "delete"](
375
+ path,
376
+ async (c) => {
377
+ const response = await handler({
378
+ params: c.req.param(),
379
+ query: c.req.query() as Record<string, string | string[]>,
380
+ body: method === "GET" ? undefined : await c.req.json(),
381
+ })
382
+ return c.json(response.body, response.status as 200)
383
+ }
384
+ )
385
+ }
386
+
387
+ export default app
388
+ ```
389
+
390
+ ## API Reference
391
+
392
+ ### Exports
393
+
394
+ | Export | Description |
395
+ |--------|-------------|
396
+ | `createRestHandlers` | Generate REST handlers for all collections |
397
+ | `createRelationshipRoutes` | Generate relationship sub-routes |
398
+ | `extractRelationships` | Inspect relationships in config |
399
+ | `parseQueryParams` | Parse URL query params for queries |
400
+ | `parseAggregateParams` | Parse URL query params for aggregation |
401
+ | `mapErrorToResponse` | Map ProseQL errors to HTTP responses |
402
+
403
+ ### Types
404
+
405
+ | Type | Description |
406
+ |------|-------------|
407
+ | `RestHandler` | Handler function signature |
408
+ | `RestRequest` | Framework-agnostic request |
409
+ | `RestResponse` | Framework-agnostic response |
410
+ | `RouteDescriptor` | Route definition (method, path, handler) |
411
+ | `HttpMethod` | `"GET" \| "POST" \| "PUT" \| "DELETE" \| "PATCH"` |
412
+ | `ParsedQueryConfig` | Parsed query configuration |
413
+ | `ParsedAggregateConfig` | Parsed aggregate configuration |
414
+ | `QueryParams` | Input query parameter map |
415
+ | `ErrorResponse` | Error response structure |
416
+
417
+ ## License
418
+
419
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proseql/rest",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "REST API handlers for ProseQL databases, framework-agnostic",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "sideEffects": false,
43
43
  "dependencies": {
44
- "@proseql/core": "^0.2.3"
44
+ "@proseql/core": "^0.2.4"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "effect": "^3.15.0"