@livequery/client 2.0.134 → 2.0.135

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 CHANGED
@@ -2,37 +2,24 @@
2
2
 
3
3
  Reactive local-first data primitives for browser clients.
4
4
 
5
- This repository is the client library package, not an application. Changes here should preserve reusable public API behavior unless a task explicitly targets a breaking change.
5
+ `@livequery/client` is a client library, not an application framework. It gives you a small set of reusable primitives for local storage, remote transport, reactive collections, reactive documents, optimistic mutations, filtering, sorting, pagination cursors, and action triggers.
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 package is ESM-first and currently targets browser clients. `LivequeryCollection.initialize()` returns early when `window` is unavailable, so do not treat the collection wrapper as SSR-safe state by default.
8
8
 
9
- ## AI Agent Guidance
10
-
11
- Repository-specific agent guidance lives in `AGENTS.md` and `copilot-instructions.md`.
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 `LivequeryClient`, initialize collections before querying, and subscribe to collection state instead of relying on one-time `.value` reads.
17
-
18
- ## Installation
9
+ ## Install
19
10
 
20
11
  ```bash
21
12
  bun add @livequery/client rxjs
22
13
  ```
23
14
 
24
- For React projects:
15
+ For React projects you may also use a React bridge package if your app has one:
25
16
 
26
17
  ```bash
27
18
  bun add @livequery/client @livequery/react rxjs
28
19
  ```
29
20
 
30
- The package is published as ESM and targets browser usage.
31
-
32
21
  ## Public Exports
33
22
 
34
- The package re-exports:
35
-
36
23
  ```ts
37
24
  export * from "./LivequeryCollection"
38
25
  export * from "./LivequeryClient"
@@ -44,49 +31,9 @@ export * from "./helpers/filterDocs"
44
31
  export * from "./LivequeryDocument"
45
32
  ```
46
33
 
47
- ## Core Types
48
-
49
- ### `Doc`
50
-
51
- Every record must have an `id`.
52
-
53
- ```ts
54
- type Doc<T = {}> = T & {
55
- id: string
56
- }
57
- ```
58
-
59
- ### `DocState`
60
-
61
- Collections and documents expose `DocState<T>`, which adds optimistic mutation metadata.
62
-
63
- ```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>
73
- }
74
- ```
75
-
76
- ### `DataChangeEvent`
77
-
78
- Transporters stream incremental change events back into the client.
34
+ The public storage interface is intentionally named `LivequeryStorge`. The spelling is part of the current public API.
79
35
 
80
- ```ts
81
- type DataChangeEvent = {
82
- collection_ref: string
83
- id: string
84
- type: "added" | "removed" | "modified"
85
- data?: Record<string, any>
86
- }
87
- ```
88
-
89
- ## Architecture
36
+ ## Mental Model
90
37
 
91
38
  ```text
92
39
  LivequeryCollection / LivequeryDocument
@@ -98,19 +45,30 @@ LivequeryCollection / LivequeryDocument
98
45
  LivequeryStorge LivequeryTransporter(s)
99
46
  ```
100
47
 
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
- - `LivequeryClient` coordinates storage, transporters, optimistic writes, and fan-out to watchers.
104
- - `LivequeryStorge` is the local persistence contract.
105
- - `LivequeryTransporter` is the remote sync contract.
48
+ - `LivequeryClient` is the coordination core. It owns collection registrations, query orchestration, transporter fan-out, local storage writes, broadcast delivery, and optimistic mutation reconciliation.
49
+ - `LivequeryCollection<T>` is the main consumer-facing list or document wrapper. It exposes reactive subjects such as `items`, `loading`, `filters`, `paging`, `summary`, and `error`.
50
+ - `LivequeryDocument<T>` wraps one document in a `BehaviorSubject` and forwards `update`, `del`, `trigger`, and `select` calls to its collection.
51
+ - `LivequeryStorge` is the local persistence contract used by the client.
52
+ - `LivequeryMemoryStorage` is the in-memory reference storage adapter.
53
+ - `LivequeryTransporter` is the remote sync/action contract.
54
+
55
+ ## Refs
56
+
57
+ Livequery distinguishes collection refs and document refs by path segment count:
58
+
59
+ - Collection ref: odd number of path segments, for example `todos` or `users/user-1/posts`.
60
+ - Document ref: even number of path segments, for example `todos/todo-1` or `users/user-1/posts/post-1`.
61
+
62
+ `LivequeryCollection.initialize(ref)` derives `collection_ref` from this rule. For `todos/todo-1`, the collection ref is `todos` and the document id is `todo-1`.
106
63
 
107
64
  ## Quick Start
108
65
 
