@livequery/core 2.0.104 → 2.0.105

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 (95) hide show
  1. package/README.md +569 -314
  2. package/build/src/ApiGatewayHandler.d.ts +56 -0
  3. package/build/src/ApiGatewayHandler.js +210 -0
  4. package/build/src/ApiGatewayHandler.js.map +1 -0
  5. package/build/src/ApiGatewayLinker.d.ts +61 -0
  6. package/build/src/ApiGatewayLinker.js +229 -0
  7. package/build/src/ApiGatewayLinker.js.map +1 -0
  8. package/build/src/ApiServiceLinker.d.ts +18 -0
  9. package/build/src/ApiServiceLinker.js +61 -0
  10. package/build/src/ApiServiceLinker.js.map +1 -0
  11. package/build/src/LivequeryContext.d.ts +43 -0
  12. package/build/src/LivequeryContext.js +2 -0
  13. package/build/src/LivequeryContext.js.map +1 -0
  14. package/build/src/LivequeryDatasource.d.ts +8 -0
  15. package/build/src/LivequeryDatasource.js +2 -0
  16. package/build/src/LivequeryDatasource.js.map +1 -0
  17. package/build/src/LivequeryRequestParser.d.ts +13 -0
  18. package/build/src/LivequeryRequestParser.js +34 -0
  19. package/build/src/LivequeryRequestParser.js.map +1 -0
  20. package/build/src/UdpDiscovery.d.ts +26 -0
  21. package/build/src/UdpDiscovery.js +238 -0
  22. package/build/src/UdpDiscovery.js.map +1 -0
  23. package/build/src/WebsocketGateway.d.ts +36 -0
  24. package/build/src/WebsocketGateway.js +353 -0
  25. package/build/src/WebsocketGateway.js.map +1 -0
  26. package/build/src/const.d.ts +8 -0
  27. package/build/src/const.js +10 -0
  28. package/build/src/const.js.map +1 -0
  29. package/build/src/headers.d.ts +9 -0
  30. package/build/src/headers.js +32 -0
  31. package/build/src/headers.js.map +1 -0
  32. package/build/src/helpers/PathHelper.d.ts +11 -0
  33. package/build/src/helpers/PathHelper.js +41 -0
  34. package/build/src/helpers/PathHelper.js.map +1 -0
  35. package/build/src/helpers/hidePrivateFields.d.ts +3 -0
  36. package/build/src/helpers/hidePrivateFields.js +29 -0
  37. package/build/src/helpers/hidePrivateFields.js.map +1 -0
  38. package/build/src/helpers/nodeRequestToWebRequest.d.ts +7 -0
  39. package/build/src/helpers/nodeRequestToWebRequest.js +17 -0
  40. package/build/src/helpers/nodeRequestToWebRequest.js.map +1 -0
  41. package/build/src/helpers/writeWebResponse.d.ts +2 -0
  42. package/build/src/helpers/writeWebResponse.js +9 -0
  43. package/build/src/helpers/writeWebResponse.js.map +1 -0
  44. package/build/src/index.d.ts +9 -0
  45. package/build/src/index.js +10 -0
  46. package/build/src/index.js.map +1 -0
  47. package/build/src/parseLivequeryRequest.d.ts +10 -0
  48. package/build/src/parseLivequeryRequest.js +17 -0
  49. package/build/src/parseLivequeryRequest.js.map +1 -0
  50. package/build/tsconfig.tsbuildinfo +1 -0
  51. package/package.json +39 -103
  52. package/dist/LivequeryCollection.d.ts +0 -92
  53. package/dist/LivequeryCollection.d.ts.map +0 -1
  54. package/dist/LivequeryCollection.js +0 -231
  55. package/dist/LivequeryCollection.js.map +0 -1
  56. package/dist/LivequeryCore.d.ts +0 -67
  57. package/dist/LivequeryCore.d.ts.map +0 -1
  58. package/dist/LivequeryCore.js +0 -343
  59. package/dist/LivequeryCore.js.map +0 -1
  60. package/dist/LivequeryDocument.d.ts +0 -23
  61. package/dist/LivequeryDocument.d.ts.map +0 -1
  62. package/dist/LivequeryDocument.js +0 -22
  63. package/dist/LivequeryDocument.js.map +0 -1
  64. package/dist/LivequeryMemoryStorage.d.ts +0 -14
  65. package/dist/LivequeryMemoryStorage.d.ts.map +0 -1
  66. package/dist/LivequeryMemoryStorage.js +0 -89
  67. package/dist/LivequeryMemoryStorage.js.map +0 -1
  68. package/dist/LivequeryStorge.d.ts +0 -12
  69. package/dist/LivequeryStorge.d.ts.map +0 -1
  70. package/dist/LivequeryStorge.js +0 -2
  71. package/dist/LivequeryStorge.js.map +0 -1
  72. package/dist/LivequeryTransporter.d.ts +0 -22
  73. package/dist/LivequeryTransporter.d.ts.map +0 -1
  74. package/dist/LivequeryTransporter.js +0 -2
  75. package/dist/LivequeryTransporter.js.map +0 -1
  76. package/dist/helpers/filterDocs.d.ts +0 -5
  77. package/dist/helpers/filterDocs.d.ts.map +0 -1
  78. package/dist/helpers/filterDocs.js +0 -80
  79. package/dist/helpers/filterDocs.js.map +0 -1
  80. package/dist/helpers/tryCatch.d.ts +0 -2
  81. package/dist/helpers/tryCatch.d.ts.map +0 -1
  82. package/dist/helpers/tryCatch.js +0 -10
  83. package/dist/helpers/tryCatch.js.map +0 -1
  84. package/dist/helpers/whenCompleted.d.ts +0 -3
  85. package/dist/helpers/whenCompleted.d.ts.map +0 -1
  86. package/dist/helpers/whenCompleted.js +0 -5
  87. package/dist/helpers/whenCompleted.js.map +0 -1
  88. package/dist/index.d.ts +0 -9
  89. package/dist/index.d.ts.map +0 -1
  90. package/dist/index.js +0 -9
  91. package/dist/index.js.map +0 -1
  92. package/dist/types.d.ts +0 -70
  93. package/dist/types.d.ts.map +0 -1
  94. package/dist/types.js +0 -2
  95. package/dist/types.js.map +0 -1
