@livequery/core 2.0.104 → 2.0.106

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