109
66
  ```ts
110
67
  import {
111
- LivequeryCollection,
112
68
  LivequeryClient,
69
+ LivequeryCollection,
113
70
  LivequeryMemoryStorage,
71
+ type DataChangeEvent,
114
72
  type Doc,
115
73
  type LivequeryQueryResult,
116
74
  type LivequeryTransporter,
@@ -126,11 +84,25 @@ type Todo = Doc<{
126
84
  const storage = new LivequeryMemoryStorage()
127
85
 
128
86
  const transporter: LivequeryTransporter = {
129
- query(_query) {
87
+ query(query) {
88
+ const changes: DataChangeEvent[] = [
89
+ {
90
+ collection_ref: query.ref,
91
+ id: "todo-1",
92
+ type: "added",
93
+ data: {
94
+ id: "todo-1",
95
+ title: "Read the docs",
96
+ done: false,
97
+ createdAt: Date.now(),
98
+ },
99
+ },
100
+ ]
101
+
130
102
  return of<Partial<LivequeryQueryResult>>({
131
- changes: [],
132
- summary: {},
133
- paging: { total: 0, current: 0 },
103
+ changes,
104
+ paging: { total: 1, current: 1 },
105
+ summary: { open: 1 },
134
106
  metadata: {},
135
107
  source: "query",
136
108
  })
@@ -138,14 +110,14 @@ const transporter: LivequeryTransporter = {
138
110
  async add(_ref, doc) {
139
111
  return { id: crypto.randomUUID(), ...doc } as Todo
140
112
  },
141
- async update(_ref, id, doc) {
142
- return { id, ...doc } as Todo
113
+ async update(_ref, id, patch) {
114
+ return { id, ...patch } as Todo
143
115
  },
144
116
  async delete(_ref, id) {
145
117
  return { id } as Todo
146
118
  },
147
- async trigger(_action) {
148
- return { ok: true }
119
+ async trigger(action) {
120
+ return { ok: true, action: action.action }
149
121
  },
150
122
  }
151
123
 
@@ -157,216 +129,490 @@ const client = new LivequeryClient({
157
129
  })
158
130
 
159
131
  const todos = new LivequeryCollection<Todo>(client, {
160
- filters: { "createdAt:sort": "desc" },
161
- mode: "server-first",
132
+ mode: "cache-first",
133
+ filters: {
134
+ "createdAt:sort": "desc",
135
+ },
162
136
  })
163
137
 
164
138
  todos.initialize("todos")
165
139
 
166
- todos.items.subscribe((items) => {
167
- console.log(items.map((doc) => doc.value))
140
+ const subscription = todos.items.subscribe((items) => {
141
+ console.log(items.map((item) => item.value))
142
+ })
143
+
144
+ await todos.query({
145
+ ":limit": 20,
146
+ "done:boolean": "false",
147
+ "createdAt:sort": "desc",
148
+ })
149
+
150
+ await todos.add({
151
+ title: "Ship feature",
152
+ done: false,
153
+ createdAt: Date.now(),
154
+ })
155
+
156
+ await todos.update({
157
+ id: "todo-1",
158
+ done: true,
168
159
  })
169
160
 
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
161
  await todos.delete("todo-1")
174
162
 
175
- // Override mutation behavior when needed
176
- await todos.add({ title: "Draft", done: false, createdAt: Date.now() }, "local-only")
163
+ subscription.unsubscribe()
177
164
  ```
178
165
 
166
+ ## Core Types
167
+
168
+ ### `Doc`
169
+
170
+ Every document must have an `id`.
171
+
172
+ ```ts
173
+ type Doc<T = {}> = T & {
174
+ id: string
175
+ }
176
+ ```
177
+
178
+ Use it to define app records:
179
+
180
+ ```ts
181
+ type Post = Doc<{
182
+ title: string
183
+ published: boolean
184
+ author: {
185
+ id: string
186
+ name: string
187
+ }
188
+ }>
189
+ ```
190
+
191
+ ### `DocState`
192
+
193
+ `DocState<T>` is the runtime shape exposed by collections and documents. It includes your document fields plus internal optimistic metadata.
194
+
195
+ ```ts
196
+ type DocState<T extends Doc> = T & {
197
+ _deleting?: boolean
198
+ _local_only?: boolean
199
+ _deleting_error?: { code: string; message: string; transporter_id: string }
200
+ _updating?: boolean
201
+ _updating_error?: { code: string; message: string; transporter_id: string }
202
+ _adding?: boolean
203
+ _adding_error?: { code: string; message: string; transporter_id: string }
204
+ _remotes?: Record<string, string | number>
205
+ _prev?: Record<string, any>
206
+ _selected?: boolean
207
+ _index?: number
208
+ }
209
+ ```
210
+
211
+ Do not strip `_adding`, `_updating`, `_deleting`, or error fields if your UI needs to show mutation progress or failure state.
212
+
213
+ ### `DataChangeEvent`
214
+
215
+ Transporter query streams and internal broadcasts use incremental change events:
216
+
217
+ ```ts
218
+ type DataChangeEvent = {
219
+ collection_ref: string
220
+ id: string
221
+ type: "added" | "removed" | "modified"
222
+ data?: Record<string, any>
223
+ }
224
+ ```
225
+
226
+ Events are incremental, not full snapshot replacements. A `modified` event may contain only changed fields.
227
+
179
228
  ## `LivequeryClient`
180
229
 
181
- Create one client with a storage adapter and one or more transporters:
230
+ `LivequeryClient` coordinates storage, transporters, query streams, optimistic writes, and collection broadcasts.
182
231
 
183
232
  ```ts
184
233
  const client = new LivequeryClient({
185
- storage,
234
+ storage: new LivequeryMemoryStorage(),
186
235
  transporters: {
187
236
  primary: transporter,
188
237
  },
189
238
  })
190
239
  ```
191
240
 
192
- ### Mutation flow
241
+ ### Constructor
242
+
243
+ ```ts
244
+ new LivequeryClient({
245
+ storage,
246
+ transporters,
247
+ })
248
+ ```
249
+
250
+ - `storage`: a `LivequeryStorge` adapter.
251
+ - `transporters`: a map of transporter id to `LivequeryTransporter`. Use one transporter for a simple app. Use multiple transporters when the same client should fan out to more than one backend.
252
+
253
+ ### `watch(ref, collection_id, mode)`
254
+
255
+ Registers a collection or document watcher and returns an observable data stream.
256
+
257
+ Most app code should not call `watch()` directly. `LivequeryCollection.initialize()` calls it for you. Use it only when building a custom wrapper around `LivequeryClient`.
258
+
259
+ ### `query(req)`
260
+
261
+ Lower-level query entry point used by `LivequeryCollection.query()`.
262
+
263
+ ```ts
264
+ await client.query<Todo>({
265
+ ref: "todos",
266
+ collection_id: todos.id,
267
+ filters: { "done:boolean": "false" },
268
+ })
269
+ ```
270
+
271
+ Consumers should usually call `collection.query(filters)` instead.
272
+
273
+ ### `add(collection_ref, documents, mode)`
274
+
275
+ Lower-level mutation entry point used by `LivequeryCollection.add()`.
276
+
277
+ - `server-first`: push to transporters first.
278
+ - `local-first`: add to storage with `_adding: true`, broadcast locally, then push remote and reconcile.
279
+ - `local-only`: add to storage with `_adding: true` and `_local_only: true`, broadcast locally, and skip transporters.
280
+
281
+ ### `update(collection_ref, documents, mode)`
282
+
283
+ Lower-level mutation entry point used by `LivequeryCollection.update()`.
193
284
 