package/README.md CHANGED
@@ -1,477 +1,732 @@
1
1
  # @livequery/core
2
2
 
3
- Reactive local-first data primitives for browser clients.
3
+ `@livequery/core` is the framework-agnostic runtime layer for Livequery.
4
4
 
5
- This repository is the core library package, not an application. Changes here should preserve reusable public API behavior unless a task explicitly targets a breaking change.
5
+ It provides the shared primitives used by HTTP adapters, data-source adapters, API gateway processes, service processes, and realtime synchronization layers. The package does not run database queries by itself and does not require a specific HTTP framework.
6
6
 
7
- This package provides the core building blocks behind Livequery collections: reactive document state, pluggable local storage, pluggable transporters, optimistic mutations, and typed inline filters.
7
+ ## What This Project Does
8
8
 
9
- ## AI Agent Guidance
9
+ Livequery treats an HTTP request path as a structured data reference.
10
10
 
11
- Repository-specific agent guidance lives in `AGENTS.md` and `copilot-instructions.md`.
11
+ Examples:
12
12
 
13
- - `AGENTS.md` is the implementation-focused guide for coding agents modifying this package.
14
- - `copilot-instructions.md` provides repo-level instructions for Copilot when generating or reviewing code in this workspace.
15
- - Both documents assume this repo is a library package, so agent changes should avoid app-specific scaffolding and should preserve public API compatibility by default.
16
- - Agents generating consumer code should also follow the usage patterns documented below: create a shared `LivequeryCore`, initialize collections before querying, and subscribe to collection state instead of relying on one-time `.value` reads.
13
+ - `/livequery/posts` points to the `posts` collection.
14
+ - `/livequery/posts/p1` points to the `posts/p1` document.
15
+ - `/livequery/users/u1/posts` points to the nested `users/u1/posts` collection.
16
+ - `/livequery/users/u1/posts/p1` points to the nested `users/u1/posts/p1` document.
17
+
18
+ This package handles the core infrastructure around that model:
19
+
20
+ - Parse raw framework requests into normalized `LivequeryRequest` objects.
21
+ - Pass request state through a shared `LivequeryContext`.
22
+ - Define a handler interface for parser, middleware, datasource, and realtime handlers.
23
+ - Discover gateway and service nodes over UDP.
24
+ - Route HTTP requests through an API gateway to online service nodes.
25
+ - Publish service metadata from service nodes.
26
+ - Manage realtime WebSocket subscriptions and update forwarding.
27
+ - Sanitize response objects by hiding private fields.
17
28
 
18
29
  ## Installation
19
30
 
20
- ```bash
21
- bun add @livequery/core rxjs
31
+ ```sh
32
+ bun add @livequery/core
22
33
  ```
23
34
 
24
- For React projects:
35
+ For local development in this repository:
25
36
 
26
- ```bash
27
- bun add @livequery/core @livequery/react rxjs
37
+ ```sh
38
+ bun install
39
+ bun run build
40
+ bun test tests/
28
41
  ```
29
42
 
30
- The package is published as ESM and targets browser usage.
43
+ Type-check tests:
31
44
 
32
- ## Public Exports
45
+ ```sh
46
+ bunx tsc -p tests/tsconfig.json --noEmit
47
+ ```
33
48
 
34
- The package re-exports:
49
+ ## Public Entry Point
35
50
 
36
51
  ```ts
37
- export * from "./LivequeryCollection"
38
- export * from "./LivequeryCore"
39
- export * from "./LivequeryMemoryStorage"
40
- export * from "./LivequeryStorge"
41
- export * from "./LivequeryTransporter"
42
- export * from "./types"
43
- export * from "./helpers/filterDocs"
44
- export * from "./LivequeryDocument"
52
+ import {
53
+ ApiGatewayHandler,
54
+ ApiServiceLinker,
55
+ LivequeryRequestParser,
56
+ UdpDiscovery,
57
+ WebsocketGateway,
58
+ hidePrivateFields,
59
+ } from '@livequery/core'
45
60
  ```
46
61
 
47
62
  ## Core Types
48
63
 
49
- ### `Doc`
64
+ ### `CollectionResponse<T>`
50
65
 
51
- Every record must have an `id`.
66
+ Response shape for collection queries.
52
67
 
53
68
  ```ts
54
- type Doc<T = {}> = T & {
55
- id: string
69
+ type CollectionResponse<T> = {
70
+ items: T[]
71
+ paging: {
72
+ current: number
73
+ total: number
74
+ }
75
+ cursor: {
76
+ current: string
77
+ next: string
78
+ prev: string
79
+ }
56
80
  }
57
81
  ```
58
82
 
59
- ### `DocState`
83
+ Use this when a handler returns a list of items with paging and cursor metadata.
60
84
 
61
- Collections and documents expose `DocState<T>`, which adds optimistic mutation metadata.
85
+ ### `DocumentResponse<T>`
86
+
87
+ Response shape for document queries.
62
88
 
63
89
  ```ts
64
- type DocState<T extends Doc> = T & {
65
- _deleting?: boolean
66
- _deleting_error?: { code: string; message: string; transporter_id: string }
67
- _updating?: boolean
68
- _updating_error?: { code: string; message: string; transporter_id: string }
69
- _adding?: boolean
70
- _adding_error?: { code: string; message: string; transporter_id: string }
71
- _remotes?: Record<string, string | number>
72
- _prev?: Partial<T>
90
+ type DocumentResponse<T> = {
91
+ item: T
73
92
  }
74
93
  ```
75
94
 
76
- ### `DataChangeEvent`
95
+ Use this when a handler returns one document.
96
+
97
+ ### `RawRequest`
77
98
 
78
- Transporters stream incremental change events back into the core.
99
+ The request shape expected from a framework adapter before Livequery parsing.
79
100
 
