@livequery/client 2.0.136 → 2.0.140
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 +196 -29
- package/dist/LivequeryClient.d.ts +7 -4
- package/dist/LivequeryClient.d.ts.map +1 -1
- package/dist/LivequeryClient.js +33 -32
- package/dist/LivequeryClient.js.map +1 -1
- package/dist/LivequeryCollection.d.ts +4 -0
- package/dist/LivequeryCollection.d.ts.map +1 -1
- package/dist/LivequeryCollection.js +51 -25
- package/dist/LivequeryCollection.js.map +1 -1
- package/dist/LivequeryMemoryStorage.d.ts +2 -2
- package/dist/LivequeryMemoryStorage.d.ts.map +1 -1
- package/dist/LivequeryMemoryStorage.js +5 -14
- package/dist/LivequeryMemoryStorage.js.map +1 -1
- package/dist/LivequeryStorage.d.ts +13 -0
- package/dist/LivequeryStorage.d.ts.map +1 -0
- package/dist/LivequeryStorage.js +2 -0
- package/dist/LivequeryStorage.js.map +1 -0
- package/dist/LivequeryStorge.d.ts +2 -12
- package/dist/LivequeryStorge.d.ts.map +1 -1
- package/dist/helpers/filterDocs.d.ts +8 -0
- package/dist/helpers/filterDocs.d.ts.map +1 -1
- package/dist/helpers/filterDocs.js +66 -49
- package/dist/helpers/filterDocs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ bun add @livequery/client @livequery/react rxjs
|
|
|
24
24
|
export * from "./LivequeryCollection"
|
|
25
25
|
export * from "./LivequeryClient"
|
|
26
26
|
export * from "./LivequeryMemoryStorage"
|
|
27
|
+
export * from "./LivequeryStorage"
|
|
27
28
|
export * from "./LivequeryStorge"
|
|
28
29
|
export * from "./LivequeryTransporter"
|
|
29
30
|
export * from "./types"
|
|
@@ -31,7 +32,7 @@ export * from "./helpers/filterDocs"
|
|
|
31
32
|
export * from "./LivequeryDocument"
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
The public storage interface is
|
|
35
|
+
The public storage interface is `LivequeryStorage`. The previous misspelled name `LivequeryStorge` remains exported as a backward-compatible alias.
|
|
35
36
|
|
|
36
37
|
## Mental Model
|
|
37
38
|
|
|
@@ -42,13 +43,13 @@ LivequeryCollection / LivequeryDocument
|
|
|
42
43
|
LivequeryClient
|
|
43
44
|
/ \
|
|
44
45
|
v v
|
|
45
|
-
|
|
46
|
+
LivequeryStorage LivequeryTransporter(s)
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
- `LivequeryClient` is the coordination core. It owns collection registrations, query orchestration, transporter fan-out, local storage writes, broadcast delivery, and optimistic mutation reconciliation.
|
|
49
50
|
- `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
51
|
- `LivequeryDocument<T>` wraps one document in a `BehaviorSubject` and forwards `update`, `del`, `trigger`, and `select` calls to its collection.
|
|
51
|
-
- `
|
|
52
|
+
- `LivequeryStorage` is the local persistence contract used by the client. `LivequeryStorge` is a backward-compatible alias.
|
|
52
53
|
- `LivequeryMemoryStorage` is the in-memory reference storage adapter.
|
|
53
54
|
- `LivequeryTransporter` is the remote sync/action contract.
|
|
54
55
|
|
|
@@ -143,7 +144,7 @@ const subscription = todos.items.subscribe((items) => {
|
|
|
143
144
|
|
|
144
145
|
await todos.query({
|
|
145
146
|
":limit": 20,
|
|
146
|
-
"done:boolean": "false",
|
|
147
|
+
"done:eq-boolean": "false",
|
|
147
148
|
"createdAt:sort": "desc",
|
|
148
149
|
})
|
|
149
150
|
|
|
@@ -210,6 +211,22 @@ type DocState<T extends Doc> = T & {
|
|
|
210
211
|
|
|
211
212
|
Do not strip `_adding`, `_updating`, `_deleting`, or error fields if your UI needs to show mutation progress or failure state.
|
|
212
213
|
|
|
214
|
+
Field reference:
|
|
215
|
+
|
|
216
|
+
| Field | Set when | Meaning |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `_adding` | `local-first` / `local-only` add in progress | Document is being created on the server |
|
|
219
|
+
| `_adding_error` | Server add rejected | Error from the failed transporter call |
|
|
220
|
+
| `_updating` | `local-first` update in progress | Document is being synced to the server |
|
|
221
|
+
| `_updating_error` | Server update rejected | Error from the failed transporter call |
|
|
222
|
+
| `_deleting` | `local-first` delete in progress | Document is pending deletion on the server |
|
|
223
|
+
| `_deleting_error` | Server delete rejected | Error from the failed transporter call |
|
|
224
|
+
| `_local_only` | `local-only` add | Document was created locally and never sent to the server |
|
|
225
|
+
| `_prev` | `local-first` update pending | Fields that existed before the local update — used to push only changed fields to the server |
|
|
226
|
+
| `_selected` | `select()` called | Whether the document is currently selected |
|
|
227
|
+
| `_index` | Assigned on insert | Stable insertion order used for sort reset |
|
|
228
|
+
| `_remotes` | Transporter-specific | Optional metadata from transporters; not used by the client core |
|
|
229
|
+
|
|
213
230
|
### `DataChangeEvent`
|
|
214
231
|
|
|
215
232
|
Transporter query streams and internal broadcasts use incremental change events:
|
|
@@ -247,7 +264,7 @@ new LivequeryClient({
|
|
|
247
264
|
})
|
|
248
265
|
```
|
|
249
266
|
|
|
250
|
-
- `storage`: a `
|
|
267
|
+
- `storage`: a `LivequeryStorage` adapter.
|
|
251
268
|
- `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
269
|
|
|
253
270
|
### `watch(ref, collection_id, mode)`
|
|
@@ -264,7 +281,7 @@ Lower-level query entry point used by `LivequeryCollection.query()`.
|
|
|
264
281
|
await client.query<Todo>({
|
|
265
282
|
ref: "todos",
|
|
266
283
|
collection_id: todos.id,
|
|
267
|
-
filters: { "done:boolean": "false" },
|
|
284
|
+
filters: { "done:eq-boolean": "false" },
|
|
268
285
|
})
|
|
269
286
|
```
|
|
270
287
|
|
|
@@ -331,7 +348,7 @@ const todos = new LivequeryCollection<Todo>(client, {
|
|
|
331
348
|
lazy: false,
|
|
332
349
|
debounce: 250,
|
|
333
350
|
filters: {
|
|
334
|
-
"done:boolean": "false",
|
|
351
|
+
"done:eq-boolean": "false",
|
|
335
352
|
"createdAt:sort": "desc",
|
|
336
353
|
},
|
|
337
354
|
})
|
|
@@ -345,6 +362,10 @@ type LivequeryCollectionOptions<T extends Doc> = {
|
|
|
345
362
|
lazy: boolean
|
|
346
363
|
debounce: number
|
|
347
364
|
mode: "server-first" | "cache-first" | "local-first" | "local-only"
|
|
365
|
+
seed: {
|
|
366
|
+
data: T[]
|
|
367
|
+
persist: boolean
|
|
368
|
+
}
|
|
348
369
|
}
|
|
349
370
|
```
|
|
350
371
|
|
|
@@ -352,6 +373,37 @@ type LivequeryCollectionOptions<T extends Doc> = {
|
|
|
352
373
|
- `lazy`: when not `true`, `initialize()` schedules an automatic query with current filters.
|
|
353
374
|
- `debounce`: enables `debounceQuery()`.
|
|
354
375
|
- `mode`: controls query behavior. Mutation methods still default to `server-first` unless you pass a mode override.
|
|
376
|
+
- `seed`: optional initial data. `seed.data` is an array of documents loaded into `items` before any query runs. `seed.persist: false` populates items in memory only — storage is not written. `seed.persist: true` writes the seed to storage before the first query, so a `cache-first` or `local-first` query can read from it immediately.
|
|
377
|
+
|
|
378
|
+
### `seed`
|
|
379
|
+
|
|
380
|
+
Pre-populate a collection with data before any query runs. Useful for hardcoded defaults, SSR-hydrated data, or offline stubs.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
const todos = new LivequeryCollection<Todo>(client, {
|
|
384
|
+
mode: "cache-first",
|
|
385
|
+
seed: {
|
|
386
|
+
data: [
|
|
387
|
+
{ id: "1", title: "Buy milk", done: false, createdAt: Date.now() },
|
|
388
|
+
],
|
|
389
|
+
persist: true,
|
|
390
|
+
},
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
todos.initialize("todos")
|
|
394
|
+
// items.value already has the seeded document before the first query
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`persist: false` — items are loaded into `items` immediately in the constructor. Storage is never written. The seed disappears after a query replaces items.
|
|
398
|
+
|
|
399
|
+
`persist: true` — seed data is written to storage before the first auto-query runs. This lets a `cache-first` query read the seed from storage on the first render.
|
|
400
|
+
|
|
401
|
+
Rules:
|
|
402
|
+
|
|
403
|
+
- Seed documents must include `id`.
|
|
404
|
+
- When `persist: true` and `lazy: false`, the client calls `seedToStorage()` then starts the auto-query. The auto-query may overwrite seed items when the transporter responds.
|
|
405
|
+
- When `persist: false`, seed items are available immediately in `items.value` from the constructor but are replaced on the first `query()` call.
|
|
406
|
+
- `seed` has no effect on the mode behavior. The collection still uses the configured mode for queries.
|
|
355
407
|
|
|
356
408
|
### Reactive Properties
|
|
357
409
|
|
|
@@ -397,7 +449,7 @@ Runs a query and replaces current `items` when cached/local documents are return
|
|
|
397
449
|
```ts
|
|
398
450
|
await todos.query({
|
|
399
451
|
":limit": 20,
|
|
400
|
-
"done:boolean": "false",
|
|
452
|
+
"done:eq-boolean": "false",
|
|
401
453
|
"createdAt:sort": "desc",
|
|
402
454
|
})
|
|
403
455
|
```
|
|
@@ -446,7 +498,7 @@ await todos.loadAround("cursor-123")
|
|
|
446
498
|
|
|
447
499
|
- `loadMore()` adds `:after`.
|
|
448
500
|
- `loadPrev()` adds `:before`.
|
|
449
|
-
- `loadAround(cursor)`
|
|
501
|
+
- `loadAround(cursor)` loads a page centered on the given cursor. It sets both `:after` and `:before` to the same cursor value — the backend decides what "around a cursor" means for that collection.
|
|
450
502
|
|
|
451
503
|
### `add(payload, mode?)`
|
|
452
504
|
|
|
@@ -472,7 +524,42 @@ const many = await todos.add([
|
|
|
472
524
|
|
|
473
525
|
The return shape follows the input shape: one payload returns one document; an array returns an array.
|
|
474
526
|
|
|
475
|
-
|
|
527
|
+
The default mutation mode follows `#defaultMode()`:
|
|
528
|
+
|
|
529
|
+
| Collection `mode` | Mutation default |
|
|
530
|
+
|---|---|
|
|
531
|
+
| `server-first` | `server-first` |
|
|
532
|
+
| `cache-first` | `server-first` |
|
|
533
|
+
| `local-first` | `local-first` |
|
|
534
|
+
| `local-only` | `local-only` |
|
|
535
|
+
| not set | `server-first` |
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
// local-only collection — mutations also default to local-only
|
|
539
|
+
const drafts = new LivequeryCollection<Todo>(client, { mode: "local-only" })
|
|
540
|
+
drafts.initialize("drafts")
|
|
541
|
+
await drafts.add({ title: "Draft", done: false, createdAt: Date.now() })
|
|
542
|
+
// ✓ stored locally, no server call
|
|
543
|
+
|
|
544
|
+
// local-first collection — optimistic local write + background server sync
|
|
545
|
+
const notes = new LivequeryCollection<Todo>(client, { mode: "local-first" })
|
|
546
|
+
notes.initialize("notes")
|
|
547
|
+
await notes.add({ title: "Note", done: false, createdAt: Date.now() })
|
|
548
|
+
// ✓ appears immediately, syncs to server in background
|
|
549
|
+
|
|
550
|
+
// cache-first collection — mutations go server-first by default
|
|
551
|
+
const posts = new LivequeryCollection<Post>(client, { mode: "cache-first" })
|
|
552
|
+
posts.initialize("posts")
|
|
553
|
+
await posts.add({ title: "Post", done: false, createdAt: Date.now() })
|
|
554
|
+
// ✓ blocks until server responds (server-first default)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Pass an explicit mode to override the default on a per-call basis:
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
await drafts.add({ title: "Force server", done: false, createdAt: Date.now() }, "server-first")
|
|
561
|
+
await posts.add({ title: "Local draft", done: false, createdAt: Date.now() }, "local-only")
|
|
562
|
+
```
|
|
476
563
|
|
|
477
564
|
### `update(payload, mode?)`
|
|
478
565
|
|
|
@@ -546,14 +633,32 @@ todos.resetError()
|
|
|
546
633
|
|
|
547
634
|
Watches pairwise document changes and emits when `check(prev, next)` returns `true`.
|
|
548
635
|
|
|
636
|
+
Returns `Observable<[DocState<T>, DocState<T>]>` — each emission is a `[previous, current]` pair for the document that changed.
|
|
637
|
+
|
|
549
638
|
```ts
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
639
|
+
// Watch when the `done` field changes on any todo
|
|
640
|
+
const doneSub = todos.watch((prev, next) => prev.done !== next.done)
|
|
641
|
+
.subscribe(([prev, next]) => {
|
|
642
|
+
console.log(next.id, "done changed:", prev.done, "→", next.done)
|
|
643
|
+
})
|
|
553
644
|
|
|
554
645
|
doneSub.unsubscribe()
|
|
646
|
+
|
|
647
|
+
// Watch when any field changes
|
|
648
|
+
const anySub = todos.watch((prev, next) => prev !== next).subscribe(([prev, next]) => {
|
|
649
|
+
console.log("document changed:", next.id)
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
// Watch for optimistic mutation completion
|
|
653
|
+
const saveSub = todos.watch(
|
|
654
|
+
(prev, next) => prev._updating === true && next._updating == null
|
|
655
|
+
).subscribe(([, next]) => {
|
|
656
|
+
console.log("save confirmed for:", next.id)
|
|
657
|
+
})
|
|
555
658
|
```
|
|
556
659
|
|
|
660
|
+
`check` is called for every field emission of every document in `items`. Keep it fast. Avoid closures that capture large objects.
|
|
661
|
+
|
|
557
662
|
### `flush()`
|
|
558
663
|
|
|
559
664
|
Flushes storage through the client for this collection's `collection_ref`.
|
|
@@ -610,12 +715,12 @@ first.select(true)
|
|
|
610
715
|
first.select(false)
|
|
611
716
|
```
|
|
612
717
|
|
|
613
|
-
## `
|
|
718
|
+
## `LivequeryStorage`
|
|
614
719
|
|
|
615
720
|
Storage adapters provide local persistence and local filtering.
|
|
616
721
|
|
|
617
722
|
```ts
|
|
618
|
-
type
|
|
723
|
+
type LivequeryStorage = {
|
|
619
724
|
query<T extends Doc>(
|
|
620
725
|
collection: string,
|
|
621
726
|
filters?: Record<string, any>
|
|
@@ -631,6 +736,8 @@ type LivequeryStorge = {
|
|
|
631
736
|
}
|
|
632
737
|
```
|
|
633
738
|
|
|
739
|
+
`LivequeryStorge` is still exported as an alias for existing consumers.
|
|
740
|
+
|
|
634
741
|
Adapter guidance:
|
|
635
742
|
|
|
636
743
|
- `query()` should apply the same filter semantics as `filterDocs()` when possible.
|
|
@@ -654,7 +761,7 @@ await storage.add<Todo>("todos", {
|
|
|
654
761
|
})
|
|
655
762
|
|
|
656
763
|
const page = await storage.query<Todo>("todos", {
|
|
657
|
-
"done:boolean": "false",
|
|
764
|
+
"done:eq-boolean": "false",
|
|
658
765
|
"createdAt:sort": "desc",
|
|
659
766
|
})
|
|
660
767
|
```
|
|
@@ -753,27 +860,38 @@ const apiTransporter: LivequeryTransporter = {
|
|
|
753
860
|
|
|
754
861
|
## Query Modes
|
|
755
862
|
|
|
863
|
+
> For detailed data flow diagrams, mutation behavior per mode, and common mistakes, see [docs/modes.md](./docs/modes.md).
|
|
864
|
+
|
|
865
|
+
| Mode | Query reads from | Transporter called? | Mutation default |
|
|
866
|
+
|------|------|------|------|
|
|
867
|
+
| `server-first` | Transporter | Yes, always | `server-first` |
|
|
868
|
+
| `cache-first` | Storage first, then transporter | Yes, in background | `server-first` |
|
|
869
|
+
| `local-first` | Storage immediately | Yes, full sync in background | `local-first` |
|
|
870
|
+
| `local-only` | Storage only | No | `local-only` |
|
|
871
|
+
|
|
756
872
|
### `server-first`
|
|
757
873
|
|
|
758
|
-
Transporters drive the query result. Collection state is built from streamed change events.
|
|
874
|
+
Transporters drive the query result. Collection state is built from streamed change events. Items are delivered asynchronously through the watch stream, not from the `query()` return value.
|
|
759
875
|
|
|
760
876
|
Use it when remote data is the source of truth and local cache is secondary.
|
|
761
877
|
|
|
762
878
|
### `cache-first`
|
|
763
879
|
|
|
764
|
-
The first query
|
|
880
|
+
The first query hydrates from local storage instantly, then transporters refresh in the background. For pagination queries (`:before` / `:after`), cache is skipped and the transporter is called directly. Mutations default to `server-first`.
|
|
765
881
|
|
|
766
882
|
Use it when fast initial UI matters but remote sync should still run.
|
|
767
883
|
|
|
768
884
|
### `local-first`
|
|
769
885
|
|
|
770
|
-
Storage serves the query immediately.
|
|
886
|
+
Storage serves the query immediately. The transporter syncs the full collection in the background by paginating all pages and writing results to storage. Remote changes are rebroadcast to local collections filtered by their current filters.
|
|
771
887
|
|
|
772
|
-
|
|
888
|
+
The server receives **empty filters** — local filtering happens during broadcast, not on the server.
|
|
889
|
+
|
|
890
|
+
Avoid this mode for large unbounded datasets; it attempts to sync every document locally.
|
|
773
891
|
|
|
774
892
|
### `local-only`
|
|
775
893
|
|
|
776
|
-
Queries resolve only from storage
|
|
894
|
+
Queries resolve only from storage. Transporters are never called. No loading state is emitted. Mutations stay local when explicitly called with `mode: "local-only"`.
|
|
777
895
|
|
|
778
896
|
Use it for drafts, temporary UI state, offline-only collections, or local workspaces.
|
|
779
897
|
|
|
@@ -821,11 +939,13 @@ Filters are flat object keys derived from document fields.
|
|
|
821
939
|
- `field:sort`: `"asc" | "desc"`
|
|
822
940
|
- `field:gt`, `field:gte`, `field:lt`, `field:lte`: numeric comparisons
|
|
823
941
|
- `field:eq-number`: numeric equality after `Number(value)`
|
|
942
|
+
- `field:neq-number`: numeric inequality after `Number(value)`
|
|
824
943
|
- `field:in`, `field:nin`: membership for string or number values
|
|
825
|
-
- `field:
|
|
826
|
-
- `field:boolean
|
|
827
|
-
- `field:
|
|
828
|
-
- `field:
|
|
944
|
+
- `field:ne`: inequality
|
|
945
|
+
- `field:eq-boolean`, `field:neq-boolean`: boolean equality or inequality
|
|
946
|
+
- `field:eq-null`, `field:neq-null`: null equality or inequality
|
|
947
|
+
- `field:eq-oid`, `field:neq-oid`: ObjectId string equality or inequality for MongoDB-backed datasources
|
|
948
|
+
- `field:like`: regular expression match
|
|
829
949
|
|
|
830
950
|
Nested field paths are supported:
|
|
831
951
|
|
|
@@ -833,7 +953,7 @@ Nested field paths are supported:
|
|
|
833
953
|
await posts.query({
|
|
834
954
|
"author.id": "user-1",
|
|
835
955
|
"stats.views:gte": 100,
|
|
836
|
-
"published:boolean": "true",
|
|
956
|
+
"published:eq-boolean": "true",
|
|
837
957
|
"title:like": "livequery",
|
|
838
958
|
"createdAt:sort": "desc",
|
|
839
959
|
})
|
|
@@ -849,7 +969,7 @@ Filters an array with the same runtime semantics used by `LivequeryMemoryStorage
|
|
|
849
969
|
import { filterDocs } from "@livequery/client"
|
|
850
970
|
|
|
851
971
|
const openTodos = filterDocs(todos, {
|
|
852
|
-
"done:boolean": "false",
|
|
972
|
+
"done:eq-boolean": "false",
|
|
853
973
|
})
|
|
854
974
|
```
|
|
855
975
|
|
|
@@ -860,11 +980,57 @@ Predicate helper for checking one document.
|
|
|
860
980
|
```ts
|
|
861
981
|
import { matchesAllFilters } from "@livequery/client"
|
|
862
982
|
|
|
863
|
-
if (matchesAllFilters(todo, { "done:boolean": "false" })) {
|
|
983
|
+
if (matchesAllFilters(todo, { "done:eq-boolean": "false" })) {
|
|
864
984
|
console.log("todo is open")
|
|
865
985
|
}
|
|
866
986
|
```
|
|
867
987
|
|
|
988
|
+
### `parseFilters(filters)`
|
|
989
|
+
|
|
990
|
+
Pre-parses a filter object into a `ParsedFilter[]` array. Call this once per query rather than calling `matchesAllFilters` in a tight loop.
|
|
991
|
+
|
|
992
|
+
```ts
|
|
993
|
+
import { parseFilters, matchesParsedFilters } from "@livequery/client"
|
|
994
|
+
|
|
995
|
+
const filters = { "done:eq-boolean": "false", "createdAt:sort": "desc" }
|
|
996
|
+
const parsed = parseFilters(filters)
|
|
997
|
+
|
|
998
|
+
// Efficient: parse once, match many
|
|
999
|
+
const openTodos = todos.filter(doc => matchesParsedFilters(doc, parsed))
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
Pagination keys (`:limit`, `:before`, `:after`, `:around`, `:page`) and sort keys (`:sort` suffix) are excluded from the returned array.
|
|
1003
|
+
|
|
1004
|
+
### `matchesParsedFilters(doc, parsedFilters)`
|
|
1005
|
+
|
|
1006
|
+
Matches one document against a pre-parsed `ParsedFilter[]`. Use together with `parseFilters()` when checking many documents against the same filters.
|
|
1007
|
+
|
|
1008
|
+
```ts
|
|
1009
|
+
const parsed = parseFilters({ "status": "active", "score:gte": 10 })
|
|
1010
|
+
|
|
1011
|
+
for (const doc of largeList) {
|
|
1012
|
+
if (matchesParsedFilters(doc, parsed)) {
|
|
1013
|
+
// ...
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
### `getByPath(obj, path)`
|
|
1019
|
+
|
|
1020
|
+
Reads a value from a nested object using dot-notation path. Returns `undefined` when any segment is missing.
|
|
1021
|
+
|
|
1022
|
+
```ts
|
|
1023
|
+
import { getByPath } from "@livequery/client"
|
|
1024
|
+
|
|
1025
|
+
const doc = { author: { profile: { name: "Ada" } } }
|
|
1026
|
+
|
|
1027
|
+
getByPath(doc, "author.profile.name") // "Ada"
|
|
1028
|
+
getByPath(doc, "author.missing.field") // undefined
|
|
1029
|
+
getByPath(doc, "title") // undefined (not present)
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
Used internally by filter evaluation and storage sorting. Available as a public export for custom storage adapters.
|
|
1033
|
+
|
|
868
1034
|
## React Usage
|
|
869
1035
|
|
|
870
1036
|
Bridge `BehaviorSubject` values into React state.
|
|
@@ -955,10 +1121,11 @@ A document ref still exposes `items`; the matching document is represented as a
|
|
|
955
1121
|
|
|
956
1122
|
- `LivequeryCollection.initialize()` is browser-only in the current implementation.
|
|
957
1123
|
- Mutations default to `server-first` because the method parameter has that default. Pass `"local-first"` or `"local-only"` explicitly when needed.
|
|
1124
|
+
- `ConflictResolverFunction` is exported as a TypeScript type but is not wired into `LivequeryClient`. It documents the intended shape for future conflict resolution support. Do not pass it to the client — there is currently no parameter that accepts it.
|
|
958
1125
|
- `LivequeryCollection` has no initialized `metadata` subject in the current constructor, so transporter `metadata` should not be considered reliable consumer state yet.
|
|
959
1126
|
- `trigger()` returns an observable with a Promise-like `then()` method.
|
|
960
1127
|
- Transporter streams should emit incremental changes. Do not send full snapshots as repeated `added` events unless the client can safely deduplicate by id.
|
|
961
|
-
-
|
|
1128
|
+
- Run `bun test` to execute the test suite. It covers collection behavior, seed loading, mutation mode defaults, query error propagation, filter parsing, and sort stability.
|
|
962
1129
|
|
|
963
1130
|
## Development
|
|
964
1131
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Observable, Subject } from "rxjs";
|
|
2
|
-
import type {
|
|
2
|
+
import type { LivequeryStorage } from "./LivequeryStorage.js";
|
|
3
3
|
import type { LivequeryQueryResult, LivequeryTransporter } from "./LivequeryTransporter.js";
|
|
4
4
|
import type { DataChangeEvent, LivequeryAction, Doc, LivequeryQueryParams, DocState, LivequeryFilters, RealtimeChangeSource, ParitalDocState } from "./types.js";
|
|
5
|
+
import { type ParsedFilter } from "./helpers/filterDocs.js";
|
|
5
6
|
export type LivequeryClientOptions = {
|
|
6
7
|
transporters: Record<string, LivequeryTransporter>;
|
|
7
|
-
storage:
|
|
8
|
+
storage: LivequeryStorage;
|
|
8
9
|
};
|
|
9
10
|
export type LivequeryLoadingState = null | 'next' | 'prev' | 'all';
|
|
10
11
|
export type SyncRequest = DataChangeEvent & {
|
|
@@ -21,7 +22,7 @@ export type ConflictResolverFunction = <T extends Doc>(e: {
|
|
|
21
22
|
document: T;
|
|
22
23
|
};
|
|
23
24
|
export type LivequeryClientConfig = {
|
|
24
|
-
storage:
|
|
25
|
+
storage: LivequeryStorage;
|
|
25
26
|
transporters: Record<string, LivequeryTransporter>;
|
|
26
27
|
};
|
|
27
28
|
export type ActionMode = 'server-first' | 'local-first' | 'local-only';
|
|
@@ -34,6 +35,7 @@ export type CollectionMetadata = {
|
|
|
34
35
|
collection_ref: string;
|
|
35
36
|
mode: 'server-first' | 'local-first' | 'cache-first' | 'local-only';
|
|
36
37
|
filters: Partial<LivequeryFilters<any>>;
|
|
38
|
+
parsedFilters: ParsedFilter[];
|
|
37
39
|
};
|
|
38
40
|
export declare class LivequeryClient {
|
|
39
41
|
#private;
|
|
@@ -52,8 +54,9 @@ export declare class LivequeryClient {
|
|
|
52
54
|
} | undefined>;
|
|
53
55
|
add<T extends Doc>(collection_ref: string, documents: Partial<DocState<T>>[], mode: ActionMode): Promise<DocState<T>[]>;
|
|
54
56
|
update<T extends Doc>(collection_ref: string, documents: ParitalDocState<T>[], mode: ActionMode): Promise<DocState<T>[]>;
|
|
55
|
-
delete<T extends Doc>(collection_ref: string, ids: string[], mode: ActionMode): Promise<
|
|
57
|
+
delete<T extends Doc>(collection_ref: string, ids: string[], mode: ActionMode): Promise<T[]>;
|
|
56
58
|
trigger<Response>(action: LivequeryAction): Observable<Response>;
|
|
59
|
+
seedToStorage<T extends Doc>(collection_ref: string, docs: T[]): Promise<void>;
|
|
57
60
|
flush(collection_ref: string): Promise<void>;
|
|
58
61
|
destroy(): void;
|
|
59
62
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LivequeryClient.d.ts","sourceRoot":"","sources":["../src/LivequeryClient.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"LivequeryClient.d.ts","sourceRoot":"","sources":["../src/LivequeryClient.ts"],"names":[],"mappings":"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"}
|
package/dist/LivequeryClient.js
CHANGED
|
@@ -50,10 +50,10 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
50
50
|
var e = new Error(message);
|
|
51
51
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
52
|
});
|
|
53
|
-
import { defer, EMPTY, expand, filter, finalize, forkJoin, from, groupBy,
|
|
53
|
+
import { defer, EMPTY, expand, filter, finalize, forkJoin, from, groupBy, map, merge, mergeMap, Observable, of, scan, shareReplay, Subject, Subscription, switchMap, takeUntil, takeWhile, tap } from "rxjs";
|
|
54
54
|
import { tryCatch } from "./helpers/tryCatch.js";
|
|
55
55
|
import { whenCompleted } from "./helpers/whenCompleted.js";
|
|
56
|
-
import {
|
|
56
|
+
import { matchesParsedFilters, parseFilters } from "./helpers/filterDocs.js";
|
|
57
57
|
import { useDispose } from "./helpers/useDispose.js";
|
|
58
58
|
import { uuidv7 } from 'uuidv7';
|
|
59
59
|
export class LivequeryClient {
|
|
@@ -104,7 +104,7 @@ export class LivequeryClient {
|
|
|
104
104
|
this.#running = merge(
|
|
105
105
|
// Server queries
|
|
106
106
|
this.#queries$.pipe(filter(req => req.collection.mode == 'server-first' || req.collection.mode == 'cache-first'), mergeMap(e => {
|
|
107
|
-
const deduplicate_key = `${e.collection.collection_id}:${JSON.stringify(e.filters)}`;
|
|
107
|
+
const deduplicate_key = `${e.collection.collection_id}:${JSON.stringify(e.filters, Object.keys(e.filters || {}).sort())}`;
|
|
108
108
|
const before = e.filters?.[':before'];
|
|
109
109
|
const after = e.filters?.[':after'];
|
|
110
110
|
const around = e.filters?.[':around'];
|
|
@@ -156,7 +156,8 @@ export class LivequeryClient {
|
|
|
156
156
|
collection_id,
|
|
157
157
|
collection_ref,
|
|
158
158
|
mode,
|
|
159
|
-
filters: {}
|
|
159
|
+
filters: {},
|
|
160
|
+
parsedFilters: []
|
|
160
161
|
});
|
|
161
162
|
return data$.pipe(finalize(() => {
|
|
162
163
|
this.#collections.delete(collection_id);
|
|
@@ -185,8 +186,9 @@ export class LivequeryClient {
|
|
|
185
186
|
filters: collection.mode == 'local-first' ? {} : req.filters,
|
|
186
187
|
collection
|
|
187
188
|
}));
|
|
188
|
-
// If collection
|
|
189
|
+
// If collection
|
|
189
190
|
collection.filters = req.filters || {};
|
|
191
|
+
collection.parsedFilters = parseFilters(collection.filters);
|
|
190
192
|
if (collection.mode == 'local-first') {
|
|
191
193
|
return await this.config.storage.query(req.ref, req.filters);
|
|
192
194
|
}
|
|
@@ -218,14 +220,14 @@ export class LivequeryClient {
|
|
|
218
220
|
continue;
|
|
219
221
|
}
|
|
220
222
|
if (event.type == 'added') {
|
|
221
|
-
event.data &&
|
|
223
|
+
event.data && matchesParsedFilters(event.data, collection.parsedFilters) && changes.push(event);
|
|
222
224
|
continue;
|
|
223
225
|
}
|
|
224
226
|
const cache_key = `${event.collection_ref}/${event.id}`;
|
|
225
227
|
const cached = docs.get(cache_key) || this.config.storage.get(collection.collection_ref, event.id);
|
|
226
228
|
docs.set(cache_key, cached);
|
|
227
229
|
const doc = await cached;
|
|
228
|
-
if (doc &&
|
|
230
|
+
if (doc && matchesParsedFilters(doc, collection.parsedFilters)) {
|
|
229
231
|
changes.push(event);
|
|
230
232
|
continue;
|
|
231
233
|
}
|
|
@@ -238,6 +240,8 @@ export class LivequeryClient {
|
|
|
238
240
|
return changes;
|
|
239
241
|
}
|
|
240
242
|
async #broadcast(collection_ref, from, events) {
|
|
243
|
+
if (events.length === 0)
|
|
244
|
+
return;
|
|
241
245
|
const collections = this.#refs.get(collection_ref) || new Set();
|
|
242
246
|
const docs = new Map();
|
|
243
247
|
for (const collection_id of collections) {
|
|
@@ -272,13 +276,13 @@ export class LivequeryClient {
|
|
|
272
276
|
});
|
|
273
277
|
}
|
|
274
278
|
}
|
|
275
|
-
#push(collection_ref, docs, server_first) {
|
|
276
|
-
|
|
279
|
+
async #push(collection_ref, docs, server_first) {
|
|
280
|
+
const results = await Promise.all(docs.flatMap(doc => Object.entries(this.config.transporters).map(async ([tid, transporter]) => {
|
|
277
281
|
const id = doc.id;
|
|
278
282
|
if (String(id).startsWith('local:')) {
|
|
279
283
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
280
284
|
try {
|
|
281
|
-
// lock by collection_ref
|
|
285
|
+
// lock by collection_ref
|
|
282
286
|
const o = new Subject();
|
|
283
287
|
this.#adding.set(collection_ref, o);
|
|
284
288
|
const $ = __addDisposableResource(env_1, useDispose(() => {
|
|
@@ -289,7 +293,7 @@ export class LivequeryClient {
|
|
|
289
293
|
const [e, data] = await tryCatch(() => transporter.add(collection_ref, doc), tid);
|
|
290
294
|
if (e && server_first)
|
|
291
295
|
throw e;
|
|
292
|
-
// unlock
|
|
296
|
+
// unlock
|
|
293
297
|
const fnd = {
|
|
294
298
|
...data,
|
|
295
299
|
_adding: undefined,
|
|
@@ -312,7 +316,7 @@ export class LivequeryClient {
|
|
|
312
316
|
__disposeResources(env_1);
|
|
313
317
|
}
|
|
314
318
|
}
|
|
315
|
-
// _deleting flag → soft-delete on remote then hard-delete locally
|
|
319
|
+
// _deleting flag → soft-delete on remote then hard-delete locally
|
|
316
320
|
if (doc._deleting) {
|
|
317
321
|
const [e, data] = await tryCatch(() => transporter.delete(collection_ref, id), tid);
|
|
318
322
|
if (e && server_first)
|
|
@@ -363,20 +367,19 @@ export class LivequeryClient {
|
|
|
363
367
|
}]);
|
|
364
368
|
return data;
|
|
365
369
|
}
|
|
366
|
-
})))
|
|
370
|
+
})));
|
|
371
|
+
return results.filter(Boolean);
|
|
367
372
|
}
|
|
368
373
|
async add(collection_ref, documents, mode) {
|
|
369
374
|
if (mode == 'server-first') {
|
|
370
375
|
const list = documents.map(doc => ({ ...doc, id: `local:${uuidv7()}` }));
|
|
371
376
|
return await this.#push(collection_ref, list, true);
|
|
372
377
|
}
|
|
373
|
-
const docs = await
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
});
|
|
379
|
-
}), toArray()));
|
|
378
|
+
const docs = await Promise.all(documents.map(doc => this.config.storage.add(collection_ref, {
|
|
379
|
+
...doc,
|
|
380
|
+
_adding: true,
|
|
381
|
+
...mode === 'local-only' ? { _local_only: true } : {}
|
|
382
|
+
})));
|
|
380
383
|
await this.#broadcast(collection_ref, 'action', docs.map(data => ({
|
|
381
384
|
collection_ref,
|
|
382
385
|
id: data.id,
|
|
@@ -392,21 +395,17 @@ export class LivequeryClient {
|
|
|
392
395
|
const list = documents.map(doc => ({ ...doc, _prev: doc }));
|
|
393
396
|
return await this.#push(collection_ref, list, true);
|
|
394
397
|
}
|
|
395
|
-
const merged = await
|
|
398
|
+
const merged = (await Promise.all(documents.map(async (doc) => {
|
|
396
399
|
const old = await this.config.storage.get(collection_ref, doc.id);
|
|
397
400
|
if (!old)
|
|
398
401
|
return;
|
|
399
402
|
const _prev = Object.keys(doc).reduce((acc, key) => {
|
|
400
403
|
if (key in (old._prev || {}))
|
|
401
404
|
return acc;
|
|
402
|
-
return {
|
|
403
|
-
...acc,
|
|
404
|
-
[key]: old[key]
|
|
405
|
-
};
|
|
405
|
+
return { ...acc, [key]: old[key] };
|
|
406
406
|
}, old._prev || {});
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}), filter(Boolean), toArray()));
|
|
407
|
+
return await this.config.storage.update(collection_ref, doc.id, { _prev, _updating: true, ...doc });
|
|
408
|
+
}))).filter(Boolean);
|
|
410
409
|
await this.#broadcast(collection_ref, 'action', merged.map(data => ({
|
|
411
410
|
collection_ref,
|
|
412
411
|
id: data.id,
|
|
@@ -423,14 +422,13 @@ export class LivequeryClient {
|
|
|
423
422
|
return await this.#push(collection_ref, list, true);
|
|
424
423
|
}
|
|
425
424
|
const soft = Object.keys(this.config.transporters).length > 0;
|
|
426
|
-
const merged = await
|
|
425
|
+
const merged = (await Promise.all(ids.map(async (id) => {
|
|
427
426
|
const is_local_doc = id.startsWith('local:');
|
|
428
427
|
if (!soft || is_local_doc || mode == 'local-only') {
|
|
429
|
-
|
|
430
|
-
return doc;
|
|
428
|
+
return await this.config.storage.delete(collection_ref, id);
|
|
431
429
|
}
|
|
432
430
|
return await this.config.storage.update(collection_ref, id, { _deleting: true });
|
|
433
|
-
})
|
|
431
|
+
}))).filter(Boolean);
|
|
434
432
|
const deleting_list = merged.filter(doc => doc._deleting);
|
|
435
433
|
const deleted_list = merged.filter(doc => !doc._deleting);
|
|
436
434
|
await this.#broadcast(collection_ref, 'action', deleting_list.map(({ id }) => ({
|
|
@@ -453,6 +451,9 @@ export class LivequeryClient {
|
|
|
453
451
|
trigger(action) {
|
|
454
452
|
return from(Object.entries(this.config.transporters)).pipe(filter(([id]) => action.transporter_id ? id === action.transporter_id : true), mergeMap(([id, transporter]) => transporter.trigger(action)));
|
|
455
453
|
}
|
|
454
|
+
async seedToStorage(collection_ref, docs) {
|
|
455
|
+
await Promise.all(docs.map(doc => this.config.storage.add(collection_ref, doc)));
|
|
456
|
+
}
|
|
456
457
|
async flush(collection_ref) {
|
|
457
458
|
await this.#broadcast(collection_ref, 'realtime', [{ collection_ref, id: '*', type: 'removed' }]);
|
|
458
459
|
return this.config.storage.flush();
|