@livequery/client 2.0.133 → 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 +710 -237
- package/dist/LivequeryClient.d.ts.map +1 -1
- package/dist/LivequeryClient.js +138 -53
- package/dist/LivequeryClient.js.map +1 -1
- package/dist/LivequeryCollection.d.ts.map +1 -1
- package/dist/LivequeryCollection.js +3 -3
- package/dist/LivequeryCollection.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,37 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Reactive local-first data primitives for browser clients.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
102
|
-
- `
|
|
103
|
-
- `
|
|
104
|
-
- `LivequeryStorge` is the local persistence contract.
|
|
105
|
-
- `
|
|
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(
|
|
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
|
-
|
|
133
|
-
|
|
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,
|
|
142
|
-
return { id, ...
|
|
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(
|
|
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
|
-
|
|
161
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
289
|
+
Lower-level delete entry point used by `LivequeryCollection.delete()`.
|
|
201
290
|
|
|
202
|
-
|
|
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
|
-
|
|
295
|
+
### `trigger(action)`
|
|
205
296
|
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
Use `collection.trigger()` for normal consumer code.
|
|
214
309
|
|
|
215
|
-
|
|
310
|
+
### `flush(collection_ref)`
|
|
216
311
|
|
|
217
|
-
|
|
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" | "
|
|
347
|
+
mode: "server-first" | "cache-first" | "local-first" | "local-only"
|
|
230
348
|
}
|
|
231
349
|
```
|
|
232
350
|
|
|
233
|
-
|
|
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
|
-
|
|
356
|
+
### Reactive Properties
|
|
236
357
|
|
|
237
358
|
```ts
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
415
|
+
searchTodos.initialize("todos")
|
|
416
|
+
await searchTodos.debounceQuery({ "title:like": "milk" })
|
|
246
417
|
```
|
|
247
418
|
|
|
248
|
-
|
|
419
|
+
### `sort(field, order)`
|
|
249
420
|
|
|
250
|
-
|
|
421
|
+
Sorts by a field or resets to insertion order.
|
|
251
422
|
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
473
|
+
The return shape follows the input shape: one payload returns one document; an array returns an array.
|
|
262
474
|
|
|
263
|
-
- `
|
|
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
|
-
|
|
477
|
+
### `update(payload, mode?)`
|
|
478
|
+
|
|
479
|
+
Updates one or many documents. Include `id` in every payload.
|
|
271
480
|
|
|
272
481
|
```ts
|
|
273
|
-
|
|
274
|
-
|
|
482
|
+
await todos.update({
|
|
483
|
+
id: "todo-1",
|
|
484
|
+
done: true,
|
|
275
485
|
})
|
|
276
486
|
|
|
277
|
-
|
|
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
|
-
|
|
498
|
+
### `delete(idOrIds, mode?)`
|
|
281
499
|
|
|
282
|
-
|
|
283
|
-
function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) {
|
|
284
|
-
const [items, setItems] = useState(() => collection.items.value)
|
|
500
|
+
Deletes one or many documents.
|
|
285
501
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
###
|
|
521
|
+
### `trigger(action, payload?, transporter_id?)`
|
|
522
|
+
|
|
523
|
+
Calls transporter actions for this collection ref.
|
|
302
524
|
|
|
303
525
|
```ts
|
|
304
|
-
|
|
526
|
+
const result = await todos.trigger<{ count: number }>("archive-done", {
|
|
527
|
+
olderThan: Date.now() - 7 * 86400_000,
|
|
528
|
+
})
|
|
305
529
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
535
|
+
The returned value is an observable with a Promise-like `then()` method.
|
|
320
536
|
|
|
321
|
-
|
|
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
|
-
|
|
539
|
+
Clears the collection error subject.
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
todos.resetError()
|
|
543
|
+
```
|
|
333
544
|
|
|
334
|
-
|
|
545
|
+
### `watch(check)`
|
|
546
|
+
|
|
547
|
+
Watches pairwise document changes and emits when `check(prev, next)` returns `true`.
|
|
335
548
|
|
|
336
549
|
```ts
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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((
|
|
350
|
-
console.log(
|
|
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
|
-
|
|
357
|
-
await first.update({ done: false }, "local-only")
|
|
595
|
+
### `trigger(action, payload?)`
|
|
358
596
|
|
|
359
|
-
|
|
360
|
-
first.trigger("archive", { reason: "completed" }).subscribe()
|
|
597
|
+
Calls a collection trigger using the document's collection ref.
|
|
361
598
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
+
Adapter guidance:
|
|
389
635
|
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
774
|
+
### `local-only`
|
|
430
775
|
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
818
|
+
### Operators
|
|
442
819
|
|
|
443
|
-
- `field
|
|
444
|
-
- `field:
|
|
445
|
-
- `field:
|
|
446
|
-
- `field
|
|
447
|
-
- `field:in`, `field:nin
|
|
448
|
-
- `field:include
|
|
449
|
-
- `field:boolean
|
|
450
|
-
- `field:like
|
|
451
|
-
- `field: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
|
|
830
|
+
Nested field paths are supported:
|
|
454
831
|
|
|
455
832
|
```ts
|
|
456
|
-
await
|
|
457
|
-
"
|
|
458
|
-
"
|
|
459
|
-
"
|
|
460
|
-
"
|
|
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
|
|
842
|
+
## Helper Functions
|
|
843
|
+
|
|
844
|
+
### `filterDocs(documents, filters)`
|
|
466
845
|
|
|
467
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
485
|
-
-
|
|
486
|
-
-
|
|
487
|
-
-
|
|
488
|
-
-
|
|
489
|
-
-
|
|
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`
|