@proseql/rpc 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 +557 -0
  2. package/package.json +2 -2
package/README.md ADDED
@@ -0,0 +1,557 @@
1
+ # @proseql/rpc
2
+
3
+ Type-safe Effect RPC integration for ProseQL databases. Derive typed RPC procedures from your database config with full error inference.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @proseql/rpc
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```ts
14
+ import { Effect, Schema } from "effect"
15
+ import { createEffectDatabase } from "@proseql/core"
16
+ import { makeRpcGroup, makeRpcHandlers } from "@proseql/rpc"
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
+ // 1. Derive RPC group from your database config
33
+ const rpcs = makeRpcGroup(config)
34
+
35
+ // 2. Create handler implementations
36
+ const program = Effect.gen(function* () {
37
+ const handlers = yield* makeRpcHandlers(config, {
38
+ books: [{ id: "1", title: "Dune", author: "Frank Herbert", year: 1965 }],
39
+ })
40
+
41
+ // 3. Use handlers directly or wire to RPC transport
42
+ const book = yield* handlers.books.findById({ id: "1" })
43
+ console.log(book.title) // "Dune"
44
+ })
45
+
46
+ await Effect.runPromise(program)
47
+ ```
48
+
49
+ For the full query and mutation API, see [`@proseql/core`](https://www.npmjs.com/package/@proseql/core).
50
+
51
+ ## RPC Group Derivation
52
+
53
+ `makeRpcGroup` derives typed RPC request schemas from your database config. Each collection gets request classes for all CRUD and batch operations.
54
+
55
+ ```ts
56
+ import { makeRpcGroup } from "@proseql/rpc"
57
+
58
+ const rpcs = makeRpcGroup(config)
59
+ // rpcs.books.FindByIdRequest
60
+ // rpcs.books.QueryRequest
61
+ // rpcs.books.CreateRequest
62
+ // rpcs.books.UpdateRequest
63
+ // rpcs.books.DeleteRequest
64
+ // rpcs.books.AggregateRequest
65
+ // rpcs.books.CreateManyRequest
66
+ // rpcs.books.UpdateManyRequest
67
+ // rpcs.books.DeleteManyRequest
68
+ // rpcs.books.UpsertRequest
69
+ // rpcs.books.UpsertManyRequest
70
+ // rpcs.books.QueryStreamRequest
71
+ ```
72
+
73
+ ### Request Tags
74
+
75
+ Each request class is tagged with the collection and operation name:
76
+
77
+ | Request Class | Tag |
78
+ |--------------|-----|
79
+ | `FindByIdRequest` | `books.findById` |
80
+ | `QueryRequest` | `books.query` |
81
+ | `QueryStreamRequest` | `books.queryStream` |
82
+ | `CreateRequest` | `books.create` |
83
+ | `UpdateRequest` | `books.update` |
84
+ | `DeleteRequest` | `books.delete` |
85
+ | `AggregateRequest` | `books.aggregate` |
86
+ | `CreateManyRequest` | `books.createMany` |
87
+ | `UpdateManyRequest` | `books.updateMany` |
88
+ | `DeleteManyRequest` | `books.deleteMany` |
89
+ | `UpsertRequest` | `books.upsert` |
90
+ | `UpsertManyRequest` | `books.upsertMany` |
91
+
92
+ ### Creating Request Instances
93
+
94
+ ```ts
95
+ const rpcs = makeRpcGroup(config)
96
+
97
+ // FindById
98
+ const findById = new rpcs.books.FindByIdRequest({ id: "1" })
99
+
100
+ // Query with filters and sorting
101
+ const query = new rpcs.books.QueryRequest({
102
+ where: { year: { $gt: 1980 } },
103
+ sort: { year: "desc" },
104
+ limit: 10,
105
+ })
106
+
107
+ // Create
108
+ const create = new rpcs.books.CreateRequest({
109
+ data: { title: "Neuromancer", author: "William Gibson", year: 1984 },
110
+ })
111
+
112
+ // Update
113
+ const update = new rpcs.books.UpdateRequest({
114
+ id: "1",
115
+ updates: { genre: "classic" },
116
+ })
117
+
118
+ // Delete
119
+ const deleteReq = new rpcs.books.DeleteRequest({ id: "1" })
120
+
121
+ // Aggregate
122
+ const aggregate = new rpcs.books.AggregateRequest({
123
+ count: true,
124
+ groupBy: "author",
125
+ })
126
+ ```
127
+
128
+ ## RPC Handlers
129
+
130
+ ### `makeRpcHandlers`
131
+
132
+ Creates handler implementations from a database config. Returns an Effect that produces handlers for all collections.
133
+
134
+ ```ts
135
+ import { Effect } from "effect"
136
+ import { makeRpcHandlers } from "@proseql/rpc"
137
+
138
+ const program = Effect.gen(function* () {
139
+ const handlers = yield* makeRpcHandlers(config, {
140
+ books: [{ id: "1", title: "Dune", author: "Frank Herbert", year: 1965 }],
141
+ })
142
+
143
+ // Use handlers
144
+ const book = yield* handlers.books.findById({ id: "1" })
145
+ const allBooks = yield* handlers.books.query({})
146
+ const created = yield* handlers.books.create({
147
+ data: { title: "New Book", author: "Author", year: 2024 },
148
+ })
149
+ })
150
+ ```
151
+
152
+ ### `makeRpcHandlersFromDatabase`
153
+
154
+ Creates handlers from an existing database instance. Use this when you need file persistence or want to share a database across multiple transports.
155
+
156
+ ```ts
157
+ import { Effect, Layer } from "effect"
158
+ import { createPersistentEffectDatabase, NodeStorageLayer, makeSerializerLayer, jsonCodec } from "@proseql/node"
159
+ import { makeRpcHandlersFromDatabase } from "@proseql/rpc"
160
+
161
+ const config = {
162
+ books: {
163
+ schema: BookSchema,
164
+ file: "./data/books.json",
165
+ relationships: {},
166
+ },
167
+ } as const
168
+
169
+ const program = Effect.gen(function* () {
170
+ // Create persistent database
171
+ const db = yield* createPersistentEffectDatabase(config, { books: [] })
172
+
173
+ // Wire RPC handlers to the persistent database
174
+ const handlers = makeRpcHandlersFromDatabase(config, db)
175
+
176
+ // Mutations through RPC now trigger persistence automatically
177
+ yield* handlers.books.create({
178
+ data: { id: "1", title: "Dune", author: "Frank Herbert", year: 1965 },
179
+ })
180
+
181
+ // Flush to ensure data is written
182
+ yield* Effect.promise(() => db.flush())
183
+ })
184
+
185
+ const PersistenceLayer = Layer.merge(
186
+ NodeStorageLayer,
187
+ makeSerializerLayer([jsonCodec()]),
188
+ )
189
+
190
+ await Effect.runPromise(
191
+ program.pipe(Effect.provide(PersistenceLayer), Effect.scoped),
192
+ )
193
+ ```
194
+
195
+ ### `makeRpcHandlersLayer`
196
+
197
+ Creates an Effect Layer providing a `DatabaseContext` service.
198
+
199
+ ```ts
200
+ import { Effect } from "effect"
201
+ import { makeRpcHandlersLayer, makeDatabaseContextTag } from "@proseql/rpc"
202
+
203
+ const layer = makeRpcHandlersLayer(config, { books: initialBooks })
204
+
205
+ const DatabaseContextTag = makeDatabaseContextTag<typeof config>()
206
+
207
+ const program = Effect.gen(function* () {
208
+ const ctx = yield* DatabaseContextTag
209
+ const book = yield* ctx.db.books.findById("1")
210
+ })
211
+
212
+ await Effect.runPromise(program.pipe(Effect.provide(layer)))
213
+ ```
214
+
215
+ ## Error Schemas
216
+
217
+ All ProseQL errors have RPC-safe schemas for transport. Errors maintain their `_tag` through the RPC layer, enabling `Effect.catchTag` on the client side.
218
+
219
+ ### CRUD Errors
220
+
221
+ | Error | When |
222
+ |-------|------|
223
+ | `NotFoundError` | Entity with ID doesn't exist |
224
+ | `ValidationError` | Schema validation failed |
225
+ | `DuplicateKeyError` | Entity with same ID already exists |
226
+ | `UniqueConstraintError` | Unique field constraint violated |
227
+ | `ForeignKeyError` | Referenced entity doesn't exist |
228
+ | `HookError` | Lifecycle hook rejected the operation |
229
+ | `OperationError` | Operation not allowed (e.g., update on append-only) |
230
+ | `ConcurrencyError` | Concurrent modification conflict |
231
+ | `TransactionError` | Transaction operation failed |
232
+
233
+ ### Query Errors
234
+
235
+ | Error | When |
236
+ |-------|------|
237
+ | `DanglingReferenceError` | Referenced entity no longer exists |
238
+ | `CollectionNotFoundError` | Collection doesn't exist in config |
239
+ | `PopulationError` | Relationship population failed |
240
+
241
+ ### Error Handling
242
+
243
+ Errors flow through the RPC layer with their tags preserved:
244
+
245
+ ```ts
246
+ import { Effect } from "effect"
247
+
248
+ const result = await Effect.runPromise(
249
+ handlers.books.findById({ id: "nonexistent" }).pipe(
250
+ Effect.catchTag("NotFoundError", (error) =>
251
+ Effect.succeed({
252
+ status: "not_found",
253
+ collection: error.collection,
254
+ id: error.id,
255
+ }),
256
+ ),
257
+ Effect.catchTag("ValidationError", (error) =>
258
+ Effect.succeed({
259
+ status: "validation_failed",
260
+ issues: error.issues,
261
+ }),
262
+ ),
263
+ ),
264
+ )
265
+ ```
266
+
267
+ Or use `Effect.catchTags` for multiple error types:
268
+
269
+ ```ts
270
+ const result = await Effect.runPromise(
271
+ handlers.books.findById({ id: "nonexistent" }).pipe(
272
+ Effect.catchTags({
273
+ NotFoundError: (e) => Effect.succeed({ status: "not_found", id: e.id }),
274
+ ValidationError: (e) => Effect.succeed({ status: "invalid", issues: e.issues }),
275
+ }),
276
+ ),
277
+ )
278
+ ```
279
+
280
+ ## Payload Schemas
281
+
282
+ Request payloads are defined using Effect Schema for type-safe serialization.
283
+
284
+ ### Query Payload
285
+
286
+ ```ts
287
+ import { QueryPayloadSchema } from "@proseql/rpc"
288
+
289
+ // Supports:
290
+ // - where: filter conditions
291
+ // - sort: field ordering
292
+ // - select: field selection
293
+ // - populate: relationship population
294
+ // - limit/offset: pagination
295
+ // - cursor: cursor-based pagination
296
+ // - streamingOptions: for queryStream
297
+ ```
298
+
299
+ ### CRUD Payloads
300
+
301
+ ```ts
302
+ import {
303
+ FindByIdPayloadSchema,
304
+ CreatePayloadSchema,
305
+ UpdatePayloadSchema,
306
+ DeletePayloadSchema,
307
+ AggregatePayloadSchema,
308
+ } from "@proseql/rpc"
309
+
310
+ // FindById: { id: string }
311
+ // Create: { data: Record<string, unknown> }
312
+ // Update: { id: string, updates: Record<string, unknown> }
313
+ // Delete: { id: string }
314
+ // Aggregate: { where?, groupBy?, count?, sum?, avg?, min?, max? }
315
+ ```
316
+
317
+ ### Batch Payloads
318
+
319
+ ```ts
320
+ import {
321
+ CreateManyPayloadSchema,
322
+ UpdateManyPayloadSchema,
323
+ DeleteManyPayloadSchema,
324
+ UpsertPayloadSchema,
325
+ UpsertManyPayloadSchema,
326
+ } from "@proseql/rpc"
327
+
328
+ // CreateMany: { data: Array<Record>, options?: { skipDuplicates? } }
329
+ // UpdateMany: { where: Record, updates: Record }
330
+ // DeleteMany: { where: Record, options?: { limit? } }
331
+ // Upsert: { where: Record, create: Record, update: Record }
332
+ // UpsertMany: { data: Array<{ where, create, update }> }
333
+ ```
334
+
335
+ ## Streaming Queries
336
+
337
+ Use `QueryStreamRequest` for incremental result delivery over RPC transport.
338
+
339
+ ```ts
340
+ import { Stream, Chunk } from "effect"
341
+
342
+ const rpcs = makeRpcGroup(config)
343
+ const handlers = await Effect.runPromise(makeRpcHandlers(config, initialData))
344
+
345
+ // queryStream returns a Stream instead of collecting to array
346
+ const stream = handlers.books.queryStream({
347
+ where: { genre: "sci-fi" },
348
+ streamingOptions: { chunkSize: 100 }, // batch items before sending
349
+ })
350
+
351
+ // Collect results
352
+ const results = await Effect.runPromise(
353
+ Stream.runCollect(stream).pipe(Effect.map(Chunk.toReadonlyArray)),
354
+ )
355
+ ```
356
+
357
+ ### Streaming Options
358
+
359
+ | Option | Description |
360
+ |--------|-------------|
361
+ | `chunkSize` | Number of items to batch before sending (default: 1) |
362
+ | `bufferSize` | Client-side buffer size for backpressure (default: 16) |
363
+
364
+ ## Result Schemas
365
+
366
+ Response types for batch and aggregate operations.
367
+
368
+ ```ts
369
+ import {
370
+ AggregateResultSchema,
371
+ GroupedAggregateResultSchema,
372
+ CreateManyResultSchema,
373
+ UpdateManyResultSchema,
374
+ DeleteManyResultSchema,
375
+ UpsertResultSchema,
376
+ UpsertManyResultSchema,
377
+ CursorPageResultSchema,
378
+ } from "@proseql/rpc"
379
+ ```
380
+
381
+ ### Aggregate Results
382
+
383
+ ```ts
384
+ // Scalar aggregation
385
+ type AggregateResult = {
386
+ count?: number
387
+ sum?: Record<string, number>
388
+ avg?: Record<string, number | null>
389
+ min?: Record<string, unknown>
390
+ max?: Record<string, unknown>
391
+ }
392
+
393
+ // Grouped aggregation
394
+ type GroupedAggregateResult = Array<{
395
+ group: Record<string, unknown>
396
+ count?: number
397
+ sum?: Record<string, number>
398
+ avg?: Record<string, number | null>
399
+ min?: Record<string, unknown>
400
+ max?: Record<string, unknown>
401
+ }>
402
+ ```
403
+
404
+ ### Batch Results
405
+
406
+ ```ts
407
+ // CreateMany
408
+ type CreateManyResult = {
409
+ created: Array<Entity>
410
+ skipped?: Array<{ data: unknown; reason: string }>
411
+ }
412
+
413
+ // UpdateMany
414
+ type UpdateManyResult = {
415
+ count: number
416
+ updated: Array<Entity>
417
+ }
418
+
419
+ // DeleteMany
420
+ type DeleteManyResult = {
421
+ count: number
422
+ deleted: Array<Entity>
423
+ }
424
+
425
+ // Upsert
426
+ type UpsertResult = Entity & { __action: "created" | "updated" }
427
+
428
+ // UpsertMany
429
+ type UpsertManyResult = {
430
+ created: Array<Entity>
431
+ updated: Array<Entity>
432
+ unchanged: Array<Entity>
433
+ }
434
+ ```
435
+
436
+ ## Building an RPC Router
437
+
438
+ Use `RpcRouter` from `@effect/rpc` to compose handlers into a router:
439
+
440
+ ```ts
441
+ import { Rpc, RpcRouter } from "@effect/rpc"
442
+ import { makeRpcGroup, makeRpcHandlers } from "@proseql/rpc"
443
+
444
+ const rpcs = makeRpcGroup(config)
445
+
446
+ const program = Effect.gen(function* () {
447
+ const handlers = yield* makeRpcHandlers(config, initialData)
448
+
449
+ // Create RPC handlers using Rpc.effect
450
+ const findBookById = Rpc.effect(rpcs.books.FindByIdRequest, (req) =>
451
+ handlers.books.findById({ id: req.id }),
452
+ )
453
+
454
+ const queryBooks = Rpc.effect(rpcs.books.QueryRequest, (req) =>
455
+ handlers.books.query({
456
+ where: req.where,
457
+ sort: req.sort,
458
+ limit: req.limit,
459
+ offset: req.offset,
460
+ }),
461
+ )
462
+
463
+ const createBook = Rpc.effect(rpcs.books.CreateRequest, (req) =>
464
+ handlers.books.create({ data: req.data }),
465
+ )
466
+
467
+ // Build router
468
+ const router = RpcRouter.make(findBookById, queryBooks, createBook)
469
+ })
470
+ ```
471
+
472
+ ## API Reference
473
+
474
+ ### Exports
475
+
476
+ | Export | Description |
477
+ |--------|-------------|
478
+ | `makeRpcGroup` | Derive RPC request schemas from database config |
479
+ | `makeRpcHandlers` | Create handlers from config + initial data |
480
+ | `makeRpcHandlersFromDatabase` | Create handlers from existing database |
481
+ | `makeRpcHandlersLayer` | Create Layer providing DatabaseContext |
482
+ | `makeRpcHandlersLayerFromDatabase` | Create Layer from existing database |
483
+ | `makeDatabaseContextTag` | Create Context.Tag for database service |
484
+ | `RpcRouter` | Re-exported from @effect/rpc |
485
+
486
+ ### Request Factories
487
+
488
+ | Factory | Description |
489
+ |---------|-------------|
490
+ | `makeFindByIdRequest` | Create FindById request class |
491
+ | `makeQueryRequest` | Create Query request class |
492
+ | `makeQueryStreamRequest` | Create streaming Query request class |
493
+ | `makeCreateRequest` | Create Create request class |
494
+ | `makeUpdateRequest` | Create Update request class |
495
+ | `makeDeleteRequest` | Create Delete request class |
496
+ | `makeAggregateRequest` | Create Aggregate request class |
497
+ | `makeCreateManyRequest` | Create CreateMany request class |
498
+ | `makeUpdateManyRequest` | Create UpdateMany request class |
499
+ | `makeDeleteManyRequest` | Create DeleteMany request class |
500
+ | `makeUpsertRequest` | Create Upsert request class |
501
+ | `makeUpsertManyRequest` | Create UpsertMany request class |
502
+ | `makeCollectionRpcs` | Create all request classes for a collection |
503
+
504
+ ### Error Schemas
505
+
506
+ | Schema | Description |
507
+ |--------|-------------|
508
+ | `NotFoundErrorSchema` | Entity not found |
509
+ | `ValidationErrorSchema` | Schema validation failed |
510
+ | `DuplicateKeyErrorSchema` | Duplicate ID |
511
+ | `UniqueConstraintErrorSchema` | Unique constraint violated |
512
+ | `ForeignKeyErrorSchema` | Foreign key constraint violated |
513
+ | `HookErrorSchema` | Lifecycle hook rejected |
514
+ | `OperationErrorSchema` | Operation not allowed |
515
+ | `ConcurrencyErrorSchema` | Concurrent modification |
516
+ | `TransactionErrorSchema` | Transaction failed |
517
+ | `DanglingReferenceErrorSchema` | Dangling reference |
518
+ | `CollectionNotFoundErrorSchema` | Collection not found |
519
+ | `PopulationErrorSchema` | Population failed |
520
+ | `CrudErrorSchema` | Union of CRUD errors |
521
+ | `QueryErrorSchema` | Union of query errors |
522
+ | `RpcErrorSchema` | Union of all RPC errors |
523
+
524
+ ### Payload Schemas
525
+
526
+ | Schema | Description |
527
+ |--------|-------------|
528
+ | `FindByIdPayloadSchema` | FindById payload |
529
+ | `QueryPayloadSchema` | Query payload |
530
+ | `CreatePayloadSchema` | Create payload |
531
+ | `UpdatePayloadSchema` | Update payload |
532
+ | `DeletePayloadSchema` | Delete payload |
533
+ | `AggregatePayloadSchema` | Aggregate payload |
534
+ | `CreateManyPayloadSchema` | CreateMany payload |
535
+ | `UpdateManyPayloadSchema` | UpdateMany payload |
536
+ | `DeleteManyPayloadSchema` | DeleteMany payload |
537
+ | `UpsertPayloadSchema` | Upsert payload |
538
+ | `UpsertManyPayloadSchema` | UpsertMany payload |
539
+ | `StreamingOptionsSchema` | Streaming options |
540
+
541
+ ### Result Schemas
542
+
543
+ | Schema | Description |
544
+ |--------|-------------|
545
+ | `AggregateResultSchema` | Scalar aggregate result |
546
+ | `GroupedAggregateResultSchema` | Grouped aggregate result |
547
+ | `CreateManyResultSchema` | CreateMany result |
548
+ | `UpdateManyResultSchema` | UpdateMany result |
549
+ | `DeleteManyResultSchema` | DeleteMany result |
550
+ | `UpsertResultSchema` | Upsert result |
551
+ | `UpsertManyResultSchema` | UpsertMany result |
552
+ | `CursorPageResultSchema` | Cursor pagination result |
553
+ | `CursorPageInfoSchema` | Cursor page info |
554
+
555
+ ## License
556
+
557
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proseql/rpc",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Effect RPC integration for ProseQL type-safe in-memory database",
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",