194
- For `add`, `update`, and `delete`, behavior depends on action mode:
285
+ For local-first style updates, the client reads the old local document, records previous field values in `_prev`, stores `_updating: true`, broadcasts a `modified` event, then pushes only changed fields to transporters.
195
286
 
196
- 1. `server-first`: pushes directly to transporters and returns transporter results.
197
- 2. `local-first`: writes optimistic state to local storage, broadcasts changes, then pushes to transporters and reconciles flags/errors.
198
- 3. `local-only`: writes locally and broadcasts only; no transporter calls are made.
287
+ ### `delete(collection_ref, ids, mode)`
199
288
 
200
- Documents created locally receive ids prefixed with `local:` until a transporter returns a persisted id.
289
+ Lower-level delete entry point used by `LivequeryCollection.delete()`.
201
290
 
202
- ### Query modes
291
+ - Local-only documents and explicit `local-only` deletes are hard-deleted from storage.
292
+ - Documents with transporters are soft-deleted first with `_deleting: true`, then hard-deleted after remote confirmation.
293
+ - Remote delete errors are persisted as `_deleting_error`.
203
294
 
204
- Collections support four modes through `LivequeryCollectionOptions.mode`:
295
+ ### `trigger(action)`
205
296
 
206
- - `server-first`: queries are driven by transporters, and collection state is built from streamed change events.
207
- - `cache-first`: first query can hydrate from local storage, then transporters refresh the result.
208
- - `local-first`: queries resolve from local storage while remote sync runs in the background and rebroadcasts matching changes.
209
- - `local-only`: queries resolve exclusively from local storage. No transporters are contacted for reads. Mutations run in `local-only` mode are kept local and never pushed to any transporter.
297
+ Calls transporter `trigger()` methods and returns an RxJS observable.
210
298
 
211
- 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. In `local-only` mode the transporter path is skipped for queries, and mutations stay local only when you explicitly execute them with `mode: "local-only"`.
299
+ ```ts
300
+ client.trigger<{ archived: boolean }>({
301
+ ref: "todos",
302
+ action: "archive-done",
303
+ payload: { olderThan: Date.now() - 7 * 86400_000 },
304
+ transporter_id: "primary",
305
+ })
306
+ ```
212
307
 
213
- ### Local-only guide
308
+ Use `collection.trigger()` for normal consumer code.
214
309
 
215
- For a focused walkthrough of local-only behavior, use cases, and caveats, see [docs/local-only.md](docs/local-only.md).
310
+ ### `flush(collection_ref)`
216
311
 
217
- This behavior description is aligned with the current `2.0.123` release line.
312
+ Broadcasts a wildcard local removal for a collection and clears storage.
313
+
314
+ ```ts
315
+ await client.flush("todos")
316
+ ```
317
+
318
+ This is broad because the current storage contract has `flush(): Promise<void>` without a collection argument.
319
+
320
+ ### `destroy()`
321
+
322
+ Unsubscribes the client's internal query pipelines. Call it when permanently disposing a client instance.
218
323
 
219
324
  ## `LivequeryCollection`
220
325
 
221
- `LivequeryCollection<T>` manages one collection or one document ref.
326
+ `LivequeryCollection<T>` is the primary app-facing API. It manages one collection ref or one document ref and exposes reactive state through `BehaviorSubject`s.
327
+
328
+ ```ts
329
+ const todos = new LivequeryCollection<Todo>(client, {
330
+ mode: "local-first",
331
+ lazy: false,
332
+ debounce: 250,
333
+ filters: {
334
+ "done:boolean": "false",
335
+ "createdAt:sort": "desc",
336
+ },
337
+ })
338
+ ```
339
+
340
+ ### Options
222
341
 
223
342
  ```ts
224
343
  type LivequeryCollectionOptions<T extends Doc> = {
225
- client: LivequeryClient
226
344
  filters: Partial<LivequeryFilters<T>>
227
345
  lazy: boolean
228
346
  debounce: number
229
- mode: "server-first" | "local-first" | "cache-first" | "local-only"
347
+ mode: "server-first" | "cache-first" | "local-first" | "local-only"
230
348
  }
231
349
  ```
232
350
 
233
- ### Create and initialize a collection
351
+ - `filters`: initial query filters.
352
+ - `lazy`: when not `true`, `initialize()` schedules an automatic query with current filters.
353
+ - `debounce`: enables `debounceQuery()`.
354
+ - `mode`: controls query behavior. Mutation methods still default to `server-first` unless you pass a mode override.
234
355
 
235
- The current constructor takes `client` as the first argument and options as the second argument.
356
+ ### Reactive Properties
236
357
 