80
101
  ```ts
81
- type DataChangeEvent = {
102
+ type RawRequest = {
103
+ path: string
104
+ ref: string
105
+ method: string
106
+ body?: any
107
+ params: Record<string, any>
108
+ query: Record<string, any>
109
+ headers: Map<string, string>
110
+ }
111
+ ```
112
+
113
+ - `path` is the actual request path, for example `/livequery/posts/p1`.
114
+ - `ref` is the route pattern, for example `/livequery/posts/:id`.
115
+ - `params` contains framework route params.
116
+ - `query` contains parsed query params.
117
+ - `headers` is a string map used by handlers such as `WebsocketGateway`.
118
+
119
+ ### `LivequeryRequest<I>`
120
+
121
+ The normalized request shape created by `LivequeryRequestParser`.
122
+
123
+ ```ts
124
+ type LivequeryRequest<I> = {
125
+ keys: Record<string, any>
126
+ path: string
127
+ document_id?: string
82
128
  collection_ref: string
83
- id: string
84
- type: "added" | "removed" | "modified"
85
- data?: Record<string, any>
129
+ schema_collection_ref: string
130
+ ref: string
131
+ method: string
132
+ body: I
133
+ query: Record<string, any>
86
134
  }
87
135
  ```
88
136
 
89
- ## Architecture
137
+ ### `LivequeryContext<T>`
138
+
139
+ The shared context passed through Livequery handlers.
90
140
 
91
- ```text
92
- LivequeryCollection / LivequeryDocument
93
- |
94
- v
95
- LivequeryCore
96
- / \
97
- v v
98
- LivequeryStorge LivequeryTransporter(s)
141
+ ```ts
142
+ type LivequeryContext<T = {}> = {
143
+ request: RawRequest
144
+ livequery?: LivequeryRequest<any>
145
+ response?: T
146
+ }
99
147
  ```
100
148
 
101
- - `LivequeryCollection` owns the reactive state for one collection ref or one document ref.
102
- - `LivequeryDocument` wraps an item as a `BehaviorSubject` with convenience mutation methods.
103
- - `LivequeryCore` coordinates storage, transporters, optimistic writes, and fan-out to watchers.
104
- - `LivequeryStorge` is the local persistence contract.
105
- - `LivequeryTransporter` is the remote sync contract.
149
+ ### `LivequeryHandler<O>`
106
150
 
107
- ## Quick Start
151
+ The common handler contract.
108
152
 
109
153
  ```ts
