@livequery/rest 2.0.91 → 2.0.92
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 +405 -43
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
# @livequery/rest
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@livequery/rest` is a REST + WebSocket transporter for `@livequery/core`.
|
|
4
|
+
|
|
5
|
+
It adapts the `LivequeryTransporter` interface to:
|
|
6
|
+
|
|
7
|
+
- HTTP requests for `query`, `add`, `update`, `delete`, and `trigger`
|
|
8
|
+
- optional WebSocket subscriptions for realtime collection updates
|
|
9
|
+
- request/response hooks for auth, caching, logging, or mocking
|
|
10
|
+
|
|
11
|
+
This package does not implement local cache or collection state management. That stays in `@livequery/core`. This package is only the transport layer between a livequery client and your backend.
|
|
4
12
|
|
|
5
13
|
## Installation
|
|
6
14
|
|
|
7
15
|
```bash
|
|
8
16
|
npm install @livequery/rest
|
|
9
|
-
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```bash
|
|
10
20
|
bun add @livequery/rest
|
|
11
21
|
```
|
|
12
22
|
|
|
13
|
-
##
|
|
23
|
+
## What It Implements
|
|
24
|
+
|
|
25
|
+
`RestTransporter` implements the `LivequeryTransporter` contract from `@livequery/core`:
|
|
26
|
+
|
|
27
|
+
- `query()` returns an `Observable<Partial<LivequeryQueryResult>>`
|
|
28
|
+
- `add()` sends `POST`
|
|
29
|
+
- `update()` sends `PATCH`
|
|
30
|
+
- `delete()` sends `DELETE`
|
|
31
|
+
- `trigger()` sends `POST /<ref>/~<action>`
|
|
32
|
+
|
|
33
|
+
When a WebSocket endpoint is configured, `query()` can also attach a realtime subscription and merge server-pushed `DataChangeEvent`s into the observable stream.
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
14
36
|
|
|
15
|
-
###
|
|
37
|
+
### REST only
|
|
16
38
|
|
|
17
39
|
```ts
|
|
18
40
|
import { RestTransporter } from '@livequery/rest'
|
|
@@ -22,88 +44,424 @@ const transporter = new RestTransporter({
|
|
|
22
44
|
})
|
|
23
45
|
```
|
|
24
46
|
|
|
25
|
-
###
|
|
47
|
+
### REST + realtime
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { RestTransporter } from '@livequery/rest'
|
|
51
|
+
|
|
52
|
+
const transporter = new RestTransporter({
|
|
53
|
+
api: 'https://api.example.com',
|
|
54
|
+
ws: 'wss://api.example.com/ws'
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### With `@livequery/core`
|
|
26
59
|
|
|
27
60
|
```ts
|
|
61
|
+
import { LivequeryCore } from '@livequery/core'
|
|
62
|
+
import { RestTransporter } from '@livequery/rest'
|
|
63
|
+
|
|
28
64
|
const transporter = new RestTransporter({
|
|
29
65
|
api: 'https://api.example.com',
|
|
30
66
|
ws: 'wss://api.example.com/ws'
|
|
31
67
|
})
|
|
68
|
+
|
|
69
|
+
const core = new LivequeryCore({
|
|
70
|
+
storage,
|
|
71
|
+
transporters: {
|
|
72
|
+
rest: transporter
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Constructor
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
type RestTransporterConfig = {
|
|
81
|
+
api: string
|
|
82
|
+
ws?: string
|
|
83
|
+
onRequest?: (
|
|
84
|
+
request: RestTransporterRequest & { ref: string }
|
|
85
|
+
) =>
|
|
86
|
+
| void
|
|
87
|
+
| Partial<RestTransporterRequest & { response?: LivequeryResult<any> }>
|
|
88
|
+
| Promise<void | Partial<RestTransporterRequest & { response?: LivequeryResult<any> }>>
|
|
89
|
+
onResponse?: (
|
|
90
|
+
request: RestTransporterRequest & { ref: string },
|
|
91
|
+
response: LivequeryResult<any>
|
|
92
|
+
) => void | Promise<void>
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Options
|
|
97
|
+
|
|
98
|
+
| Option | Type | Description |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `api` | `string` | Base HTTP URL used for all REST calls. |
|
|
101
|
+
| `ws` | `string` | Optional WebSocket endpoint for realtime sync. |
|
|
102
|
+
| `onRequest` | `function` | Optional interceptor before `fetch()`. Can override request fields or return a fake response. |
|
|
103
|
+
| `onResponse` | `function` | Optional hook called after the response is resolved. |
|
|
104
|
+
|
|
105
|
+
## Request Model
|
|
106
|
+
|
|
107
|
+
Outgoing requests use this shape internally:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
type RestTransporterRequest = {
|
|
111
|
+
url: string
|
|
112
|
+
method: string
|
|
113
|
+
query?: Record<string, any>
|
|
114
|
+
body?: Record<string, any> | string
|
|
115
|
+
headers?: Record<string, string | undefined>
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The transporter builds URLs like this:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
<api>/<ref>
|
|
123
|
+
<api>/<ref>?<query>
|
|
124
|
+
<api>/<ref>/~<action>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Examples:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
GET /users
|
|
131
|
+
GET /users/123
|
|
132
|
+
POST /users
|
|
133
|
+
PATCH /users/123
|
|
134
|
+
DELETE /users/123
|
|
135
|
+
POST /users/~ban
|
|
32
136
|
```
|
|
33
137
|
|
|
34
|
-
|
|
138
|
+
## Hooks
|
|
35
139
|
|
|
36
|
-
|
|
140
|
+
### `onRequest`
|
|
141
|
+
|
|
142
|
+
Use `onRequest` to:
|
|
143
|
+
|
|
144
|
+
- inject auth headers
|
|
145
|
+
- override request body or URL
|
|
146
|
+
- short-circuit requests from cache
|
|
147
|
+
- mock server responses in tests
|
|
37
148
|
|
|
38
149
|
```ts
|
|
39
150
|
const transporter = new RestTransporter({
|
|
40
151
|
api: 'https://api.example.com',
|
|
41
152
|
ws: 'wss://api.example.com/ws',
|
|
42
|
-
|
|
43
|
-
onRequest: async ({ url, method, headers, ref }) => {
|
|
153
|
+
onRequest: async ({ headers }) => {
|
|
44
154
|
const token = await getAccessToken()
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
headers: {
|
|
158
|
+
...headers,
|
|
159
|
+
Authorization: `Bearer ${token}`
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
To skip the network completely, return a `response`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const transporter = new RestTransporter({
|
|
170
|
+
api: 'https://api.example.com',
|
|
171
|
+
onRequest: ({ ref }) => {
|
|
172
|
+
const cached = cache.get(ref)
|
|
173
|
+
if (!cached) return
|
|
174
|
+
|
|
45
175
|
return {
|
|
46
|
-
|
|
176
|
+
response: {
|
|
177
|
+
data: cached
|
|
178
|
+
}
|
|
47
179
|
}
|
|
48
|
-
}
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### `onResponse`
|
|
185
|
+
|
|
186
|
+
Use `onResponse` for logging, metrics, or centralized error inspection:
|
|
49
187
|
|
|
188
|
+
```ts
|
|
189
|
+
const transporter = new RestTransporter({
|
|
190
|
+
api: 'https://api.example.com',
|
|
50
191
|
onResponse: async (request, response) => {
|
|
51
|
-
if (response.error)
|
|
192
|
+
if (response.error) {
|
|
193
|
+
console.error('Livequery REST error', {
|
|
194
|
+
url: request.url,
|
|
195
|
+
method: request.method,
|
|
196
|
+
error: response.error
|
|
197
|
+
})
|
|
198
|
+
}
|
|
52
199
|
}
|
|
53
200
|
})
|
|
54
201
|
```
|
|
55
202
|
|
|
56
|
-
|
|
203
|
+
## REST Response Contract
|
|
204
|
+
|
|
205
|
+
The transporter expects your backend to return a `LivequeryResult<T>` envelope:
|
|
57
206
|
|
|
58
207
|
```ts
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
208
|
+
type LivequeryResult<T> = {
|
|
209
|
+
data: T
|
|
210
|
+
error?: {
|
|
211
|
+
code: string
|
|
212
|
+
message: string
|
|
213
|
+
}
|
|
62
214
|
}
|
|
63
215
|
```
|
|
64
216
|
|
|
65
|
-
|
|
217
|
+
### Collection query response
|
|
66
218
|
|
|
67
|
-
|
|
219
|
+
For collection reads, `data` should look like:
|
|
68
220
|
|
|
69
|
-
|
|
221
|
+
```ts
|
|
222
|
+
type LivequeryCollectionResponse<T> = {
|
|
223
|
+
summary?: Record<string, any>
|
|
224
|
+
items: T[]
|
|
225
|
+
subscription_token?: string
|
|
226
|
+
count?: {
|
|
227
|
+
prev: number
|
|
228
|
+
next: number
|
|
229
|
+
total: number
|
|
230
|
+
current: number
|
|
231
|
+
}
|
|
232
|
+
has?: {
|
|
233
|
+
prev: boolean
|
|
234
|
+
next: boolean
|
|
235
|
+
}
|
|
236
|
+
cursor?: {
|
|
237
|
+
first: string
|
|
238
|
+
last: string
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
```
|
|
70
242
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
243
|
+
Example:
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
{
|
|
247
|
+
"data": {
|
|
248
|
+
"items": [
|
|
249
|
+
{ "id": "u1", "name": "Ada" },
|
|
250
|
+
{ "id": "u2", "name": "Linus" }
|
|
251
|
+
],
|
|
252
|
+
"summary": {
|
|
253
|
+
"active": 2
|
|
254
|
+
},
|
|
255
|
+
"count": {
|
|
256
|
+
"prev": 0,
|
|
257
|
+
"next": 20,
|
|
258
|
+
"current": 2,
|
|
259
|
+
"total": 22
|
|
260
|
+
},
|
|
261
|
+
"has": {
|
|
262
|
+
"prev": false,
|
|
263
|
+
"next": true
|
|
264
|
+
},
|
|
265
|
+
"cursor": {
|
|
266
|
+
"first": "cursor-1",
|
|
267
|
+
"last": "cursor-2"
|
|
268
|
+
},
|
|
269
|
+
"subscription_token": "rt_abc123"
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Single document response
|
|
77
275
|
|
|
78
|
-
|
|
276
|
+
For document reads, `data` should contain `item`:
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"data": {
|
|
281
|
+
"item": {
|
|
282
|
+
"id": "u1",
|
|
283
|
+
"name": "Ada"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
79
288
|
|
|
80
|
-
|
|
289
|
+
### Create response
|
|
81
290
|
|
|
82
|
-
|
|
83
|
-
|---|---|
|
|
84
|
-
| `query(ref, filters)` | Query a collection or document. Returns an Observable. |
|
|
85
|
-
| `add(ref, data)` | Create a new document (`POST`) |
|
|
86
|
-
| `update(ref, id, data)` | Update a document (`PATCH`) |
|
|
87
|
-
| `delete(ref, id)` | Delete a document (`DELETE`) |
|
|
88
|
-
| `trigger({ ref, action, payload })` | Trigger a custom action (`POST /ref/~action`) |
|
|
291
|
+
`add()` accepts either of these backend shapes:
|
|
89
292
|
|
|
90
|
-
|
|
293
|
+
```json
|
|
294
|
+
{
|
|
295
|
+
"data": {
|
|
296
|
+
"item": {
|
|
297
|
+
"id": "u1",
|
|
298
|
+
"name": "Ada"
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
```json
|
|
305
|
+
{
|
|
306
|
+
"data": {
|
|
307
|
+
"id": "u1",
|
|
308
|
+
"name": "Ada"
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Query Output Shape
|
|
314
|
+
|
|
315
|
+
`query()` converts server data into the shape expected by `@livequery/core`:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
type LivequeryQueryResult = {
|
|
319
|
+
changes: DataChangeEvent[]
|
|
320
|
+
summary: Record<string, any>
|
|
321
|
+
paging: {
|
|
322
|
+
total: number
|
|
323
|
+
current: number
|
|
324
|
+
next?: { count: number; cursor: string }
|
|
325
|
+
prev?: { count: number; cursor: string }
|
|
326
|
+
}
|
|
327
|
+
source: 'query' | 'realtime' | 'action'
|
|
328
|
+
error: { code: string; message: string }
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Collection responses are converted to `added` events for each returned item. Document responses are converted to a single `added` event for the returned document.
|
|
333
|
+
|
|
334
|
+
## Realtime
|
|
335
|
+
|
|
336
|
+
If `ws` is provided, the transporter creates a `Socket` instance and adds these headers to REST calls:
|
|
337
|
+
|
|
338
|
+
- `socket_id`
|
|
339
|
+
- `x-lcid`
|
|
340
|
+
- `x-lgid`
|
|
341
|
+
|
|
342
|
+
This lets your backend bind the HTTP query to the active realtime session.
|
|
343
|
+
|
|
344
|
+
### Realtime flow
|
|
345
|
+
|
|
346
|
+
1. A collection query returns `subscription_token`.
|
|
347
|
+
2. The transporter forwards that token to the socket with a `subscribe` event.
|
|
348
|
+
3. The socket listens for server `sync` messages.
|
|
349
|
+
4. Incoming changes are emitted as `DataChangeEvent`s with `source: 'realtime'`.
|
|
350
|
+
|
|
351
|
+
Realtime listening is only attached for standard collection queries. It is skipped when:
|
|
352
|
+
|
|
353
|
+
- no `ws` endpoint is configured
|
|
354
|
+
- the request is a cursor query using `:after`
|
|
355
|
+
- the request is a cursor query using `:before`
|
|
356
|
+
- the request is an around query using `:around`
|
|
357
|
+
|
|
358
|
+
### WebSocket protocol expected by `Socket`
|
|
359
|
+
|
|
360
|
+
When the socket opens, it sends:
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{ "event": "start", "data": { "id": "<client_id>" } }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
It also sends a heartbeat every 60 seconds:
|
|
367
|
+
|
|
368
|
+
```json
|
|
369
|
+
{ "event": "ping" }
|
|
370
|
+
```
|
|
91
371
|
|
|
92
|
-
|
|
372
|
+
To subscribe a collection query, it sends:
|
|
373
|
+
|
|
374
|
+
```json
|
|
375
|
+
{ "event": "subscribe", "data": { "realtime_token": "<token>" } }
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The server should respond with a hello message containing the gateway id:
|
|
379
|
+
|
|
380
|
+
```json
|
|
381
|
+
{ "event": "hello", "gid": "gateway-1" }
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Realtime sync messages should look like:
|
|
385
|
+
|
|
386
|
+
```json
|
|
387
|
+
{
|
|
388
|
+
"event": "sync",
|
|
389
|
+
"data": {
|
|
390
|
+
"changes": [
|
|
391
|
+
{
|
|
392
|
+
"ref": "users",
|
|
393
|
+
"id": "u1",
|
|
394
|
+
"type": "modified",
|
|
395
|
+
"data": { "name": "Ada Lovelace" }
|
|
396
|
+
}
|
|
397
|
+
]
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Each sync change is routed to `listen(ref)` subscribers and normalized to:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
type DataChangeEvent = {
|
|
406
|
+
collection_ref: string
|
|
407
|
+
id: string
|
|
408
|
+
type: 'added' | 'removed' | 'modified'
|
|
409
|
+
data?: Record<string, any>
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Public API
|
|
414
|
+
|
|
415
|
+
### `RestTransporter`
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
import { RestTransporter } from '@livequery/rest'
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Methods:
|
|
422
|
+
|
|
423
|
+
- `query({ ref, filters })`
|
|
424
|
+
- `add(ref, data)`
|
|
425
|
+
- `update(collectionRef, id, data)`
|
|
426
|
+
- `delete(collectionRef, id)`
|
|
427
|
+
- `trigger({ ref, action, payload })`
|
|
428
|
+
|
|
429
|
+
### `Socket`
|
|
93
430
|
|
|
94
431
|
```ts
|
|
95
432
|
import { Socket } from '@livequery/rest'
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
The socket class is exported for low-level integrations and debugging.
|
|
436
|
+
|
|
437
|
+
Example:
|
|
96
438
|
|
|
439
|
+
```ts
|
|
97
440
|
const socket = new Socket('wss://api.example.com/ws')
|
|
98
|
-
|
|
99
|
-
socket.
|
|
441
|
+
|
|
442
|
+
socket.listen('users').subscribe(change => {
|
|
443
|
+
console.log(change)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
socket.stop()
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## Error Handling
|
|
450
|
+
|
|
451
|
+
If `fetch()` throws or the backend returns an error envelope, the transporter surfaces it as:
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
{
|
|
455
|
+
error: {
|
|
456
|
+
code: string,
|
|
457
|
+
message: string
|
|
458
|
+
}
|
|
459
|
+
}
|
|
100
460
|
```
|
|
101
461
|
|
|
102
|
-
|
|
462
|
+
For `query()`, errors are emitted as an observable result with `source: 'query'`.
|
|
103
463
|
|
|
104
|
-
|
|
105
|
-
2. The socket subscribes to the token and listens for `sync` events from the server.
|
|
106
|
-
3. Incoming changes are emitted as `DataChangeEvent`s with `source: "realtime"`.
|
|
464
|
+
For `add()`, `update()`, `delete()`, and `trigger()`, errors are thrown as rejected promises.
|
|
107
465
|
|
|
108
466
|
## Build
|
|
109
467
|
|
|
@@ -111,7 +469,11 @@ socket.stop() // close connection
|
|
|
111
469
|
bun run build
|
|
112
470
|
```
|
|
113
471
|
|
|
114
|
-
|
|
472
|
+
Build steps:
|
|
473
|
+
|
|
474
|
+
- clean `dist/`
|
|
475
|
+
- bundle ESM output with Bun
|
|
476
|
+
- generate `.d.ts` files with TypeScript
|
|
115
477
|
|
|
116
478
|
## License
|
|
117
479
|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"repository": {
|
|
4
4
|
"url": "https://github.com/livequery/rest"
|
|
5
5
|
},
|
|
6
|
-
"version": "2.0.
|
|
6
|
+
"version": "2.0.92",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"description": "",
|
|
9
9
|
"main": "build/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"uuid": "^13.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@livequery/core": "2.0.
|
|
26
|
+
"@livequery/core": "2.0.92",
|
|
27
27
|
"typescript": "5.6.3"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {},
|