237
358
  ```ts
238
- const posts = new LivequeryCollection<Post>(client, {
239
- filters: { "publishedAt:sort": "desc" },
240
- lazy: false,
241
- debounce: 250,
359
+ items: BehaviorSubject<LivequeryDocument<DocState<T>>[]>
360
+ summary: BehaviorSubject<Record<string, any>>
361
+ loading: BehaviorSubject<null | "all" | "next" | "prev">
362
+ filters: BehaviorSubject<Partial<LivequeryFilters<T>>>
363
+ paging: BehaviorSubject<LivequeryPaging>
364
+ selected: BehaviorSubject<Set<string>>
365
+ error: BehaviorSubject<{ code: string; message: string } | null>
366
+ ref: string | undefined
367
+ collection_ref: string | undefined
368
+ id: string
369
+ ```
370
+
371
+ Reading `.value` gives a snapshot. Subscribe for live updates:
372
+
373
+ ```ts
374
+ const sub = todos.items.subscribe((documents) => {
375
+ for (const document of documents) {
376
+ console.log(document.value.id, document.value.title)
377
+ }
378
+ })
379
+
380
+ sub.unsubscribe()
381
+ ```
382
+
383
+ ### `initialize(ref)`
384
+
385
+ Initializes the collection and registers it with the client.
386
+
387
+ ```ts
388
+ todos.initialize("todos")
389
+ ```
390
+
391
+ Call `initialize()` before `query()`, `add()`, `update()`, `delete()`, `trigger()`, or `flush()`. The method returns a subscription when running in the browser. It returns early on the server.
392
+
393
+ ### `query(filters)`
394
+
395
+ Runs a query and replaces current `items` when cached/local documents are returned.
396
+
397
+ ```ts
398
+ await todos.query({
399
+ ":limit": 20,
400
+ "done:boolean": "false",
401
+ "createdAt:sort": "desc",
402
+ })
403
+ ```
404
+
405
+ ### `debounceQuery(filters)`
406
+
407
+ Pushes filters into a debounced query subject. This only has an effect when the collection was created with a truthy `debounce` option.
408
+
409
+ ```ts
410
+ const searchTodos = new LivequeryCollection<Todo>(client, {
242
411
  mode: "cache-first",
412
+ debounce: 300,
243
413
  })
244
414
 
245
- posts.initialize("posts")
415
+ searchTodos.initialize("todos")
416
+ await searchTodos.debounceQuery({ "title:like": "milk" })
246
417
  ```
247
418
 
248
- `initialize(ref)` subscribes the collection to `LivequeryClient.watch(ref, id, mode)`. In the current implementation, it is browser-only and returns early when `window` is unavailable.
419
+ ### `sort(field, order)`
249
420
 
250
- ### Collection refs and document refs
421
+ Sorts by a field or resets to insertion order.
251
422
 
252
- If a ref has an even number of path segments, the last segment is treated as a document id.
423
+ ```ts
424
+ await todos.sort("createdAt", "desc")
425
+ await todos.sort("title", "asc")
426
+ await todos.sort("reset", "asc")
427
+ ```
428
+
429
+ For non-`local-only` collections, sorting calls `query()` with a `field:sort` filter. For `local-only`, sorting is applied to current items in memory.
430
+
431
+ ### `loadMore()`, `loadPrev()`, `loadAround(cursor)`
432
+
433
+ Cursor helpers based on `paging.value`.
253
434
 
254
435
  ```ts
255
- posts.initialize("posts")
256
- singlePost.initialize("posts/post-1")
436
+ if (todos.paging.value.next) {
437
+ await todos.loadMore()
438
+ }
439
+
440
+ if (todos.paging.value.prev) {
441
+ await todos.loadPrev()
442
+ }
443
+
444
+ await todos.loadAround("cursor-123")
257
445
  ```
258
446
 
259
- For collection mutations, `add`, `update`, and `delete` always target the collection portion of the ref.
447
+ - `loadMore()` adds `:after`.
448
+ - `loadPrev()` adds `:before`.
449
+ - `loadAround(cursor)` currently sets both `:after` and `:before` to the same cursor.
450
+
451
+ ### `add(payload, mode?)`
452
+
453
+ Adds one or many documents.
454
+
455
+ ```ts
456
+ const todo = await todos.add({
457
+ title: "Buy milk",
458
+ done: false,
459
+ createdAt: Date.now(),
460
+ })
461
+
462
+ const localDraft = await todos.add(
463
+ { title: "Draft", done: false, createdAt: Date.now() },
464
+ "local-only"
465
+ )
466
+
467
+ const many = await todos.add([
468
+ { title: "A", done: false, createdAt: Date.now() },
469
+ { title: "B", done: false, createdAt: Date.now() },
470
+ ])
471
+ ```
260
472
 
261
- ### Reactive state
473
+ The return shape follows the input shape: one payload returns one document; an array returns an array.
262
474
 
263
- - `items`: `BehaviorSubject<LivequeryDocument<DocState<T>>[]>`
264
- - `summary`: `BehaviorSubject<Record<string, any>>`
265
- - `loading`: `BehaviorSubject<null | "all" | "next" | "prev">`
266
- - `filters`: `BehaviorSubject<Partial<LivequeryFilters<T>>>`
267
- - `paging`: `BehaviorSubject<LivequeryPaging>`
268
- - `error`: `BehaviorSubject<{ code: string; message: string } | null>`
475
+ Important: the current method signature defaults `mode` to `"server-first"`. A collection configured with `mode: "local-only"` still needs `add(payload, "local-only")` if you want the mutation to stay local.
269
476
 
270
- `items` is a `BehaviorSubject`, not a plain array. Reading `collection.items.value` gives the current snapshot only. If you need live updates, subscribe.
477
+ ### `update(payload, mode?)`
478
+
479
+ Updates one or many documents. Include `id` in every payload.
271
480
 
272
481
  ```ts
273
- const subscription = posts.items.subscribe((items) => {
274
- console.log("realtime items", items.map((doc) => doc.value))
482
+ await todos.update({
483
+ id: "todo-1",
484
+ done: true,
275
485
  })
276
486
 
277
- subscription.unsubscribe()
487
+ await todos.update(
488
+ { id: "todo-1", title: "Local title" },
489
+ "local-only"
490
+ )
491
+
492
+ await todos.update([
493
+ { id: "todo-1", done: true },
494
+ { id: "todo-2", done: false },
495
+ ])
278
496
  ```
279
497
 