110
- import {
111
- LivequeryCollection,
112
- LivequeryCore,
113
- LivequeryMemoryStorage,
114
- type Doc,
115
- type LivequeryQueryResult,
116
- type LivequeryTransporter,
117
- } from "@livequery/core"
118
- import { of } from "rxjs"
119
-
120
- type Todo = Doc<{
121
- title: string
122
- done: boolean
123
- createdAt: number
124
- }>
125
-
126
- const storage = new LivequeryMemoryStorage()
127
-
128
- const transporter: LivequeryTransporter = {
129
- query(_query) {
130
- return of<Partial<LivequeryQueryResult>>({
131
- changes: [],
132
- summary: {},
133
- paging: { total: 0, current: 0 },
134
- metadata: {},
135
- source: "query",
136
- })
137
- },
138
- async add(_ref, doc) {
139
- return { id: crypto.randomUUID(), ...doc } as Todo
140
- },
141
- async update(_ref, id, doc) {
142
- return { id, ...doc } as Todo
143
- },
144
- async delete(_ref, id) {
145
- return { id } as Todo
146
- },
147
- async trigger(_action) {
148
- return { ok: true }
149
- },
154
+ type LivequeryHandler<O = {}> = {
155
+ handle(ctx: LivequeryContext<O>): any
150
156
  }
157
+ ```
151
158
 
152
- const core = new LivequeryCore({
153
- storage,
154
- transporters: {
155
- primary: transporter,
156
- },
157
- })
159
+ Use this interface for parsers, middleware, datasource adapters, auth handlers, and realtime handlers.
158
160
 
159
- const todos = new LivequeryCollection<Todo>(core, {
160
- filters: { "createdAt:sort": "desc" },
161
- mode: "server-first",
162
- })
161
+ ## `LivequeryRequestParser`
163
162
 
164
- todos.initialize("todos")
163
+ `LivequeryRequestParser` is the first handler in a typical request pipeline. It reads `ctx.request` and writes `ctx.livequery`.
165
164
 
166
- todos.items.subscribe((items) => {
167
- console.log(items.map((doc) => doc.value))
168
- })
165
+ ### When To Use It
166
+
167
+ Use it inside HTTP framework adapters before invoking datasource or business logic handlers. Downstream handlers should rely on `ctx.livequery` instead of reparsing paths.
169
168
 
170
- await todos.query({ ":limit": 20, "createdAt:sort": "desc" })
171
- await todos.add({ title: "Buy milk", done: false, createdAt: Date.now() })
172
- await todos.update("todo-1", { done: true })
173
- await todos.delete("todo-1")
169
+ ### Constructor
170
+
171
+ ```ts
172
+ new LivequeryRequestParser()
174
173
  ```
175
174
 
176
- ## `LivequeryCore`
175
+ ### `handle(ctx)`
176
+
177
+ Parses a raw request into:
178
+
179
+ - `ref`: actual data reference, for example `posts/p1`.
180
+ - `collection_ref`: collection reference, for example `posts`.
181
+ - `schema_collection_ref`: route-pattern-based collection reference, for example `users/uid/posts`.
182
+ - `document_id`: document id when the request targets a document.
183
+ - `method`: uppercased request method.
184
+ - `keys`, `body`, `query`, and original `path`.
185
+
186
+ It also removes:
177
187
 
178
- Create one core with a storage adapter and one or more transporters:
188
+ - The route prefix before the data ref, such as `livequery`.
189
+ - Query strings before parsing path segments.
190
+ - Realtime suffixes after `~` in the pathname.
191
+
192
+ Query values are preserved in `query`. A `~` inside the query string is not treated as a realtime suffix.
193
+
194
+ ### Example
179
195
 
180
196
  ```ts
181
- const core = new LivequeryCore({
182
- storage,
183
- transporters: {
184
- primary: transporter,
197
+ import { LivequeryRequestParser, type LivequeryContext } from '@livequery/core'
198
+
199
+ const ctx: LivequeryContext = {
200
+ request: {
201
+ path: '/livequery/posts/p1',
202
+ ref: '/livequery/posts/:id',
203
+ method: 'get',
204
+ params: { id: 'p1' },
205
+ query: {},
206
+ headers: new Map(),
185
207
  },
186
- })
187
- ```
208
+ }
188
209
 
189
- ### Mutation flow
210
+ new LivequeryRequestParser().handle(ctx)
211
+
212
+ console.log(ctx.livequery)
213
+ // {
214
+ // ref: 'posts/p1',
215
+ // collection_ref: 'posts',
216
+ // schema_collection_ref: 'posts',
217
+ // document_id: 'p1',
218
+ // keys: { id: 'p1' },
219
+ // method: 'GET',
220
+ // ...
221
+ // }
222
+ ```
190
223
 
191
- For `add`, `update`, and `delete`, the core:
224
+ ## `LivequeryDatasource`
192
225
 
193
- 1. writes to local storage first
194
- 2. broadcasts the optimistic change to active watchers
195
- 3. pushes the mutation to each transporter
196
- 4. clears optimistic flags or stores mutation errors after the remote call finishes
226
+ `LivequeryDatasource<RouteConfig>` is a type for datasource adapters.
197
227
 
198
- Documents created locally receive ids prefixed with `local:` until a transporter returns a persisted id.
228
+ ```ts
229
+ type LivequeryDatasourceInitConfig<Config> = Config & {
230
+ method: string
231
+ path: string
232
+ }
199
233
 
200
- ### Query modes
234
+ type LivequeryDatasource<RouteConfig> = LivequeryHandler & {
235
+ init(routes: Array<LivequeryDatasourceInitConfig<RouteConfig>>): Promise<void> | void
236
+ }
237
+ ```
201
238
 
202
- Collections support three modes through `LivequeryCollectionOptions.mode`:
239
+ ### When To Use It
203
240
 
204
- - `server-first`: queries are driven by transporters, and collection state is built from streamed change events.
205
- - `cache-first`: first query can hydrate from local storage, then transporters refresh the result.
206
- - `local-first`: queries resolve from local storage while remote sync runs in the background and rebroadcasts matching changes.
241
+ Use this type when implementing an adapter that connects Livequery requests to a database, external API, or framework-specific route system.
207
242
 
208
- Implementation detail: in `local-first` mode, filters are applied by the storage adapter, while the remote query path is triggered with empty filters and matching is re-checked when added events are broadcast locally.
243
+ A datasource should:
209
244
 
210
- ## `LivequeryCollection`
245
+ - Implement `handle(ctx)` to process a request.
246
+ - Implement `init(routes)` to register route configuration.
211
247
 
212
- `LivequeryCollection<T>` manages one collection or one document ref.
248
+ ### Example
213
249
 
214
250
  ```ts
215
- type LivequeryCollectionOptions<T extends Doc> = {
216
- core: LivequeryCore
217
- filters: Partial<LivequeryFilters<T>>
218
- lazy: boolean
219
- debounce: number
220
- mode: "server-first" | "local-first" | "cache-first"
251
+ import type { LivequeryContext, LivequeryDatasource } from '@livequery/core'
252
+
253
+ type RouteConfig = { table: string }
254
+
255
+ class MemoryDatasource implements LivequeryDatasource<RouteConfig> {
256
+ #routes = new Map<string, RouteConfig>()
257
+
258
+ init(routes: Array<RouteConfig & { method: string; path: string }>) {
259
+ for (const route of routes) {
260
+ this.#routes.set(`${route.method.toUpperCase()} ${route.path}`, route)
261
+ }
262
+ }
263
+
264
+ handle(ctx: LivequeryContext) {
265
+ if (!ctx.livequery) return
266
+
267
+ ctx.response = {
268
+ item: {
269
+ id: ctx.livequery.document_id,
270
+ ref: ctx.livequery.ref,
271
+ },
272
+ }
273
+ }
221
274
  }
222
275
  ```
223
276
 
224
- ### Create and initialize a collection
277
+ ## `ApiGatewayHandler`
278
+
279
+ `ApiGatewayHandler` is an HTTP reverse proxy and route registry for Livequery service nodes.
280
+
281
+ It can:
282
+
283
+ - Receive service metadata from `UdpDiscovery`.
284
+ - Register routes by method and path.
285
+ - Forward HTTP requests to online service nodes.
286
+ - Round-robin between multiple hosts for the same route.
287
+ - Connect to service WebSocket gateways when service metadata includes `ws`.
288
+
289
+ ### When To Use It
225
290
 
226
- The current constructor takes `core` as the first argument and options as the second argument.
291
+ Use this in a gateway process. Public HTTP requests enter the gateway and are forwarded to service nodes discovered at runtime.
292
+
293
+ ### Constructor
227
294
 
228
295
  ```ts
229
- const posts = new LivequeryCollection<Post>(core, {
230
- filters: { "publishedAt:sort": "desc" },
231
- lazy: false,
232
- debounce: 250,
233
- mode: "cache-first",
296
+ new ApiGatewayHandler({
297
+ node_id?: string
298
+ discovery?: UdpDiscovery<ServiceApiMetadata>
299
+ ws?: WebsocketGateway
234
300
  })
301
+ ```
302
+
303
+ - `node_id`: stable id for this gateway. A random id is used when omitted.
304
+ - `discovery`: custom discovery instance, useful in tests or custom network setups.
305
+ - `ws`: realtime gateway used for cross-gateway WebSocket forwarding.
306
+
307
+ ### `register(options)`
235
308
 
236
- posts.initialize("posts")
309
+ Registers service routes manually.
310
+
311
+ ```ts
312
+ gateway.register({
313
+ node_id: 'service-1',
314
+ hostname: '127.0.0.1',
315
+ port: 3001,
316
+ paths: [{ method: 'GET', path: 'livequery/posts' }],
317
+ })
237
318
  ```
238
319
 
239
- `initialize(ref)` subscribes the collection to `LivequeryCore.watch(ref, id, mode)`. In the current implementation, it is browser-only and returns early when `window` is unavailable.
320
+ ### `deregister(node_id)`
321
+
322
+ Removes all route hosts for a service node.
323
+
324
+ Use this when a service goes offline or when a forwarded request fails.
240
325
 
241
- ### Collection refs and document refs
326
+ ### `fetch(request)`
242
327
 
243
- If a ref has an even number of path segments, the last segment is treated as a document id.
328
+ Accepts a Web `Request`, forwards it to the selected service, and returns a Web `Response`.
244
329
 
245
330
  ```ts
246
- posts.initialize("posts")
247
- singlePost.initialize("posts/post-1")
331
+ const response = await gateway.fetch(
332
+ new Request('http://gateway/livequery/posts')
333
+ )
248
334
  ```
249
335
 
250
- For collection mutations, `add`, `update`, and `delete` always target the collection portion of the ref.
336
+ ### `fetch(req, res, extraHeaders?)`
337
+
338
+ Accepts Node.js `IncomingMessage` and `ServerResponse`.
339
+
340
+ ```ts
341
+ import * as http from 'http'
342
+ import { ApiGatewayHandler } from '@livequery/core'
343
+
344
+ const gateway = new ApiGatewayHandler({})
345
+
346
+ http.createServer((req, res) => {
347
+ gateway.fetch(req as any, res)
348
+ }).listen(3000)
349
+ ```
350
+
351
+ ### `fetchRequest(request)`
352
+
353
+ Alias for `fetch(request)`.
354
+
355
+ ### `close()`
356
+
357
+ Unsubscribes from discovery, closes discovery sockets, disconnects service subscriptions, and clears service state.
358
+
359
+ ### Error Responses
360
+
361
+ - Missing route: `404 { error: { status: 404, code: 'API_NOT_FOUND' } }`
362
+ - Route exists but no host is online: `503 { error: { status: 503, code: 'API_OFFLINE' } }`
363
+ - Forwarded service request fails: `502 { error: { status: 502, code: 'SERVICE_API_OFFLINE' } }`
364
+
365
+ ## `ApiServiceLinker`
366
+
367
+ `ApiServiceLinker` publishes service metadata so gateways can discover and route to a service node.
251
368
 
252
- ### Reactive state
369
+ ### When To Use It
253
370
 
254
- - `items`: `BehaviorSubject<LivequeryDocument<DocState<T>>[]>`
255
- - `summary`: `BehaviorSubject<Record<string, any>>`
256
- - `loading`: `BehaviorSubject<null | "all" | "next" | "prev">`
257
- - `filters`: `BehaviorSubject<Partial<LivequeryFilters<T>>>`
258
- - `paging`: `BehaviorSubject<LivequeryPaging>`
259
- - `error`: `BehaviorSubject<{ code: string; message: string } | null>`
371
+ Use this inside each service process that should be discoverable by an `ApiGatewayHandler`.
260
372
 
261
- `items` is a `BehaviorSubject`, not a plain array. Reading `collection.items.value` gives the current snapshot only. If you need live updates, subscribe.
373
+ ### Constructor
262
374
 
263
375
  ```ts
264
- const subscription = posts.items.subscribe((items) => {
265
- console.log("realtime items", items.map((doc) => doc.value))
376
+ new ApiServiceLinker({
377
+ paths: [{ method: 'GET', path: 'livequery/posts' }],
378
+ node_id?: 'service-1',
379
+ discovery?: customDiscovery,
380
+ ws?: websocketGateway,
266
381
  })
267
-
268
- subscription.unsubscribe()
269
382
  ```
270
383
 
271
- In React, reading only `collection.items.value` during render will not trigger rerenders when new events arrive. Bridge the `BehaviorSubject` into component state.
384
+ ### `start(name, port)`
272
385
 
273
- ```tsx
274
- function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) {
275
- const [items, setItems] = useState(() => collection.items.value)
386
+ Broadcasts service metadata through UDP discovery.
276
387
 
277
- useEffect(() => {
278
- const subscription = collection.items.subscribe(setItems)
279
- return () => subscription.unsubscribe()
280
- }, [collection])
388
+ ```ts
389
+ const linker = new ApiServiceLinker({
390
+ paths: [{ method: 'GET', path: 'livequery/posts' }],
391
+ })
281
392
 
282
- return (
283
- <ul>
284
- {items.map((item) => (
285
- <li key={item.value.id}>{item.value.title}</li>
286
- ))}
287
- </ul>
288
- )
289
- }
393
+ linker.start('posts-service', 3001)
290
394
  ```
291
395
 
292
- ### Main methods
396
+ When the service sees a gateway in the same namespace, it refreshes its metadata version and broadcasts again.
397
+
398
+ ### `close()`
399
+
400
+ Unsubscribes from discovery and closes the discovery instance.
401
+
402
+ ## `UdpDiscovery`
403
+
404
+ `UdpDiscovery<T>` is an observable UDP discovery layer. It sends and receives msgpack packets signed with HMAC SHA-256.
405
+
406
+ ### When To Use It
407
+
408
+ Use it when gateway and service nodes need to discover each other without a central registry. `ApiGatewayHandler` and `ApiServiceLinker` create a default instance when no custom discovery is provided.
409
+
410
+ ### Constructor
293
411
 
294
412
  ```ts
295
- query(filters: Partial<LivequeryFilters<T>>): Promise<void>
296
- debounceQuery(filters: Partial<LivequeryFilters<T>>): Promise<void>
297
- loadMore(): Promise<void>
298
- loadPrev(): Promise<void>
299
- loadAround(cursor: string): Promise<void>
300
- add(payload: Partial<T>): Promise<T>
301
- update(id: string, payload: Partial<T>): Promise<T | undefined>
302
- delete(id: string): Promise<void | T | undefined>
303
- trigger<R>(action: string, payload?: Record<string, any>): Observable<{ data: R; error?: Error }>
304
- resetError(): void
305
- watch(check: (prev: T, next: T) => boolean): Observable<[DocState<T>, DocState<T>]>
413
+ const discovery = new UdpDiscovery<MyNode>({
414
+ key: 'shared-secret',
415
+ port: 11001,
416
+ })
306
417
  ```
307
418
 
308
- Notes about current behavior:
419
+ All trusted nodes must use the same key.
420
+
421
+ ### `status$`
422
+
423
+ Observable lifecycle status:
424
+
425
+ - `not_ready`
426
+ - `ready`
427
+ - `closed`
309
428
 
310
- - `query()` requires `initialize()` to have run first so the collection has a `ref` and watcher registration.
311
- - `debounceQuery()` only emits through the debounced path when `options.debounce` is truthy.
312
- - `loadMore()` uses `paging.next.cursor` as `:after`.
313
- - `loadPrev()` uses `paging.prev.cursor` as `:before`.
314
- - `loadAround()` currently sets both `:after` and `:before` to the provided cursor.
429
+ ### `broadcast(node, targetIp?)`
315
430
 
316
- ## `LivequeryDocument`
431
+ Broadcasts a node metadata packet.
317
432
 
318
- Each entry inside `collection.items` is a `LivequeryDocument`, which extends `BehaviorSubject<DocState<T>>`.
433
+ If `targetIp` is omitted, the packet is sent to configured multicast peers and local multicast. If `targetIp` is provided, the packet is sent only to that address or list of addresses.
319
434
 
320
435
  ```ts
321
- class LivequeryDocument<T extends Doc> extends BehaviorSubject<DocState<T>> {
322
- update(data: Partial<T>): Promise<T | undefined>
323
- del(): Promise<void | T | undefined>
324
- trigger<R>(action: string, payload: Record<string, any>): Observable<{ data: R; error?: Error }>
325
- }
436
+ await discovery.broadcast({
437
+ node_id: 'service-1',
438
+ namespace: 'default',
439
+ version: Date.now(),
440
+ role: 'service',
441
+ })
326
442
  ```
327
443
 
328
- Example:
444
+ ### `close()`
445
+
446
+ Closes sockets and completes the observable streams.
447
+
448
+ ### Example
329
449
 
330
450
  ```ts
331
- const first = todos.items.value[0]
451
+ import { UdpDiscovery, type UdpDiscoveryNode } from '@livequery/core'
332
452
 
333
- first.subscribe((doc) => {
334
- console.log(doc.title, doc._updating)
453
+ type Node = UdpDiscoveryNode & { role: 'service' | 'gateway' }
454
+
455
+ const discovery = new UdpDiscovery<Node>({ key: 'livequery/' })
456
+
457
+ discovery.subscribe(node => {
458
+ console.log('node online', node)
335
459
  })
336
460
 
337
- await first.update({ done: true })
338
- await first.del()
339
- first.trigger("archive", { reason: "completed" }).subscribe()
461
+ await discovery.broadcast({
462
+ node_id: 'service-1',
463
+ namespace: 'default',
464
+ version: Date.now(),
465
+ role: 'service',
466
+ })
340
467
  ```
341
468
 
342
- ## `LivequeryStorge`
469
+ ## `WebsocketGateway`
470
+
471
+ `WebsocketGateway` manages realtime subscriptions and forwards update events. It extends `Subject<UpdatedData>`, so callers can publish updates with `next(update)`.
472
+
473
+ ### When To Use It
343
474
 
344
- Local persistence adapters must implement:
475
+ Use it when clients need realtime updates for Livequery refs.
476
+
477
+ Typical flow:
478
+
479
+ 1. A client connects to the WebSocket endpoint.
480
+ 2. The client starts a socket session.
481
+ 3. HTTP requests register subscriptions by passing client/gateway headers.
482
+ 4. Services publish `UpdatedData`.
483
+ 5. The gateway sends `sync` events to subscribed clients.
484
+
485
+ ### Constructor
345
486
 
346
487
  ```ts
347
- type LivequeryStorge = {
348
- query<T extends Doc>(
349
- collection: string,
350
- filters?: Record<string, any>
351
- ): Promise<{
352
- documents: T[]
353
- paging: LivequeryPaging
354
- }>
355
- get<T extends Doc>(ref: string, id: string): Promise<T | null>
356
- add<T extends Doc>(collection: string, document: T): Promise<T>
357
- update<T extends Doc>(collection: string, id: string, document: Record<string, any>): Promise<T | null>
358
- delete<T extends Doc>(collection: string, id: string): Promise<T | null>
359
- }
488
+ new WebsocketGateway(serverOrPort)
360
489
  ```
361
490
 
362
- The package ships with `LivequeryMemoryStorage`, an in-memory adapter useful for tests, demos, and ephemeral state.
491
+ - Pass an `http.Server` in Node.js.
492
+ - Pass a port number in Bun runtime.
493
+
494
+ ### Properties
363
495
 
364
- ### `LivequeryMemoryStorage`
496
+ - `id`: unique gateway id.
497
+ - `auth`: token used by trusted gateway-to-gateway connections.
365
498
 
366
- The built-in adapter:
499
+ ### `handle(ctx)`
367
500
 
368
- - stores documents in `Map<string, Map<string, Doc>>`
369
- - generates a local id with `local:${crypto.randomUUID()}` when `id` is missing
370
- - applies filters through the exported `filterDocs()` helper
371
- - supports nested sort keys such as `profile.createdAt:sort`
501
+ Reads:
372
502
 
373
- ## `LivequeryTransporter`
503
+ - `ctx.livequery.ref`
504
+ - `x-lcid` or `socket_id`
505
+ - `x-lgid`
374
506
 
375
- Remote adapters must implement:
507
+ Then registers a realtime subscription for the current request ref.
508
+
509
+ ### `listen(events)`
510
+
511
+ Registers one or more realtime subscriptions.
376
512
 
377
513
  ```ts
378
- type LivequeryTransporter = {
379
- query<T extends Doc>(query: LivequeryQueryParams<T>): Observable<Partial<LivequeryQueryResult>>
380
- add<T extends Doc>(ref: string, doc: Omit<T, "id">): Promise<T>
381
- update<T extends Doc>(ref: string, id: string, doc: Partial<T>): Promise<T>
382
- delete<T extends Doc>(ref: string, id: string): Promise<T>
383
- trigger<T>(action: LivequeryAction): Promise<T>
384
- }
514
+ wsGateway.listen([{
515
+ ref: 'posts',
516
+ client_id: 'client-1',
517
+ gateway_id: wsGateway.id,
518
+ listener_node_id: wsGateway.id,
519
+ }])
385
520
  ```
386
521
 
387
- ### Query result shape
522
+ ### `unsubscribe_client(socket, body)`
523
+
524
+ Removes subscriptions for a client by `ref` or `refs`.
525
+
526
+ ### `link(ref, handler)`
527
+
528
+ Attaches an observable update stream for a ref that already has subscribers.
388
529
 
389
530
  ```ts
390
- type LivequeryQueryResult = {
391
- error: { code: string; message: string }
392
- changes: DataChangeEvent[]
393
- summary: Record<string, any>
394
- paging: LivequeryPaging
395
- metadata: Record<string, any>
396
- source: "query" | "action" | "realtime"
397
- loading?: "all" | "next" | "prev" | null
398
- }
531
+ import { Subject } from 'rxjs'
532
+
533
+ const updates$ = new Subject<any>()
534
+
535
+ await wsGateway.link('posts', () => updates$)
536
+
537
+ updates$.next({
538
+ ref: 'posts',
539
+ type: 'modified',
540
+ data: { id: 'p1', title: 'New title' },
541
+ })
399
542
  ```
400
543
 
401
- Transporters can emit partial results. In practice, the most useful fields are `changes`, `paging`, `summary`, `metadata`, and `error`.
544
+ ### `connect(url, auth, ondisconnect?)`
545
+
546
+ Creates an outbound connection to another WebSocket gateway and forwards subscription/sync events.
547
+
548
+ ### `close()`
549
+
550
+ Closes the WebSocket server, active sockets, subscriptions, update streams, and completes the subject.
402
551
 
403
- ## Query Filters
552
+ ### Client Protocol
404
553
 
405
- Filters are flat keys derived from the document type.
554
+ Client connects to `WEBSOCKET_PATH`, then sends:
406
555
 
407
- ### Pagination keys
556
+ ```json
557
+ { "event": "start", "data": { "id": "client-1", "auth": "" } }
558
+ ```
559
+
560
+ Gateway responds:
408
561
 
409
- - `:limit`
410
- - `:before`
411
- - `:after`
412
- - `:around`
413
- - `:page`
562
+ ```json
563
+ { "event": "hello", "gid": "...", "binary": true }
564
+ ```
414
565
 
415
- ### Supported operators
566
+ Server update sent to client:
416
567
 
417
- - `field:sort` with `"asc" | "desc"` for string and number fields
418
- - `field:gt`, `field:gte`, `field:lt`, `field:lte` for numeric fields
419
- - `field:eq-number` for numeric equality
420
- - `field` for string equality
421
- - `field:in`, `field:nin` for string or number membership
422
- - `field:include` for array containment
423
- - `field:boolean` with `"true" | "false" | "not-true" | "not-false"`
424
- - `field:like` for case-insensitive substring matching on strings
425
- - `field:null` with `"null-only" | "not-null"`
568
+ ```json
569
+ {
570
+ "event": "sync",
571
+ "data": {
572
+ "changes": [
573
+ { "ref": "posts", "type": "modified", "data": { "id": "p1" } }
574
+ ]
575
+ }
576
+ }
577
+ ```
426
578
 
427
- Nested field paths are supported, for example `"profile.createdAt:sort"`.
579
+ ## Helpers
580
+
581
+ ### `hidePrivateFieldsInItem(item)`
582
+
583
+ Returns a new object with private fields removed. Fields beginning with `_` are removed, except `_id`, which is mapped to `id` when `id` is missing.
428
584
 
429
585
  ```ts
430
- await todos.query({
431
- ":limit": 20,
432
- "done:boolean": "false",
433
- "title:like": "milk",
434
- "createdAt:gte": 1714176000000,
435
- "createdAt:sort": "desc",
436
- })
586
+ hidePrivateFieldsInItem({ _id: '1', name: 'Alice', _secret: true })
587
+ // { id: '1', name: 'Alice' }
437
588
  ```
438
589
 
439
- ## Helper Exports
590
+ ### `hidePrivateFields(data)`
440
591
 
441
- ### `filterDocs()`
592
+ Sanitizes a plain item, a `DocumentResponse`, or a `CollectionResponse`.
442
593
 
443
594
  ```ts
444
- import { filterDocs } from "@livequery/core"
595
+ hidePrivateFields({
596
+ item: { _id: 'p1', title: 'Hello', _internal: true },
597
+ })
598
+ // { item: { id: 'p1', title: 'Hello' } }
599
+ ```
600
+
601
+ ### `nodeRequestToWebRequest(req, extraHeaders?)`
602
+
603
+ Converts a Node.js request with optional `rawBody` into a Web `Request`.
604
+
605
+ - Uses the `host` header, or `127.0.0.1` as fallback.
606
+ - Merges `extraHeaders`.
607
+ - Omits body for `GET` and `HEAD`.
608
+ - Uses `req.rawBody` for methods that support a body.
609
+
610
+ ### `writeWebResponse(res, response)`
445
611
 
446
- const visible = filterDocs(docs, {
447
- "done:boolean": "false",
448
- "title:like": "milk",
612
+ Copies a Web `Response` into a Node.js `ServerResponse`.
613
+
614
+ - Copies status.
615
+ - Copies headers.
616
+ - Writes the response body.
617
+
618
+ ## Constants
619
+
620
+ | Constant | Meaning | Default |
621
+ | --- | --- | --- |
622
+ | `API_GATEWAY_NAMESPACE` | Namespace used by gateway and service metadata filtering | `default` |
623
+ | `LIVEQUERY_MAGIC_KEY` | Livequery path prefix and default discovery key suffix | `livequery/` |
624
+ | `API_GATEWAY_MULTICAST_PORT` | UDP discovery port | `11001` |
625
+ | `API_GATEWAY_MULTICAST_ADDRESS` | UDP multicast address | `239.0.1.1` |
626
+ | `API_GATEWAY_WHITELIST_ADDRESS` | Additional peer IPs or prefixes | empty |
627
+ | `NODE_ID` | Runtime node id | random UUID |
628
+ | `LIVEQUERY_API_GATEWAY_DEBUG` | Enables gateway logs | false |
629
+ | `WEBSOCKET_PATH` | Realtime WebSocket path | `/livequery/realtime-updates` |
630
+
631
+ ## Example: Service Process
632
+
633
+ ```ts
634
+ import * as http from 'http'
635
+ import {
636
+ ApiServiceLinker,
637
+ LivequeryRequestParser,
638
+ hidePrivateFields,
639
+ type LivequeryContext,
640
+ } from '@livequery/core'
641
+
642
+ const parser = new LivequeryRequestParser()
643
+
644
+ const server = http.createServer(async (req, res) => {
645
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
646
+ const id = url.pathname.split('/').at(-1)
647
+
648
+ const ctx: LivequeryContext = {
649
+ request: {
650
+ path: url.pathname,
651
+ ref: '/livequery/posts/:id',
652
+ method: req.method ?? 'GET',
653
+ params: { id },
654
+ query: Object.fromEntries(url.searchParams),
655
+ headers: new Map(Object.entries(req.headers).map(([k, v]) => [k, String(v)])),
656
+ },
657
+ }
658
+
659
+ parser.handle(ctx)
660
+
661
+ ctx.response = hidePrivateFields({
662
+ item: { _id: ctx.livequery?.document_id, title: 'Hello', _internal: true },
663
+ })
664
+
665
+ res.setHeader('content-type', 'application/json')
666
+ res.end(JSON.stringify(ctx.response))
449
667
  })
668
+
669
+ server.listen(3001)
670
+
671
+ new ApiServiceLinker({
672
+ paths: [{ method: 'GET', path: 'livequery/posts/:id' }],
673
+ }).start('posts-service', 3001)
450
674
  ```
451
675
 
452
- ### `matchesAllFilters()`
676
+ ## Example: Gateway Process
453
677
 
454
- The helper module also exports `matchesAllFilters(doc, filters)` for direct predicate checks.
678
+ ```ts
679
+ import * as http from 'http'
680
+ import {
681
+ ApiGatewayHandler,
682
+ WebsocketGateway,
683
+ } from '@livequery/core'
455
684
 
456
- ## Caveats
685
+ const server = http.createServer()
686
+ const ws = new WebsocketGateway(server)
687
+ const gateway = new ApiGatewayHandler({ ws })
457
688
 
458
- - `initialize()` is browser-only because it exits early when `window` is unavailable.
459
- - The public storage interface name is intentionally spelled `LivequeryStorge`, matching the source.
460
- - Optimistic flags such as `_adding`, `_updating`, `_deleting`, and `_prev` are system-managed fields.
461
- - Transporter query streams are expected to emit incremental `changes`, not full snapshots.
462
- - `LivequeryCollection` declares a `metadata` subject but does not initialize it in the constructor, so transporter-emitted `metadata` is not safe to rely on yet.
463
- - `trigger()` is typed at the collection and document layer as `Observable<{ data, error? }>` but currently forwards raw transporter results from `LivequeryCore.trigger()`.
689
+ server.on('request', (req, res) => {
690
+ gateway.fetch(req as any, res)
691
+ })
464
692
 
465
- ## Development
693
+ server.listen(3000)
694
+ ```
466
695
 
467
- ```bash
468
- bun run build
696
+ ## Environment Variables
697
+
698
+ ```sh
699
+ API_GATEWAY_NAMESPACE=default
700
+ LIVEQUERY_MAGIC_KEY=livequery
701
+ UDP_PUBLIC_PORT=11001
702
+ UDP_MULTICAST_ADDRESS=239.0.1.1
703
+ UDP_WHITELIST_ADDRESS=192.168.1
704
+ REALTIME_UPDATE_SOCKET_PATH=/livequery/realtime-updates
705
+ LIVEQUERY_API_GATEWAY_DEBUG=1
706
+ LIVEQUERY_UDP_DEBUG=1
469
707
  ```
470
708
 
471
- Available scripts:
709
+ `UDP_WHITELIST_ADDRESS` accepts:
710
+
711
+ - A full IP address, for example `192.168.1.10`.
712
+ - A three-part prefix, for example `192.168.1`, expanded to `192.168.1.0` through `192.168.1.255`.
713
+
714
+ ## Tests
715
+
716
+ ```sh
717
+ bun run build
718
+ bun test tests/
719
+ bunx tsc -p tests/tsconfig.json --noEmit
720
+ ```
472
721
 
473
- - `bun run clean`
474
- - `bun run build:js`
475
- - `bun run build:types`
476
- - `bun run build`
477
- - `bun run build:watch`
722
+ The test suite covers:
723
+
724
+ - Public entrypoint exports.
725
+ - Request parsing, including nested params, realtime suffixes, and query strings containing `~`.
726
+ - API gateway routing, metadata updates, header/body forwarding, error responses, and round-robin.
727
+ - Service metadata publishing with `ApiServiceLinker`.
728
+ - UDP discovery signatures, TTL, status, and close behavior.
729
+ - WebSocket gateway lifecycle, subscriptions, observable links, and gateway-to-gateway forwarding.
730
+ - Hono integration and multi-process gateway/service discovery flows.
731
+ - Response field sanitization.
732
+ - Node/Web HTTP helper conversion.