@livequery/core 2.0.91 → 2.0.96
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 +320 -460
- package/dist/LivequeryCollection.d.ts +19 -17
- package/dist/LivequeryCollection.d.ts.map +1 -1
- package/dist/LivequeryCollection.js +231 -0
- package/dist/LivequeryCollection.js.map +1 -0
- package/dist/LivequeryCore.d.ts +4 -4
- package/dist/LivequeryCore.d.ts.map +1 -1
- package/dist/LivequeryCore.js +337 -0
- package/dist/LivequeryCore.js.map +1 -0
- package/dist/LivequeryDocument.d.ts +2 -2
- package/dist/LivequeryDocument.d.ts.map +1 -1
- package/dist/LivequeryDocument.js +22 -0
- package/dist/LivequeryDocument.js.map +1 -0
- package/dist/LivequeryMemoryStorage.d.ts +2 -2
- package/dist/LivequeryMemoryStorage.d.ts.map +1 -1
- package/dist/LivequeryMemoryStorage.js +89 -0
- package/dist/LivequeryMemoryStorage.js.map +1 -0
- package/dist/LivequeryStorge.d.ts +1 -1
- package/dist/LivequeryStorge.d.ts.map +1 -1
- package/dist/LivequeryStorge.js +2 -0
- package/dist/LivequeryStorge.js.map +1 -0
- package/dist/LivequeryTransporter.d.ts +1 -1
- package/dist/LivequeryTransporter.d.ts.map +1 -1
- package/dist/LivequeryTransporter.js +2 -0
- package/dist/LivequeryTransporter.js.map +1 -0
- package/dist/helpers/filterDocs.d.ts +1 -1
- package/dist/helpers/filterDocs.d.ts.map +1 -1
- package/dist/helpers/filterDocs.js +80 -0
- package/dist/helpers/filterDocs.js.map +1 -0
- package/dist/helpers/tryCatch.js +10 -0
- package/dist/helpers/tryCatch.js.map +1 -0
- package/dist/helpers/whenCompleted.js +5 -0
- package/dist/helpers/whenCompleted.js.map +1 -0
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3167
- package/dist/index.js.map +1 -100
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +73 -4
package/README.md
CHANGED
|
@@ -1,76 +1,97 @@
|
|
|
1
1
|
# @livequery/core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Table of Contents
|
|
6
|
-
|
|
7
|
-
- [Architecture](#architecture)
|
|
8
|
-
- [Installation](#installation)
|
|
9
|
-
- [Quick Start](#quick-start)
|
|
10
|
-
- [Core Concepts](#core-concepts)
|
|
11
|
-
- [Doc](#doc)
|
|
12
|
-
- [LivequeryStorge](#livequerystorge)
|
|
13
|
-
- [LivequeryTransporter](#livequerytransporter)
|
|
14
|
-
- [LivequeryCore](#livequerycore)
|
|
15
|
-
- [LivequeryCollection](#livequerycollection)
|
|
16
|
-
- [LivequeryDocument](#livequerydocument)
|
|
17
|
-
- [Query Filters](#query-filters)
|
|
18
|
-
- [API Reference](#api-reference)
|
|
19
|
-
- [LivequeryMemoryStorage](#livequerymemorystorage)
|
|
20
|
-
- [LivequeryCollection methods](#livequerycollection-methods)
|
|
21
|
-
- [Writing a Custom Transporter](#writing-a-custom-transporter)
|
|
22
|
-
- [Writing a Custom Storage Adapter](#writing-a-custom-storage-adapter)
|
|
23
|
-
- [Types Reference](#types-reference)
|
|
24
|
-
|
|
25
|
-
---
|
|
3
|
+
Reactive local-first data primitives for browser clients.
|
|
26
4
|
|
|
27
|
-
|
|
5
|
+
This package provides the core building blocks behind Livequery collections: reactive document state, pluggable local storage, pluggable transporters, optimistic mutations, and typed inline filters.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
28
8
|
|
|
9
|
+
```bash
|
|
10
|
+
bun add @livequery/core rxjs
|
|
29
11
|
```
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
└──────────────┬──────────────────────┘
|
|
36
|
-
│
|
|
37
|
-
┌──────────────▼──────────────────────┐
|
|
38
|
-
│ LivequeryCore │
|
|
39
|
-
│ - coordinates storage & transport │
|
|
40
|
-
│ - optimistic local mutations │
|
|
41
|
-
│ - broadcasts changes to collections│
|
|
42
|
-
└───────┬──────────────┬──────────────┘
|
|
43
|
-
│ │
|
|
44
|
-
┌───────▼──────┐ ┌─────▼──────────────┐
|
|
45
|
-
│ LivequeryStorge│ │ LivequeryTransporter│
|
|
46
|
-
│ (local) │ │ (remote backend) │
|
|
47
|
-
│ │ │ (can be many) │
|
|
48
|
-
└──────────────┘ └────────────────────┘
|
|
12
|
+
|
|
13
|
+
For React projects:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @livequery/core @livequery/react rxjs
|
|
49
17
|
```
|
|
50
18
|
|
|
51
|
-
|
|
52
|
-
1. `LivequeryCollection.add/update/delete` calls `LivequeryCore.add/update/delete`.
|
|
53
|
-
2. The core applies the change to local storage immediately (optimistic update).
|
|
54
|
-
3. The change is broadcast to all live collections watching the same `ref`.
|
|
55
|
-
4. The core then calls every configured transporter to push the change remotely.
|
|
19
|
+
The package is published as ESM and targets browser usage.
|
|
56
20
|
|
|
57
|
-
|
|
58
|
-
1. `LivequeryCollection.query(filters)` calls `LivequeryCore.query`.
|
|
59
|
-
2. The core returns locally-stored documents instantly from storage.
|
|
60
|
-
3. In parallel, it fires the query against every transporter.
|
|
61
|
-
4. Each transporter streams `DataChangeEvent[]` back into the collection, which merges them reactively.
|
|
21
|
+
## Public Exports
|
|
62
22
|
|
|
63
|
-
|
|
23
|
+
The package re-exports:
|
|
64
24
|
|
|
65
|
-
|
|
25
|
+
```ts
|
|
26
|
+
export * from "./LivequeryCollection"
|
|
27
|
+
export * from "./LivequeryCore"
|
|
28
|
+
export * from "./LivequeryMemoryStorage"
|
|
29
|
+
export * from "./LivequeryStorge"
|
|
30
|
+
export * from "./LivequeryTransporter"
|
|
31
|
+
export * from "./types"
|
|
32
|
+
export * from "./helpers/filterDocs"
|
|
33
|
+
export * from "./LivequeryDocument"
|
|
34
|
+
```
|
|
66
35
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
36
|
+
## Core Types
|
|
37
|
+
|
|
38
|
+
### `Doc`
|
|
39
|
+
|
|
40
|
+
Every record must have an `id`.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
type Doc<T = {}> = T & {
|
|
44
|
+
id: string
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### `DocState`
|
|
49
|
+
|
|
50
|
+
Collections and documents expose `DocState<T>`, which adds optimistic mutation metadata.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
type DocState<T extends Doc> = T & {
|
|
54
|
+
_deleting?: boolean
|
|
55
|
+
_deleting_error?: { code: string; message: string; transporter_id: string }
|
|
56
|
+
_updating?: boolean
|
|
57
|
+
_updating_error?: { code: string; message: string; transporter_id: string }
|
|
58
|
+
_adding?: boolean
|
|
59
|
+
_adding_error?: { code: string; message: string; transporter_id: string }
|
|
60
|
+
_remotes?: Record<string, string | number>
|
|
61
|
+
_prev?: Partial<T>
|
|
62
|
+
}
|
|
71
63
|
```
|
|
72
64
|
|
|
73
|
-
|
|
65
|
+
### `DataChangeEvent`
|
|
66
|
+
|
|
67
|
+
Transporters stream incremental change events back into the core.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
type DataChangeEvent = {
|
|
71
|
+
collection_ref: string
|
|
72
|
+
id: string
|
|
73
|
+
type: "added" | "removed" | "modified"
|
|
74
|
+
data?: Record<string, any>
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Architecture
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
LivequeryCollection / LivequeryDocument
|
|
82
|
+
|
|
|
83
|
+
v
|
|
84
|
+
LivequeryCore
|
|
85
|
+
/ \
|
|
86
|
+
v v
|
|
87
|
+
LivequeryStorge LivequeryTransporter(s)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `LivequeryCollection` owns the reactive state for one collection ref or one document ref.
|
|
91
|
+
- `LivequeryDocument` wraps an item as a `BehaviorSubject` with convenience mutation methods.
|
|
92
|
+
- `LivequeryCore` coordinates storage, transporters, optimistic writes, and fan-out to watchers.
|
|
93
|
+
- `LivequeryStorge` is the local persistence contract.
|
|
94
|
+
- `LivequeryTransporter` is the remote sync contract.
|
|
74
95
|
|
|
75
96
|
## Quick Start
|
|
76
97
|
|
|
@@ -80,527 +101,366 @@ import {
|
|
|
80
101
|
LivequeryCore,
|
|
81
102
|
LivequeryMemoryStorage,
|
|
82
103
|
type Doc,
|
|
104
|
+
type LivequeryQueryResult,
|
|
83
105
|
type LivequeryTransporter,
|
|
84
106
|
} from "@livequery/core"
|
|
85
107
|
import { of } from "rxjs"
|
|
86
108
|
|
|
87
|
-
|
|
88
|
-
type Todo = Doc & {
|
|
109
|
+
type Todo = Doc<{
|
|
89
110
|
title: string
|
|
90
111
|
done: boolean
|
|
91
112
|
createdAt: number
|
|
92
|
-
}
|
|
113
|
+
}>
|
|
93
114
|
|
|
94
|
-
// 2. Create a storage (in-memory for this example)
|
|
95
115
|
const storage = new LivequeryMemoryStorage()
|
|
96
116
|
|
|
97
|
-
// 3. Create a transporter (no-op; replace with your real backend)
|
|
98
117
|
const transporter: LivequeryTransporter = {
|
|
99
118
|
query(_query) {
|
|
100
|
-
return of({
|
|
119
|
+
return of<Partial<LivequeryQueryResult>>({
|
|
120
|
+
changes: [],
|
|
121
|
+
summary: {},
|
|
122
|
+
paging: { total: 0, current: 0 },
|
|
123
|
+
metadata: {},
|
|
124
|
+
source: "query",
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
async add(_ref, doc) {
|
|
128
|
+
return { id: crypto.randomUUID(), ...doc } as Todo
|
|
129
|
+
},
|
|
130
|
+
async update(_ref, id, doc) {
|
|
131
|
+
return { id, ...doc } as Todo
|
|
132
|
+
},
|
|
133
|
+
async delete(_ref, id) {
|
|
134
|
+
return { id } as Todo
|
|
135
|
+
},
|
|
136
|
+
async trigger(_action) {
|
|
137
|
+
return { ok: true }
|
|
101
138
|
},
|
|
102
|
-
add: async (_ref, doc) => ({ ...doc, id: crypto.randomUUID() } as any),
|
|
103
|
-
update: async (_ref, _id, doc) => doc as any,
|
|
104
|
-
delete: async (_ref, _id) => ({} as any),
|
|
105
|
-
trigger: async (_action) => ({} as any),
|
|
106
139
|
}
|
|
107
140
|
|
|
108
|
-
// 4. Create the core
|
|
109
141
|
const core = new LivequeryCore({
|
|
110
142
|
storage,
|
|
111
|
-
transporters: {
|
|
143
|
+
transporters: {
|
|
144
|
+
primary: transporter,
|
|
145
|
+
},
|
|
112
146
|
})
|
|
113
147
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// 6. Subscribe to reactive state
|
|
119
|
-
todos.items.subscribe((docs) => {
|
|
120
|
-
console.log("items:", docs.map((doc) => doc.value))
|
|
148
|
+
const todos = new LivequeryCollection<Todo>(core, {
|
|
149
|
+
filters: { "createdAt:sort": "desc" },
|
|
150
|
+
mode: "server-first",
|
|
121
151
|
})
|
|
122
|
-
todos.loading.subscribe((state) => console.log("loading:", state))
|
|
123
|
-
todos.paging.subscribe((p) => console.log("paging:", p))
|
|
124
152
|
|
|
125
|
-
|
|
126
|
-
await todos.query({ "createdAt:sort": "desc", ":limit": 20 })
|
|
153
|
+
todos.initialize("todos")
|
|
127
154
|
|
|
128
|
-
|
|
155
|
+
todos.items.subscribe((items) => {
|
|
156
|
+
console.log(items.map((doc) => doc.value))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
await todos.query({ ":limit": 20, "createdAt:sort": "desc" })
|
|
129
160
|
await todos.add({ title: "Buy milk", done: false, createdAt: Date.now() })
|
|
130
|
-
await todos.update("
|
|
131
|
-
await todos.delete("
|
|
161
|
+
await todos.update("todo-1", { done: true })
|
|
162
|
+
await todos.delete("todo-1")
|
|
132
163
|
```
|
|
133
164
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
## Core Concepts
|
|
165
|
+
## `LivequeryCore`
|
|
137
166
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
Every document stored in livequery must extend `Doc<T>`:
|
|
167
|
+
Create one core with a storage adapter and one or more transporters:
|
|
141
168
|
|
|
142
169
|
```ts
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
170
|
+
const core = new LivequeryCore({
|
|
171
|
+
storage,
|
|
172
|
+
transporters: {
|
|
173
|
+
primary: transporter,
|
|
174
|
+
},
|
|
175
|
+
})
|
|
146
176
|
```
|
|
147
177
|
|
|
148
|
-
|
|
178
|
+
### Mutation flow
|
|
149
179
|
|
|
150
|
-
|
|
151
|
-
type Post = Doc & {
|
|
152
|
-
title: string
|
|
153
|
-
body: string
|
|
154
|
-
publishedAt: number
|
|
155
|
-
}
|
|
156
|
-
```
|
|
180
|
+
For `add`, `update`, and `delete`, the core:
|
|
157
181
|
|
|
158
|
-
|
|
182
|
+
1. writes to local storage first
|
|
183
|
+
2. broadcasts the optimistic change to active watchers
|
|
184
|
+
3. pushes the mutation to each transporter
|
|
185
|
+
4. clears optimistic flags or stores mutation errors after the remote call finishes
|
|
159
186
|
|
|
160
|
-
|
|
161
|
-
type DocState<T extends Doc> = T & {
|
|
162
|
-
_deleting?: boolean // pending deletion
|
|
163
|
-
_updating?: boolean // pending update
|
|
164
|
-
_adding?: boolean // pending add
|
|
165
|
-
_remotes?: Record<string, string | number> // per-transporter version cursors
|
|
166
|
-
_prev?: Partial<T> // previous values before last local mutation
|
|
167
|
-
}
|
|
168
|
-
```
|
|
187
|
+
Documents created locally receive ids prefixed with `local:` until a transporter returns a persisted id.
|
|
169
188
|
|
|
170
|
-
|
|
189
|
+
### Query modes
|
|
171
190
|
|
|
172
|
-
|
|
191
|
+
Collections support three modes through `LivequeryCollectionOptions.mode`:
|
|
173
192
|
|
|
174
|
-
`
|
|
193
|
+
- `server-first`: queries are driven by transporters, and collection state is built from streamed change events.
|
|
194
|
+
- `cache-first`: first query can hydrate from local storage, then transporters refresh the result.
|
|
195
|
+
- `local-first`: queries resolve from local storage while remote sync runs in the background and rebroadcasts matching changes.
|
|
175
196
|
|
|
176
|
-
|
|
177
|
-
type LivequeryStorge = {
|
|
178
|
-
query<T extends Doc>(
|
|
179
|
-
collection: string,
|
|
180
|
-
filters?: Record<string, any>
|
|
181
|
-
): Promise<{ documents: T[]; paging: LivequeryPaging }>
|
|
197
|
+
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.
|
|
182
198
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
## `LivequeryCollection`
|
|
200
|
+
|
|
201
|
+
`LivequeryCollection<T>` manages one collection or one document ref.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
type LivequeryCollectionOptions<T extends Doc> = {
|
|
205
|
+
core: LivequeryCore
|
|
206
|
+
filters: Partial<LivequeryFilters<T>>
|
|
207
|
+
lazy: boolean
|
|
208
|
+
debounce: number
|
|
209
|
+
mode: "server-first" | "local-first" | "cache-first"
|
|
187
210
|
}
|
|
188
211
|
```
|
|
189
212
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
### LivequeryTransporter
|
|
213
|
+
### Create and initialize a collection
|
|
193
214
|
|
|
194
|
-
|
|
215
|
+
The current constructor takes `core` as the first argument and options as the second argument.
|
|
195
216
|
|
|
196
217
|
```ts
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
// Called for optimistic mutations
|
|
204
|
-
add<T extends Doc>(ref: string, doc: Omit<T, 'id'>): Promise<T>
|
|
205
|
-
update<T extends Doc>(ref: string, id: string, doc: Partial<T>): Promise<T>
|
|
206
|
-
delete<T extends Doc>(ref: string, id: string): Promise<T>
|
|
218
|
+
const posts = new LivequeryCollection<Post>(core, {
|
|
219
|
+
filters: { "publishedAt:sort": "desc" },
|
|
220
|
+
lazy: false,
|
|
221
|
+
debounce: 250,
|
|
222
|
+
mode: "cache-first",
|
|
223
|
+
})
|
|
207
224
|
|
|
208
|
-
|
|
209
|
-
trigger<T>(action: LivequeryAction): Promise<T>
|
|
210
|
-
}
|
|
225
|
+
posts.initialize("posts")
|
|
211
226
|
```
|
|
212
227
|
|
|
213
|
-
|
|
228
|
+
`initialize(ref)` subscribes the collection to `LivequeryCore.watch(ref, id, mode)`. In the current implementation, it is browser-only and returns early when `window` is unavailable.
|
|
214
229
|
|
|
215
|
-
###
|
|
230
|
+
### Collection refs and document refs
|
|
216
231
|
|
|
217
|
-
|
|
232
|
+
If a ref has an even number of path segments, the last segment is treated as a document id.
|
|
218
233
|
|
|
219
234
|
```ts
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
transporters: { // one or more named transporters
|
|
223
|
-
primary: myTransporter,
|
|
224
|
-
},
|
|
225
|
-
})
|
|
235
|
+
posts.initialize("posts")
|
|
236
|
+
singlePost.initialize("posts/post-1")
|
|
226
237
|
```
|
|
227
238
|
|
|
228
|
-
|
|
239
|
+
For collection mutations, `add`, `update`, and `delete` always target the collection portion of the ref.
|
|
240
|
+
|
|
241
|
+
### Reactive state
|
|
229
242
|
|
|
230
|
-
|
|
243
|
+
- `items`: `BehaviorSubject<LivequeryDocument<DocState<T>>[]>`
|
|
244
|
+
- `summary`: `BehaviorSubject<Record<string, any>>`
|
|
245
|
+
- `loading`: `BehaviorSubject<null | "all" | "next" | "prev">`
|
|
246
|
+
- `filters`: `BehaviorSubject<Partial<LivequeryFilters<T>>>`
|
|
247
|
+
- `paging`: `BehaviorSubject<LivequeryPaging>`
|
|
248
|
+
- `error`: `BehaviorSubject<{ code: string; message: string } | null>`
|
|
231
249
|
|
|
232
|
-
`
|
|
250
|
+
`items` is a `BehaviorSubject`, not a plain array. Reading `collection.items.value` gives the current snapshot only. If you need live updates, subscribe.
|
|
233
251
|
|
|
234
252
|
```ts
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
lazy: true, // true = don't auto-load on initialize(); false = load immediately (default)
|
|
238
|
-
debounce: 300, // optional debounce time in ms for debounceQuery()
|
|
253
|
+
const subscription = posts.items.subscribe((items) => {
|
|
254
|
+
console.log("realtime items", items.map((doc) => doc.value))
|
|
239
255
|
})
|
|
240
256
|
|
|
241
|
-
|
|
242
|
-
posts.initialize(core, "posts")
|
|
257
|
+
subscription.unsubscribe()
|
|
243
258
|
```
|
|
244
259
|
|
|
245
|
-
|
|
260
|
+
In React, reading only `collection.items.value` during render will not trigger rerenders when new events arrive. Bridge the `BehaviorSubject` into component state.
|
|
246
261
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
262
|
+
```tsx
|
|
263
|
+
function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) {
|
|
264
|
+
const [items, setItems] = useState(() => collection.items.value)
|
|
265
|
+
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const subscription = collection.items.subscribe(setItems)
|
|
268
|
+
return () => subscription.unsubscribe()
|
|
269
|
+
}, [collection])
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<ul>
|
|
273
|
+
{items.map((item) => (
|
|
274
|
+
<li key={item.value.id}>{item.value.title}</li>
|
|
275
|
+
))}
|
|
276
|
+
</ul>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Main methods
|
|
256
282
|
|
|
257
283
|
```ts
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
284
|
+
query(filters: Partial<LivequeryFilters<T>>): Promise<void>
|
|
285
|
+
debounceQuery(filters: Partial<LivequeryFilters<T>>): Promise<void>
|
|
286
|
+
loadMore(): Promise<void>
|
|
287
|
+
loadPrev(): Promise<void>
|
|
288
|
+
loadAround(cursor: string): Promise<void>
|
|
289
|
+
add(payload: Partial<T>): Promise<T>
|
|
290
|
+
update(id: string, payload: Partial<T>): Promise<T | undefined>
|
|
291
|
+
delete(id: string): Promise<void | T | undefined>
|
|
292
|
+
trigger<R>(action: string, payload?: Record<string, any>): Observable<{ data: R; error?: Error }>
|
|
293
|
+
resetError(): void
|
|
294
|
+
watch(check: (prev: T, next: T) => boolean): Observable<[DocState<T>, DocState<T>]>
|
|
261
295
|
```
|
|
262
296
|
|
|
263
|
-
|
|
297
|
+
Notes about current behavior:
|
|
298
|
+
|
|
299
|
+
- `query()` requires `initialize()` to have run first so the collection has a `ref` and watcher registration.
|
|
300
|
+
- `debounceQuery()` only emits through the debounced path when `options.debounce` is truthy.
|
|
301
|
+
- `loadMore()` uses `paging.next.cursor` as `:after`.
|
|
302
|
+
- `loadPrev()` uses `paging.prev.cursor` as `:before`.
|
|
303
|
+
- `loadAround()` currently sets both `:after` and `:before` to the provided cursor.
|
|
264
304
|
|
|
265
|
-
|
|
305
|
+
## `LivequeryDocument`
|
|
266
306
|
|
|
267
|
-
Each
|
|
307
|
+
Each entry inside `collection.items` is a `LivequeryDocument`, which extends `BehaviorSubject<DocState<T>>`.
|
|
268
308
|
|
|
269
309
|
```ts
|
|
270
|
-
class LivequeryDocument<T extends Doc> extends BehaviorSubject<T
|
|
271
|
-
update(data: Partial<T>): Promise<
|
|
272
|
-
del(): Promise<void>
|
|
310
|
+
class LivequeryDocument<T extends Doc> extends BehaviorSubject<DocState<T>> {
|
|
311
|
+
update(data: Partial<T>): Promise<T | undefined>
|
|
312
|
+
del(): Promise<void | T | undefined>
|
|
273
313
|
trigger<R>(action: string, payload: Record<string, any>): Observable<{ data: R; error?: Error }>
|
|
274
314
|
}
|
|
275
315
|
```
|
|
276
316
|
|
|
277
|
-
|
|
278
|
-
const doc = posts.items.value[0]
|
|
317
|
+
Example:
|
|
279
318
|
|
|
280
|
-
|
|
281
|
-
|
|
319
|
+
```ts
|
|
320
|
+
const first = todos.items.value[0]
|
|
282
321
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
322
|
+
first.subscribe((doc) => {
|
|
323
|
+
console.log(doc.title, doc._updating)
|
|
324
|
+
})
|
|
286
325
|
|
|
287
|
-
|
|
288
|
-
|
|
326
|
+
await first.update({ done: true })
|
|
327
|
+
await first.del()
|
|
328
|
+
first.trigger("archive", { reason: "completed" }).subscribe()
|
|
289
329
|
```
|
|
290
330
|
|
|
291
|
-
|
|
331
|
+
## `LivequeryStorge`
|
|
292
332
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
Filters are fully type-safe. The TypeScript compiler will only allow valid field paths and operators for your document type.
|
|
296
|
-
|
|
297
|
-
### Pagination / sorting
|
|
298
|
-
|
|
299
|
-
| Key | Type | Description |
|
|
300
|
-
|-----|------|-------------|
|
|
301
|
-
| `"<field>:sort"` | `"asc" \| "desc"` | Sort by a string field |
|
|
302
|
-
| `":limit"` | `number` | Max items per page |
|
|
303
|
-
| `":page"` | `number` | Page number (1-based) |
|
|
304
|
-
| `":before"` | `string` | Cursor for previous-page fetch |
|
|
305
|
-
| `":after"` | `string` | Cursor for next-page fetch |
|
|
306
|
-
|
|
307
|
-
### Field operators
|
|
308
|
-
|
|
309
|
-
| Operator | Applies to | Description |
|
|
310
|
-
|----------|-----------|-------------|
|
|
311
|
-
| `gt` | `number` | Greater than |
|
|
312
|
-
| `gte` | `number` | Greater than or equal |
|
|
313
|
-
| `lt` | `number` | Less than |
|
|
314
|
-
| `lte` | `number` | Less than or equal |
|
|
315
|
-
| `eq-number` | `number` | Strict numeric equality |
|
|
316
|
-
| `in` | `number \| string` | Value is in array |
|
|
317
|
-
| `nin` | `number \| string` | Value is NOT in array |
|
|
318
|
-
| `include` | `number[] \| string[]` | Array field includes value |
|
|
319
|
-
| `boolean` | `boolean` | `"true"`, `"false"`, `"not-true"`, `"not-false"` |
|
|
320
|
-
| `like` | `string` | Case-insensitive substring match |
|
|
321
|
-
| `null` | any | `"null-only"` or `"not-null"` |
|
|
333
|
+
Local persistence adapters must implement:
|
|
322
334
|
|
|
323
335
|
```ts
|
|
324
|
-
type
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
"archived:boolean": "false",
|
|
337
|
-
"deletedAt:null": "null-only",
|
|
338
|
-
"score:sort": "desc",
|
|
339
|
-
":limit": 20,
|
|
340
|
-
":page": 1,
|
|
341
|
-
":before": "",
|
|
342
|
-
":after": "",
|
|
336
|
+
type LivequeryStorge = {
|
|
337
|
+
query<T extends Doc>(
|
|
338
|
+
collection: string,
|
|
339
|
+
filters?: Record<string, any>
|
|
340
|
+
): Promise<{
|
|
341
|
+
documents: T[]
|
|
342
|
+
paging: LivequeryPaging
|
|
343
|
+
}>
|
|
344
|
+
get<T extends Doc>(ref: string, id: string): Promise<T | null>
|
|
345
|
+
add<T extends Doc>(collection: string, document: T): Promise<T>
|
|
346
|
+
update<T extends Doc>(collection: string, id: string, document: Record<string, any>): Promise<T | null>
|
|
347
|
+
delete<T extends Doc>(collection: string, id: string): Promise<T | null>
|
|
343
348
|
}
|
|
344
349
|
```
|
|
345
350
|
|
|
346
|
-
|
|
351
|
+
The package ships with `LivequeryMemoryStorage`, an in-memory adapter useful for tests, demos, and ephemeral state.
|
|
347
352
|
|
|
348
|
-
|
|
353
|
+
### `LivequeryMemoryStorage`
|
|
349
354
|
|
|
350
|
-
|
|
355
|
+
The built-in adapter:
|
|
351
356
|
|
|
352
|
-
|
|
357
|
+
- stores documents in `Map<string, Map<string, Doc>>`
|
|
358
|
+
- generates a local id with `local:${crypto.randomUUID()}` when `id` is missing
|
|
359
|
+
- applies filters through the exported `filterDocs()` helper
|
|
360
|
+
- supports nested sort keys such as `profile.createdAt:sort`
|
|
353
361
|
|
|
354
|
-
|
|
355
|
-
const storage = new LivequeryMemoryStorage()
|
|
356
|
-
```
|
|
362
|
+
## `LivequeryTransporter`
|
|
357
363
|
|
|
358
|
-
|
|
359
|
-
|--------|-----------|-------------|
|
|
360
|
-
| `query` | `(collection, filters?) → Promise<{documents, paging}>` | Filter, sort and paginate documents |
|
|
361
|
-
| `get` | `(collection, id) → Promise<T \| null>` | Fetch a single document by id |
|
|
362
|
-
| `add` | `(collection, document) → Promise<T>` | Upsert a document (insert or replace by id) |
|
|
363
|
-
| `update` | `(collection, id, partial) → Promise<T \| null>` | Merge partial fields into existing document |
|
|
364
|
-
| `delete` | `(collection, id) → Promise<T \| null>` | Remove and return a document |
|
|
365
|
-
| `clear` | `(collection?) → void` | Clear one collection or all collections |
|
|
364
|
+
Remote adapters must implement:
|
|
366
365
|
|
|
367
366
|
```ts
|
|
368
|
-
|
|
369
|
-
|
|
367
|
+
type LivequeryTransporter = {
|
|
368
|
+
query<T extends Doc>(query: LivequeryQueryParams<T>): Observable<Partial<LivequeryQueryResult>>
|
|
369
|
+
add<T extends Doc>(ref: string, doc: Omit<T, "id">): Promise<T>
|
|
370
|
+
update<T extends Doc>(ref: string, id: string, doc: Partial<T>): Promise<T>
|
|
371
|
+
delete<T extends Doc>(ref: string, id: string): Promise<T>
|
|
372
|
+
trigger<T>(action: LivequeryAction): Promise<T>
|
|
373
|
+
}
|
|
370
374
|
```
|
|
371
375
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
### LivequeryCollection methods
|
|
375
|
-
|
|
376
|
-
| Method | Description |
|
|
377
|
-
|--------|-------------|
|
|
378
|
-
| `initialize(core, ref)` | Wire up the core for a given collection path; optionally auto-loads (required before use) |
|
|
379
|
-
| `query(filters)` | Execute a fresh query replacing current items |
|
|
380
|
-
| `debounceQuery(filters)` | Queue a debounced query (uses the `debounce` option) |
|
|
381
|
-
| `loadMore()` | Append next page using `paging.next.cursor` |
|
|
382
|
-
| `loadPrev()` | Prepend previous page using `paging.prev.cursor` |
|
|
383
|
-
| `loadAround(cursor)` | Load items around a specific cursor (both directions) |
|
|
384
|
-
| `add(payload)` | Optimistically add a new document |
|
|
385
|
-
| `update(id, payload)` | Optimistically update a document |
|
|
386
|
-
| `delete(id)` | Optimistically delete a document |
|
|
387
|
-
| `trigger(action, payload?)` | Fire a custom action via the transporter |
|
|
388
|
-
| `watch(check)` | Returns an Observable that emits when a document changes, filtered by `check` |
|
|
389
|
-
| `resetError()` | Clear the current `error` state |
|
|
376
|
+
### Query result shape
|
|
390
377
|
|
|
391
378
|
```ts
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
// Custom action handled by your transporter
|
|
403
|
-
collection.trigger("sendEmail", { to: "user@example.com" }).subscribe()
|
|
404
|
-
|
|
405
|
-
// Watch for field changes across all items
|
|
406
|
-
collection.watch((prev, next) => prev.done !== next.done).subscribe(([prev, next]) => {
|
|
407
|
-
console.log("done changed", prev, next)
|
|
408
|
-
})
|
|
379
|
+
type LivequeryQueryResult = {
|
|
380
|
+
error: { code: string; message: string }
|
|
381
|
+
changes: DataChangeEvent[]
|
|
382
|
+
summary: Record<string, any>
|
|
383
|
+
paging: LivequeryPaging
|
|
384
|
+
metadata: Record<string, any>
|
|
385
|
+
source: "query" | "action" | "realtime"
|
|
386
|
+
loading?: "all" | "next" | "prev" | null
|
|
387
|
+
}
|
|
409
388
|
```
|
|
410
389
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
## Writing a Custom Transporter
|
|
414
|
-
|
|
415
|
-
Implement `LivequeryTransporter` to connect to any backend:
|
|
390
|
+
Transporters can emit partial results. In practice, the most useful fields are `changes`, `paging`, `summary`, `metadata`, and `error`.
|
|
416
391
|
|
|
417
|
-
|
|
418
|
-
import { Observable } from "rxjs"
|
|
419
|
-
import type {
|
|
420
|
-
LivequeryTransporter, Doc,
|
|
421
|
-
LivequeryQueryParams, LivequeryAction
|
|
422
|
-
} from "@livequery/core"
|
|
392
|
+
## Query Filters
|
|
423
393
|
|
|
424
|
-
|
|
425
|
-
query<T extends Doc>(params: LivequeryQueryParams<T>) {
|
|
426
|
-
return new Observable(subscriber => {
|
|
427
|
-
fetch(`/api/${params.ref}?${new URLSearchParams(params.filters as any)}`)
|
|
428
|
-
.then(r => r.json())
|
|
429
|
-
.then(data => {
|
|
430
|
-
subscriber.next({
|
|
431
|
-
changes: data.items.map((item: T) => ({ id: item.id, type: "added", data: item })),
|
|
432
|
-
paging: data.paging,
|
|
433
|
-
summary: data.summary ?? {},
|
|
434
|
-
metadata: {},
|
|
435
|
-
source: "query",
|
|
436
|
-
})
|
|
437
|
-
subscriber.complete()
|
|
438
|
-
})
|
|
439
|
-
.catch(err => subscriber.error(err))
|
|
440
|
-
})
|
|
441
|
-
},
|
|
394
|
+
Filters are flat keys derived from the document type.
|
|
442
395
|
|
|
443
|
-
|
|
444
|
-
const res = await fetch(`/api/${ref}`, {
|
|
445
|
-
method: "POST",
|
|
446
|
-
headers: { "Content-Type": "application/json" },
|
|
447
|
-
body: JSON.stringify(doc),
|
|
448
|
-
})
|
|
449
|
-
return res.json() as Promise<T>
|
|
450
|
-
},
|
|
396
|
+
### Pagination keys
|
|
451
397
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
})
|
|
458
|
-
return res.json() as Promise<T>
|
|
459
|
-
},
|
|
398
|
+
- `:limit`
|
|
399
|
+
- `:before`
|
|
400
|
+
- `:after`
|
|
401
|
+
- `:around`
|
|
402
|
+
- `:page`
|
|
460
403
|
|
|
461
|
-
|
|
462
|
-
const res = await fetch(`/api/${ref}/${id}`, { method: "DELETE" })
|
|
463
|
-
return res.json() as Promise<T>
|
|
464
|
-
},
|
|
404
|
+
### Supported operators
|
|
465
405
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
---
|
|
406
|
+
- `field:sort` with `"asc" | "desc"` for string and number fields
|
|
407
|
+
- `field:gt`, `field:gte`, `field:lt`, `field:lte` for numeric fields
|
|
408
|
+
- `field:eq-number` for numeric equality
|
|
409
|
+
- `field` for string equality
|
|
410
|
+
- `field:in`, `field:nin` for string or number membership
|
|
411
|
+
- `field:include` for array containment
|
|
412
|
+
- `field:boolean` with `"true" | "false" | "not-true" | "not-false"`
|
|
413
|
+
- `field:like` for case-insensitive substring matching on strings
|
|
414
|
+
- `field:null` with `"null-only" | "not-null"`
|
|
478
415
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
Implement `LivequeryStorge` to persist data in `localStorage`, `IndexedDB`, SQLite, etc.:
|
|
416
|
+
Nested field paths are supported, for example `"profile.createdAt:sort"`.
|
|
482
417
|
|
|
483
418
|
```ts
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
localStorage.setItem(collection, JSON.stringify(docs))
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
async query<T extends Doc>(collection: string, filters?: Record<string, any>) {
|
|
495
|
-
const docs = this.read<T>(collection)
|
|
496
|
-
// apply filters, sort, paginate …
|
|
497
|
-
return { documents: docs, paging: { total: docs.length, current: docs.length } }
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async get<T extends Doc>(collection: string, id: string) {
|
|
501
|
-
return this.read<T>(collection).find(d => d.id === id) ?? null
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
async add<T extends Doc>(collection: string, document: T) {
|
|
505
|
-
const docs = this.read<T>(collection)
|
|
506
|
-
const i = docs.findIndex(d => d.id === document.id)
|
|
507
|
-
if (i >= 0) docs[i] = document; else docs.push(document)
|
|
508
|
-
this.write(collection, docs)
|
|
509
|
-
return document
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
async update<T extends Doc>(collection: string, id: string, patch: Record<string, any>) {
|
|
513
|
-
const docs = this.read<T>(collection)
|
|
514
|
-
const i = docs.findIndex(d => d.id === id)
|
|
515
|
-
if (i < 0) return null
|
|
516
|
-
docs[i] = { ...docs[i], ...patch }
|
|
517
|
-
this.write(collection, docs)
|
|
518
|
-
return docs[i]
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
async delete<T extends Doc>(collection: string, id: string) {
|
|
522
|
-
const docs = this.read<T>(collection)
|
|
523
|
-
const i = docs.findIndex(d => d.id === id)
|
|
524
|
-
if (i < 0) return null
|
|
525
|
-
const [removed] = docs.splice(i, 1)
|
|
526
|
-
this.write(collection, docs)
|
|
527
|
-
return removed ?? null
|
|
528
|
-
}
|
|
529
|
-
}
|
|
419
|
+
await todos.query({
|
|
420
|
+
":limit": 20,
|
|
421
|
+
"done:boolean": "false",
|
|
422
|
+
"title:like": "milk",
|
|
423
|
+
"createdAt:gte": 1714176000000,
|
|
424
|
+
"createdAt:sort": "desc",
|
|
425
|
+
})
|
|
530
426
|
```
|
|
531
427
|
|
|
532
|
-
|
|
428
|
+
## Helper Exports
|
|
533
429
|
|
|
534
|
-
|
|
430
|
+
### `filterDocs()`
|
|
535
431
|
|
|
536
432
|
```ts
|
|
537
|
-
|
|
538
|
-
type Doc<T = {}> = T & { id: string }
|
|
539
|
-
|
|
540
|
-
// Document state inside a collection (tracks optimistic-update flags)
|
|
541
|
-
type DocState<T extends Doc> = T & {
|
|
542
|
-
_deleting?: boolean
|
|
543
|
-
_updating?: boolean
|
|
544
|
-
_adding?: boolean
|
|
545
|
-
_remotes?: Record<string, string | number> // per-transporter version cursors
|
|
546
|
-
_prev?: Partial<T> // previous values before last local mutation
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Change event emitted by transporters and the core
|
|
550
|
-
type DataChangeEvent = {
|
|
551
|
-
collection_ref: string
|
|
552
|
-
id: string
|
|
553
|
-
type: 'added' | 'removed' | 'modified'
|
|
554
|
-
data?: Record<string, any>
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Query parameters forwarded to every transporter
|
|
558
|
-
type LivequeryQueryParams<T extends Doc> = {
|
|
559
|
-
ref: string
|
|
560
|
-
filters?: Partial<LivequeryFilters<T>>
|
|
561
|
-
headers?: Record<string, string>
|
|
562
|
-
}
|
|
433
|
+
import { filterDocs } from "@livequery/core"
|
|
563
434
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
paging: LivequeryPaging
|
|
570
|
-
metadata: Record<string, any>
|
|
571
|
-
source: 'query' | 'action' | 'realtime'
|
|
572
|
-
}
|
|
435
|
+
const visible = filterDocs(docs, {
|
|
436
|
+
"done:boolean": "false",
|
|
437
|
+
"title:like": "milk",
|
|
438
|
+
})
|
|
439
|
+
```
|
|
573
440
|
|
|
574
|
-
|
|
575
|
-
type LivequeryAction = {
|
|
576
|
-
ref: string
|
|
577
|
-
action: string
|
|
578
|
-
payload?: Record<string, any>
|
|
579
|
-
}
|
|
441
|
+
### `matchesAllFilters()`
|
|
580
442
|
|
|
581
|
-
|
|
582
|
-
type LivequeryPaging = {
|
|
583
|
-
total: number
|
|
584
|
-
current: number
|
|
585
|
-
next?: { count: number; cursor: string }
|
|
586
|
-
prev?: { count: number; cursor: string }
|
|
587
|
-
}
|
|
443
|
+
The helper module also exports `matchesAllFilters(doc, filters)` for direct predicate checks.
|
|
588
444
|
|
|
589
|
-
|
|
590
|
-
type LivequeryLoadingState = null | 'next' | 'prev' | 'all'
|
|
591
|
-
```
|
|
445
|
+
## Caveats
|
|
592
446
|
|
|
593
|
-
|
|
447
|
+
- `initialize()` is browser-only because it exits early when `window` is unavailable.
|
|
448
|
+
- The public storage interface name is intentionally spelled `LivequeryStorge`, matching the source.
|
|
449
|
+
- Optimistic flags such as `_adding`, `_updating`, `_deleting`, and `_prev` are system-managed fields.
|
|
450
|
+
- Transporter query streams are expected to emit incremental `changes`, not full snapshots.
|
|
451
|
+
- `LivequeryCollection` declares a `metadata` subject but does not initialize it in the constructor, so transporter-emitted `metadata` is not safe to rely on yet.
|
|
452
|
+
- `trigger()` is typed at the collection and document layer as `Observable<{ data, error? }>` but currently forwards raw transporter results from `LivequeryCore.trigger()`.
|
|
594
453
|
|
|
595
|
-
##
|
|
454
|
+
## Development
|
|
596
455
|
|
|
597
456
|
```bash
|
|
598
457
|
bun run build
|
|
599
458
|
```
|
|
600
459
|
|
|
601
|
-
|
|
460
|
+
Available scripts:
|
|
602
461
|
|
|
603
|
-
|
|
604
|
-
bun run build:
|
|
605
|
-
bun run
|
|
606
|
-
|
|
462
|
+
- `bun run clean`
|
|
463
|
+
- `bun run build:js`
|
|
464
|
+
- `bun run build:types`
|
|
465
|
+
- `bun run build`
|
|
466
|
+
- `bun run build:watch`
|