280
- In React, reading only `collection.items.value` during render will not trigger rerenders when new events arrive. Bridge the `BehaviorSubject` into component state.
498
+ ### `delete(idOrIds, mode?)`
281
499
 
282
- ```tsx
283
- function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) {
284
- const [items, setItems] = useState(() => collection.items.value)
500
+ Deletes one or many documents.
285
501
 
286
- useEffect(() => {
287
- const subscription = collection.items.subscribe(setItems)
288
- return () => subscription.unsubscribe()
289
- }, [collection])
502
+ ```ts
503
+ await todos.delete("todo-1")
504
+ await todos.delete(["todo-1", "todo-2"])
505
+ await todos.delete("todo-draft", "local-only")
506
+ ```
290
507
 
291
- return (
292
- <ul>
293
- {items.map((item) => (
294
- <li key={item.value.id}>{item.value.title}</li>
295
- ))}
296
- </ul>
297
- )
298
- }
508
+ ### `select(mode, id?)`
509
+
510
+ Maintains `selected` state and writes `_selected` back into documents with local-only updates.
511
+
512
+ ```ts
513
+ todos.select("all")
514
+ todos.select("none")
515
+ todos.select("toggle")
516
+ todos.select("toggle", "todo-1")
517
+ todos.select(true, "todo-1")
518
+ todos.select(false, "todo-1")
299
519
  ```
300
520
 
301
- ### Main methods
521
+ ### `trigger(action, payload?, transporter_id?)`
522
+
523
+ Calls transporter actions for this collection ref.
302
524
 
303
525
  ```ts
304
- type ActionMode = "server-first" | "local-first" | "local-only"
526
+ const result = await todos.trigger<{ count: number }>("archive-done", {
527
+ olderThan: Date.now() - 7 * 86400_000,
528
+ })
305
529
 
306
- query(filters: Partial<LivequeryFilters<T>>): Promise<void>
307
- debounceQuery(filters: Partial<LivequeryFilters<T>>): Promise<void>
308
- loadMore(): Promise<void>
309
- loadPrev(): Promise<void>
310
- loadAround(cursor: string): Promise<void>
311
- add<Input extends Partial<T> | Partial<T>[]>(payload: Input, mode?: ActionMode): Promise<Input extends Partial<T>[] ? T[] : T>
312
- update<Input extends Partial<T> | Partial<T>[]>(id: string, payload: Input, mode?: ActionMode): Promise<Input extends Partial<T>[] ? T[] : T>
313
- delete<Input extends string | string[]>(id: Input, mode?: ActionMode): Promise<Input extends string[] ? DocState<T>[] : DocState<T>>
314
- trigger<R>(action: string, payload?: Record<string, any>): Observable<{ data: R; error?: Error }> & PromiseLike<R>
315
- resetError(): void
316
- watch(check: (prev: T, next: T) => boolean): Observable<[DocState<T>, DocState<T>]>
530
+ todos.trigger("refresh-index").subscribe((value) => {
531
+ console.log(value)
532
+ })
317
533
  ```
318
534
 
319
- Notes about current behavior:
535
+ The returned value is an observable with a Promise-like `then()` method.
320
536
 
321
- - `query()` requires `initialize()` to have run first so the collection has a `ref` and watcher registration.
322
- - `debounceQuery()` only emits through the debounced path when `options.debounce` is truthy.
323
- - `loadMore()` uses `paging.next.cursor` as `:after`.
324
- - `loadPrev()` uses `paging.prev.cursor` as `:before`.
325
- - `loadAround()` currently sets both `:after` and `:before` to the provided cursor.
326
- - `add`, `update`, and `delete` accept a mode override. In the current implementation, omitted mode defaults to `"server-first"`.
327
- - In `"server-first"`, mutations are remote-first and do not rely on optimistic local writes.
328
- - `add(payload, "local-only")` stores the document in local storage only and never contacts any transporter. The document receives a `local:` prefixed id and is marked with `_local_only` internally.
329
- - Collection mutations preserve the input shape in TypeScript: pass one item and you get one result; pass an array and you get an array.
330
- - `mode: "local-only"` on the collection controls query behavior. To keep a mutation local-only, pass `"local-only"` explicitly to `add`, `update`, `delete`, `LivequeryDocument.update`, or `LivequeryDocument.del`.
537
+ ### `resetError()`
331
538
 
332
- ## `LivequeryDocument`
539
+ Clears the collection error subject.
540
+
541
+ ```ts
542
+ todos.resetError()
543
+ ```
333
544
 
334
- Each entry inside `collection.items` is a `LivequeryDocument`, which extends `BehaviorSubject<DocState<T>>`.
545
+ ### `watch(check)`
546
+
547
+ Watches pairwise document changes and emits when `check(prev, next)` returns `true`.
335
548
 
336
549
  ```ts
337
- class LivequeryDocument<T extends Doc> extends BehaviorSubject<DocState<T>> {
338
- update(data: Partial<T>, mode?: ActionMode): Promise<T | undefined>
339
- del(mode?: ActionMode): Promise<DocState<T> | undefined>
340
- trigger<R>(action: string, payload: Record<string, any>): Observable<{ data: R; error?: Error }> & PromiseLike<R>
341
- }
550
+ const doneSub = todos.watch((prev, next) => prev.done !== next.done).subscribe(([prev, next]) => {
551
+ console.log(prev.id, "done changed", prev.done, "=>", next.done)
552
+ })
553
+
554
+ doneSub.unsubscribe()
555
+ ```
556
+
557
+ ### `flush()`
558
+
559
+ Flushes storage through the client for this collection's `collection_ref`.
560
+
561
+ ```ts
562
+ await todos.flush()
342
563
  ```
343
564
 
344
- Example:
565
+ ## `LivequeryDocument`
566
+
567
+ Every item in `collection.items.value` is a `LivequeryDocument<T>`. It extends `BehaviorSubject<DocState<T>>`.
345
568
 
346
569
  ```ts
347
570
  const first = todos.items.value[0]
348
571
 
349
- first.subscribe((doc) => {
350
- console.log(doc.title, doc._updating)
572
+ first.subscribe((value) => {
573
+ console.log(value.title, value._updating)
351
574
  })
575
+ ```
576
+
577
+ ### `update(data, mode?)`
578
+
579
+ Updates the current document through its collection. The document id is added automatically.
352
580
 
581
+ ```ts
353
582
  await first.update({ done: true })
583
+ await first.update({ title: "Local edit" }, "local-only")
584
+ ```
585
+
586
+ ### `del(mode?)`
587
+
588
+ Deletes the current document through its collection.
589
+
590
+ ```ts
354
591
  await first.del()
592
+ await first.del("local-only")
593
+ ```
355
594
 
356
- // Explicit local-only mutation
357
- await first.update({ done: false }, "local-only")
595
+ ### `trigger(action, payload?)`
358
596
 
359
- // Observable style
360
- first.trigger("archive", { reason: "completed" }).subscribe()
597
+ Calls a collection trigger using the document's collection ref.
361
598
 
362
- // Promise-like style
363
- const archived = await first.trigger<{ archived: boolean }>("archive", { reason: "completed" })
364
- console.log(archived.archived)
599
+ ```ts
600
+ await first.trigger("archive", { reason: "completed" })
601
+ ```
602
+
603
+ ### `select(selected)`
604
+
605
+ Forwards selection changes to the parent collection.
606
+
607
+ ```ts
608
+ first.select("toggle")
609
+ first.select(true)
610
+ first.select(false)
365
611
  ```
366
612
 
367
613
  ## `LivequeryStorge`
368
614
 
369
- Local persistence adapters must implement:
615
+ Storage adapters provide local persistence and local filtering.
370
616
 
371
617
  ```ts
372
618
  type LivequeryStorge = {
@@ -378,27 +624,46 @@ type LivequeryStorge = {
378
624
  paging: LivequeryPaging
379
625
  }>
380
626
  get<T extends Doc>(ref: string, id: string): Promise<T | null>
381
- add<T extends Doc>(collection: string, document: Partial<DocState<T>>): Promise<T>
382
- update<T extends Doc>(collection: string, id: string, document: Record<string, any>): Promise<T | null>
383
- delete<T extends Doc>(collection: string, id: string): Promise<T | null>
627
+ add<T extends Doc>(collection: string, document: Partial<DocState<T>>): Promise<DocState<T>>
628
+ update<T extends Doc>(collection: string, id: string, document: Record<string, any>): Promise<DocState<T> | null>
629
+ delete<T extends Doc>(collection: string, id: string): Promise<DocState<T> | null>
384
630
  flush(): Promise<void>
385
631
  }
386
632
  ```
387
633
 
388
- The package ships with `LivequeryMemoryStorage`, an in-memory adapter useful for tests, demos, and ephemeral state.
634
+ Adapter guidance:
389
635
 
390
- ### `LivequeryMemoryStorage`
636
+ - `query()` should apply the same filter semantics as `filterDocs()` when possible.
637
+ - `get()` must return the full local document because local broadcast filtering reads it for `modified` events.
638
+ - `add()` should generate an id when one is missing.
639
+ - `update()` should merge patch fields into the stored document.
640
+ - `delete()` should return the deleted document or `null`.
641
+ - `flush()` currently clears all storage.
391
642
 
392
- The built-in adapter:
643
+ ## `LivequeryMemoryStorage`
644
+
645
+ The built-in in-memory adapter is useful for demos, tests, and ephemeral browser state.
646
+
647
+ ```ts
648
+ const storage = new LivequeryMemoryStorage()
649
+
650
+ await storage.add<Todo>("todos", {
651
+ title: "Local only",
652
+ done: false,
653
+ createdAt: Date.now(),
654
+ })
655
+
656
+ const page = await storage.query<Todo>("todos", {
657
+ "done:boolean": "false",
658
+ "createdAt:sort": "desc",
659
+ })
660
+ ```
393
661
 
394
- - stores documents in `Map<string, Map<string, Doc>>`
395
- - generates a local id with `local:${crypto.randomUUID()}` when `id` is missing
396
- - applies filters through the exported `filterDocs()` helper
397
- - supports nested sort keys such as `profile.createdAt:sort`
662
+ It stores documents in a `Map<string, Map<string, Doc>>`, generates ids with `uuidv7`, applies runtime filtering through `filterDocs()`, and supports nested path sorting such as `"author.profile.createdAt:sort"`.
398
663
 
399
664
  ## `LivequeryTransporter`
400
665
 
401
- Remote adapters must implement:
666
+ Transporters connect the client to remote systems.
402
667
 
403
668
  ```ts
404
669
  type LivequeryTransporter = {
@@ -410,27 +675,139 @@ type LivequeryTransporter = {
410
675
  }
411
676
  ```
412
677
 
413
- ### Query result shape
678
+ ### Query Streams
679
+
680
+ `query()` returns an observable because transporters can emit:
681
+
682
+ - initial query changes
683
+ - pagination updates
684
+ - summary updates
685
+ - later realtime changes
414
686
 
415
687
  ```ts
416
- type LivequeryQueryResult = {
417
- error: { code: string; message: string }
418
- changes: DataChangeEvent[]
419
- summary: Record<string, any>
420
- paging: LivequeryPaging
421
- metadata: Record<string, any>
422
- source: "query" | "action" | "realtime"
423
- loading?: "all" | "next" | "prev" | null
688
+ import { Observable } from "rxjs"
689
+
690
+ const apiTransporter: LivequeryTransporter = {
691
+ query(query) {
692
+ return new Observable((subscriber) => {
693
+ fetch(`/api/${query.ref}`)
694
+ .then((res) => res.json())
695
+ .then((documents: Todo[]) => {
696
+ subscriber.next({
697
+ changes: documents.map((doc) => ({
698
+ collection_ref: query.ref,
699
+ id: doc.id,
700
+ type: "added",
701
+ data: doc,
702
+ })),
703
+ paging: {
704
+ total: documents.length,
705
+ current: documents.length,
706
+ },
707
+ source: "query",
708
+ })
709
+ })
710
+ .catch((error) => {
711
+ subscriber.next({
712
+ error: {
713
+ code: "QUERY_FAILED",
714
+ message: String(error),
715
+ },
716
+ source: "query",
717
+ })
718
+ })
719
+ })
720
+ },
721
+ async add(ref, doc) {
722
+ const res = await fetch(`/api/${ref}`, {
723
+ method: "POST",
724
+ body: JSON.stringify(doc),
725
+ headers: { "content-type": "application/json" },
726
+ })
727
+ return res.json()
728
+ },
729
+ async update(ref, id, patch) {
730
+ const res = await fetch(`/api/${ref}/${id}`, {
731
+ method: "PATCH",
732
+ body: JSON.stringify(patch),
733
+ headers: { "content-type": "application/json" },
734
+ })
735
+ return res.json()
736
+ },
737
+ async delete(ref, id) {
738
+ const res = await fetch(`/api/${ref}/${id}`, {
739
+ method: "DELETE",
740
+ })
741
+ return res.json()
742
+ },
743
+ async trigger(action) {
744
+ const res = await fetch(`/api/${action.ref}:trigger`, {
745
+ method: "POST",
746
+ body: JSON.stringify(action),
747
+ headers: { "content-type": "application/json" },
748
+ })
749
+ return res.json()
750
+ },
424
751
  }
425
752
  ```
426
753
 
427
- Transporters can emit partial results. In practice, the most useful fields are `changes`, `paging`, `summary`, `metadata`, and `error`.
754
+ ## Query Modes
755
+
756
+ ### `server-first`
757
+
758
+ Transporters drive the query result. Collection state is built from streamed change events.
759
+
760
+ Use it when remote data is the source of truth and local cache is secondary.
761
+
762
+ ### `cache-first`
763
+
764
+ The first query can hydrate from storage, then transporters refresh in the background.
765
+
766
+ Use it when fast initial UI matters but remote sync should still run.
767
+
768
+ ### `local-first`
769
+
770
+ Storage serves the query immediately. Transporters refresh in the background. Remote query changes are written into storage and then broadcast back into matching local collections.
771
+
772
+ For `local-first`, the remote query path receives empty filters. Local filtering is enforced by storage query results and by broadcast filtering.
428
773
 
429
- ## Query Filters
774
+ ### `local-only`
430
775
 
431
- Filters are flat keys derived from the document type.
776
+ Queries resolve only from storage and skip transporters. Mutations stay local only when you explicitly call them with `mode: "local-only"`.
432
777
 
433
- ### Pagination keys
778
+ Use it for drafts, temporary UI state, offline-only collections, or local workspaces.
779
+
780
+ ```ts
781
+ const drafts = new LivequeryCollection<Todo>(client, {
782
+ mode: "local-only",
783
+ })
784
+
785
+ drafts.initialize("drafts")
786
+
787
+ await drafts.add(
788
+ { title: "Unpublished draft", done: false, createdAt: Date.now() },
789
+ "local-only"
790
+ )
791
+ ```
792
+
793
+ ## Broadcast Filtering
794
+
795
+ For `local-first` and `local-only` collection watchers, `LivequeryClient` filters broadcast events against the collection's current filters before delivering them:
796
+
797
+ - `added`: forwarded only when `event.data` matches filters.
798
+ - `modified`: reads the full document from storage and forwards `modified` only if the full document still matches filters.
799
+ - `modified` that no longer matches filters is converted to `removed` for that collection.
800
+ - `removed`: forwarded without filter checks.
801
+
802
+ Within one broadcast call, full-document reads are cached by `collection_ref/id` so multiple local collections do not repeatedly call storage for the same modified document.
803
+
804
+ Current limitation: if a document was not already present in a filtered collection and a later `modified` event makes it match, the client does not yet convert that `modified` into `added`. A later query will include it.
805
+
806
+ ## Filters
807
+
808
+ Filters are flat object keys derived from document fields.
809
+
810
+ ### Pagination Keys
434
811
 
435
812
  - `:limit`
436
813
  - `:before`
@@ -438,55 +815,150 @@ Filters are flat keys derived from the document type.
438
815
  - `:around`
439
816
  - `:page`
440
817
 
441
- ### Supported operators
818
+ ### Operators
442
819
 
443
- - `field:sort` with `"asc" | "desc"` for string and number fields
444
- - `field:gt`, `field:gte`, `field:lt`, `field:lte` for numeric fields
445
- - `field:eq-number` for numeric equality
446
- - `field` for string equality
447
- - `field:in`, `field:nin` for string or number membership
448
- - `field:include` for array containment
449
- - `field:boolean` with `"true" | "false" | "not-true" | "not-false"`
450
- - `field:like` for case-insensitive substring matching on strings
451
- - `field:null` with `"null-only" | "not-null"`
820
+ - `field`: strict equality
821
+ - `field:sort`: `"asc" | "desc"`
822
+ - `field:gt`, `field:gte`, `field:lt`, `field:lte`: numeric comparisons
823
+ - `field:eq-number`: numeric equality after `Number(value)`
824
+ - `field:in`, `field:nin`: membership for string or number values
825
+ - `field:include`: array contains value
826
+ - `field:boolean`: `"true" | "false" | "not-true" | "not-false"`
827
+ - `field:like`: case-insensitive substring check
828
+ - `field:null`: `"null-only" | "not-null"`
452
829
 
453
- Nested field paths are supported, for example `"profile.createdAt:sort"`.
830
+ Nested field paths are supported:
454
831
 
455
832
  ```ts
456
- await todos.query({
457
- ":limit": 20,
458
- "done:boolean": "false",
459
- "title:like": "milk",
460
- "createdAt:gte": 1714176000000,
833
+ await posts.query({
834
+ "author.id": "user-1",
835
+ "stats.views:gte": 100,
836
+ "published:boolean": "true",
837
+ "title:like": "livequery",
461
838
  "createdAt:sort": "desc",
462
839
  })
463
840
  ```
464
841
 
465
- ## Helper Exports
842
+ ## Helper Functions
843
+
844
+ ### `filterDocs(documents, filters)`
466
845
 
467
- ### `filterDocs()`
846
+ Filters an array with the same runtime semantics used by `LivequeryMemoryStorage`.
468
847
 
469
848
  ```ts
470
849
  import { filterDocs } from "@livequery/client"
471
850
 
472
- const visible = filterDocs(docs, {
851
+ const openTodos = filterDocs(todos, {
473
852
  "done:boolean": "false",
474
- "title:like": "milk",
475
853
  })
476
854
  ```
477
855
 
478
- ### `matchesAllFilters()`
856
+ ### `matchesAllFilters(doc, filters)`
857
+
858
+ Predicate helper for checking one document.
859
+
860
+ ```ts
861
+ import { matchesAllFilters } from "@livequery/client"
862
+
863
+ if (matchesAllFilters(todo, { "done:boolean": "false" })) {
864
+ console.log("todo is open")
865
+ }
866
+ ```
867
+
868
+ ## React Usage
869
+
870
+ Bridge `BehaviorSubject` values into React state.
871
+
872
+ ```tsx
873
+ import { useEffect, useMemo, useState } from "react"
874
+ import { LivequeryCollection, type DocState } from "@livequery/client"
875
+
876
+ function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) {
877
+ const [items, setItems] = useState(() => collection.items.value)
878
+ const [loading, setLoading] = useState(() => collection.loading.value)
879
+
880
+ useEffect(() => {
881
+ const sub = collection.items.subscribe(setItems)
882
+ const loadingSub = collection.loading.subscribe(setLoading)
883
+ return () => {
884
+ sub.unsubscribe()
885
+ loadingSub.unsubscribe()
886
+ }
887
+ }, [collection])
888
+
889
+ return (
890
+ <ul aria-busy={loading !== null}>
891
+ {items.map((item) => (
892
+ <li key={item.value.id}>
893
+ <label>
894
+ <input
895
+ type="checkbox"
896
+ checked={item.value.done}
897
+ onChange={() => item.update({ done: !item.value.done })}
898
+ />
899
+ {item.value.title}
900
+ {item.value._updating ? " Saving..." : null}
901
+ </label>
902
+ </li>
903
+ ))}
904
+ </ul>
905
+ )
906
+ }
907
+ ```
908
+
909
+ Do not read `collection.items.value` once during render and expect the UI to stay in sync. Subscribe or use a framework-specific adapter.
910
+
911
+ ## Common Usage Patterns
912
+
913
+ ### App-Level Client
914
+
915
+ Create one shared client per data boundary.
916
+
917
+ ```ts
918
+ export const livequery = new LivequeryClient({
919
+ storage: new LivequeryMemoryStorage(),
920
+ transporters: {
921
+ primary: apiTransporter,
922
+ },
923
+ })
924
+ ```
925
+
926
+ ### Collection Factory
927
+
928
+ ```ts
929
+ export function createTodoCollection() {
930
+ const collection = new LivequeryCollection<Todo>(livequery, {
931
+ mode: "cache-first",
932
+ filters: {
933
+ "createdAt:sort": "desc",
934
+ },
935
+ })
936
+ collection.initialize("todos")
937
+ return collection
938
+ }
939
+ ```
940
+
941
+ ### Document Ref
942
+
943
+ ```ts
944
+ const todo = new LivequeryCollection<Todo>(client, {
945
+ mode: "cache-first",
946
+ })
947
+
948
+ todo.initialize("todos/todo-1")
949
+ await todo.query({})
950
+ ```
479
951
 
480
- The helper module also exports `matchesAllFilters(doc, filters)` for direct predicate checks.
952
+ A document ref still exposes `items`; the matching document is represented as a one-item collection.
481
953
 
482
954
  ## Caveats
483
955
 
484
- - `initialize()` is browser-only because it exits early when `window` is unavailable.
485
- - The public storage interface name is intentionally spelled `LivequeryStorge`, matching the source.
486
- - Optimistic flags such as `_adding`, `_updating`, `_deleting`, and `_prev` are system-managed fields.
487
- - Transporter query streams are expected to emit incremental `changes`, not full snapshots.
488
- - `LivequeryCollection` declares a `metadata` subject but does not initialize it in the constructor, so transporter-emitted `metadata` is not safe to rely on yet.
489
- - `trigger()` supports both styles: subscribe as `Observable<{ data, error? }>` or await as a Promise-like value for ergonomic async usage.
956
+ - `LivequeryCollection.initialize()` is browser-only in the current implementation.
957
+ - Mutations default to `server-first` because the method parameter has that default. Pass `"local-first"` or `"local-only"` explicitly when needed.
958
+ - `LivequeryCollection` has no initialized `metadata` subject in the current constructor, so transporter `metadata` should not be considered reliable consumer state yet.
959
+ - `trigger()` returns an observable with a Promise-like `then()` method.
960
+ - Transporter streams should emit incremental changes. Do not send full snapshots as repeated `added` events unless the client can safely deduplicate by id.
961
+ - There is no dedicated test suite in this package yet. Use `bun run build` for the current validation baseline.
490
962
 
491
963
  ## Development
492
964
 
@@ -501,3 +973,4 @@ Available scripts:
501
973
  - `bun run build:types`
502
974
  - `bun run build`
503
975
  - `bun run build:watch`
976
+ - `bun run prepublishOnly`