@livequery/core 2.0.90 → 2.0.92
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 +316 -463
- package/dist/index.js +2 -2
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,76 +1,103 @@
|
|
|
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. The package combines RxJS-based collections, pluggable local storage, pluggable remote transporters, optimistic mutations, and typed inline filters.
|
|
26
4
|
|
|
27
|
-
|
|
5
|
+
This package is only the core layer. You can use the built-in pieces, but you can also implement your own `LivequeryStorge` and `LivequeryTransporter` adapters to match your cache strategy, backend API, realtime channel, or persistence model.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
28
8
|
|
|
9
|
+
```bash
|
|
10
|
+
npm install @livequery/core rxjs
|
|
11
|
+
# or
|
|
12
|
+
bun add @livequery/core rxjs
|
|
29
13
|
```
|
|
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
|
-
└──────────────┘ └────────────────────┘
|
|
14
|
+
|
|
15
|
+
For React projects:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @livequery/core @livequery/react rxjs
|
|
19
|
+
# or
|
|
20
|
+
bun add @livequery/core @livequery/react rxjs
|
|
49
21
|
```
|
|
50
22
|
|
|
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.
|
|
23
|
+
The package is published as ESM and targets browser usage.
|
|
56
24
|
|
|
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.
|
|
25
|
+
If you are using this package with React, `@livequery/react` is the recommended companion package so collection state can be connected to React components more ergonomically.
|
|
62
26
|
|
|
63
|
-
|
|
27
|
+
## Public Exports
|
|
64
28
|
|
|
65
|
-
|
|
29
|
+
The public API is re-exported from `src/index.ts`:
|
|
66
30
|
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
31
|
+
```ts
|
|
32
|
+
export * from "./LivequeryCollection"
|
|
33
|
+
export * from "./LivequeryCore"
|
|
34
|
+
export * from "./LivequeryMemoryStorage"
|
|
35
|
+
export * from "./LivequeryStorge"
|
|
36
|
+
export * from "./LivequeryTransporter"
|
|
37
|
+
export * from "./types"
|
|
38
|
+
export * from "./helpers/filterDocs"
|
|
39
|
+
export * from "./LivequeryDocument"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Core Concepts
|
|
43
|
+
|
|
44
|
+
### `Doc`
|
|
45
|
+
|
|
46
|
+
Every record must have an `id`.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
type Doc<T = {}> = T & {
|
|
50
|
+
id: string
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `DocState`
|
|
55
|
+
|
|
56
|
+
Collections expose documents as `DocState<T>`, which adds optimistic mutation metadata.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
type DocState<T extends Doc> = T & {
|
|
60
|
+
_deleting?: boolean
|
|
61
|
+
_deleting_error?: { code: string; message: string; transporter_id: string }
|
|
62
|
+
_updating?: boolean
|
|
63
|
+
_updating_error?: { code: string; message: string; transporter_id: string }
|
|
64
|
+
_adding?: boolean
|
|
65
|
+
_adding_error?: { code: string; message: string; transporter_id: string }
|
|
66
|
+
_remotes?: Record<string, string | number>
|
|
67
|
+
_prev?: Partial<T>
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `DataChangeEvent`
|
|
72
|
+
|
|
73
|
+
Remote query streams feed the core with incremental changes:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
type DataChangeEvent = {
|
|
77
|
+
collection_ref: string
|
|
78
|
+
id: string
|
|
79
|
+
type: "added" | "removed" | "modified"
|
|
80
|
+
data?: Record<string, any>
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Architecture
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
LivequeryCollection / LivequeryDocument
|
|
88
|
+
|
|
|
89
|
+
v
|
|
90
|
+
LivequeryCore
|
|
91
|
+
/ \
|
|
92
|
+
v v
|
|
93
|
+
LivequeryStorge LivequeryTransporter(s)
|
|
71
94
|
```
|
|
72
95
|
|
|
73
|
-
|
|
96
|
+
- `LivequeryCollection` manages reactive state for one collection or document ref.
|
|
97
|
+
- `LivequeryDocument` wraps one item as a `BehaviorSubject` with convenience mutation methods.
|
|
98
|
+
- `LivequeryCore` coordinates storage, transporters, optimistic writes, and broadcast fan-out.
|
|
99
|
+
- `LivequeryStorge` is the local persistence contract.
|
|
100
|
+
- `LivequeryTransporter` is the remote sync contract.
|
|
74
101
|
|
|
75
102
|
## Quick Start
|
|
76
103
|
|
|
@@ -80,527 +107,353 @@ import {
|
|
|
80
107
|
LivequeryCore,
|
|
81
108
|
LivequeryMemoryStorage,
|
|
82
109
|
type Doc,
|
|
110
|
+
type LivequeryQueryResult,
|
|
83
111
|
type LivequeryTransporter,
|
|
84
112
|
} from "@livequery/core"
|
|
85
113
|
import { of } from "rxjs"
|
|
86
114
|
|
|
87
|
-
|
|
88
|
-
type Todo = Doc & {
|
|
115
|
+
type Todo = Doc<{
|
|
89
116
|
title: string
|
|
90
117
|
done: boolean
|
|
91
118
|
createdAt: number
|
|
92
|
-
}
|
|
119
|
+
}>
|
|
93
120
|
|
|
94
|
-
// 2. Create a storage (in-memory for this example)
|
|
95
121
|
const storage = new LivequeryMemoryStorage()
|
|
96
122
|
|
|
97
|
-
// 3. Create a transporter (no-op; replace with your real backend)
|
|
98
123
|
const transporter: LivequeryTransporter = {
|
|
99
|
-
query(
|
|
100
|
-
return of({
|
|
124
|
+
query() {
|
|
125
|
+
return of<Partial<LivequeryQueryResult>>({
|
|
126
|
+
changes: [],
|
|
127
|
+
summary: {},
|
|
128
|
+
paging: { total: 0, current: 0 },
|
|
129
|
+
source: "query",
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
async add(_ref, doc) {
|
|
133
|
+
return { id: crypto.randomUUID(), ...doc } as Todo
|
|
134
|
+
},
|
|
135
|
+
async update(_ref, id, doc) {
|
|
136
|
+
return { id, ...doc } as Todo
|
|
137
|
+
},
|
|
138
|
+
async delete(_ref, id) {
|
|
139
|
+
return { id } as Todo
|
|
140
|
+
},
|
|
141
|
+
async trigger(_action) {
|
|
142
|
+
return { ok: true }
|
|
101
143
|
},
|
|
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
144
|
}
|
|
107
145
|
|
|
108
|
-
// 4. Create the core
|
|
109
146
|
const core = new LivequeryCore({
|
|
110
147
|
storage,
|
|
111
|
-
transporters: {
|
|
148
|
+
transporters: {
|
|
149
|
+
primary: transporter,
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const todos = new LivequeryCollection<Todo>({
|
|
154
|
+
filters: { "createdAt:sort": "desc" },
|
|
155
|
+
mode: "server-first",
|
|
112
156
|
})
|
|
113
157
|
|
|
114
|
-
// 5. Create a reactive collection and initialize it
|
|
115
|
-
const todos = new LivequeryCollection<Todo>({ filters: { "createdAt:sort": "desc" } })
|
|
116
158
|
todos.initialize(core, "todos")
|
|
117
159
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
console.log("items:", docs.map((doc) => doc.value))
|
|
160
|
+
todos.items.subscribe((items) => {
|
|
161
|
+
console.log(items.map((doc) => doc.value))
|
|
121
162
|
})
|
|
122
|
-
todos.loading.subscribe((state) => console.log("loading:", state))
|
|
123
|
-
todos.paging.subscribe((p) => console.log("paging:", p))
|
|
124
163
|
|
|
125
|
-
|
|
126
|
-
await todos.query({ "createdAt:sort": "desc", ":limit": 20 })
|
|
127
|
-
|
|
128
|
-
// 8. Mutate data
|
|
164
|
+
await todos.query({ ":limit": 20, "createdAt:sort": "desc" })
|
|
129
165
|
await todos.add({ title: "Buy milk", done: false, createdAt: Date.now() })
|
|
130
|
-
await todos.update("
|
|
131
|
-
await todos.delete("
|
|
166
|
+
await todos.update("todo-1", { done: true })
|
|
167
|
+
await todos.delete("todo-1")
|
|
132
168
|
```
|
|
133
169
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
## Core Concepts
|
|
170
|
+
## `LivequeryCore`
|
|
137
171
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
Every document stored in livequery must extend `Doc<T>`:
|
|
172
|
+
Create one core with a storage adapter and one or more transporters:
|
|
141
173
|
|
|
142
174
|
```ts
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
175
|
+
const core = new LivequeryCore({
|
|
176
|
+
storage,
|
|
177
|
+
transporters: {
|
|
178
|
+
primary: transporter,
|
|
179
|
+
},
|
|
180
|
+
})
|
|
146
181
|
```
|
|
147
182
|
|
|
148
|
-
|
|
183
|
+
### Mutation flow
|
|
149
184
|
|
|
150
|
-
|
|
151
|
-
type Post = Doc & {
|
|
152
|
-
title: string
|
|
153
|
-
body: string
|
|
154
|
-
publishedAt: number
|
|
155
|
-
}
|
|
156
|
-
```
|
|
185
|
+
For `add`, `update`, and `delete`, the core:
|
|
157
186
|
|
|
158
|
-
|
|
187
|
+
1. writes to local storage first
|
|
188
|
+
2. broadcasts the optimistic change to active watchers
|
|
189
|
+
3. pushes the mutation to each transporter
|
|
190
|
+
4. clears optimistic flags or stores mutation errors after the remote call finishes
|
|
159
191
|
|
|
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
|
-
```
|
|
192
|
+
Documents created locally receive ids prefixed with `local:` until a transporter returns a persisted id.
|
|
169
193
|
|
|
170
|
-
|
|
194
|
+
### Query modes
|
|
171
195
|
|
|
172
|
-
|
|
196
|
+
Collections support three modes through `LivequeryCollectionOptions.mode`:
|
|
173
197
|
|
|
174
|
-
`
|
|
198
|
+
- `server-first`: collection reads are driven by the transporter layer. In practice, the collection waits for remote query results and builds state from transporter events.
|
|
199
|
+
- `cache-first`: the collection reads from local cache first, then pulls fresh data from the transporter and merges the result back in.
|
|
200
|
+
- `local-first`: the collection reads only from local cache for the query result, applies filters at the storage layer, then performs background synchronization so remote changes are applied silently afterward.
|
|
175
201
|
|
|
176
|
-
|
|
177
|
-
type LivequeryStorge = {
|
|
178
|
-
query<T extends Doc>(
|
|
179
|
-
collection: string,
|
|
180
|
-
filters?: Record<string, any>
|
|
181
|
-
): Promise<{ documents: T[]; paging: LivequeryPaging }>
|
|
202
|
+
Current implementation detail: in `local-first` mode, active filters are not forwarded to the transporter. The query result is filtered by the storage adapter, and added events are filtered again before they are rebroadcast into matching collections.
|
|
182
203
|
|
|
183
|
-
|
|
184
|
-
add<T extends Doc>(collection: string, document: T): Promise<T>
|
|
185
|
-
update<T extends Doc>(collection: string, id: string, document: Record<string, any>): Promise<T | null>
|
|
186
|
-
delete<T extends Doc>(collection: string, id: string): Promise<T | null>
|
|
187
|
-
}
|
|
188
|
-
```
|
|
204
|
+
## `LivequeryCollection`
|
|
189
205
|
|
|
190
|
-
|
|
206
|
+
`LivequeryCollection<T>` manages one collection or one document ref.
|
|
191
207
|
|
|
192
|
-
|
|
208
|
+
```ts
|
|
209
|
+
type LivequeryCollectionOptions<T extends Doc> = {
|
|
210
|
+
filters: Partial<LivequeryFilters<T>>
|
|
211
|
+
lazy: boolean
|
|
212
|
+
debounce: number
|
|
213
|
+
mode: "server-first" | "local-first" | "cache-first"
|
|
214
|
+
}
|
|
215
|
+
```
|
|
193
216
|
|
|
194
|
-
|
|
217
|
+
### Initialize a collection
|
|
195
218
|
|
|
196
219
|
```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>
|
|
220
|
+
const posts = new LivequeryCollection<Post>({
|
|
221
|
+
filters: { "publishedAt:sort": "desc" },
|
|
222
|
+
lazy: false,
|
|
223
|
+
debounce: 250,
|
|
224
|
+
mode: "cache-first",
|
|
225
|
+
})
|
|
207
226
|
|
|
208
|
-
|
|
209
|
-
trigger<T>(action: LivequeryAction): Promise<T>
|
|
210
|
-
}
|
|
227
|
+
posts.initialize(core, "posts")
|
|
211
228
|
```
|
|
212
229
|
|
|
213
|
-
|
|
230
|
+
`initialize()` 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
231
|
|
|
215
|
-
###
|
|
232
|
+
### Reactive state
|
|
216
233
|
|
|
217
|
-
|
|
234
|
+
- `items`: `BehaviorSubject<LivequeryDocument<DocState<T>>[]>`
|
|
235
|
+
- `summary`: `BehaviorSubject<Record<string, any>>`
|
|
236
|
+
- `loading`: `BehaviorSubject<null | "all" | "next" | "prev">`
|
|
237
|
+
- `filters`: `BehaviorSubject<Partial<LivequeryFilters<T>>>`
|
|
238
|
+
- `paging`: `BehaviorSubject<LivequeryPaging>`
|
|
239
|
+
- `error`: `BehaviorSubject<{ code: string; message: string } | null>`
|
|
240
|
+
|
|
241
|
+
`items` is a `BehaviorSubject`, not a plain array. Reading `collection.items.value` gives you the current snapshot only. If you need live updates, you must subscribe.
|
|
218
242
|
|
|
219
243
|
```ts
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
transporters: { // one or more named transporters
|
|
223
|
-
primary: myTransporter,
|
|
224
|
-
},
|
|
244
|
+
const subscription = posts.items.subscribe((items) => {
|
|
245
|
+
console.log("realtime items", items.map((doc) => doc.value))
|
|
225
246
|
})
|
|
247
|
+
|
|
248
|
+
// later
|
|
249
|
+
subscription.unsubscribe()
|
|
226
250
|
```
|
|
227
251
|
|
|
228
|
-
|
|
252
|
+
In React, using only `collection.items.value` during render will not cause rerenders when new events arrive. Bridge the `BehaviorSubject` into React state with `subscribe()`.
|
|
229
253
|
|
|
230
|
-
|
|
254
|
+
```tsx
|
|
255
|
+
function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) {
|
|
256
|
+
const [items, setItems] = useState(() => collection.items.value)
|
|
231
257
|
|
|
232
|
-
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
const subscription = collection.items.subscribe(setItems)
|
|
260
|
+
return () => subscription.unsubscribe()
|
|
261
|
+
}, [collection])
|
|
233
262
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
263
|
+
return (
|
|
264
|
+
<ul>
|
|
265
|
+
{items.map((item) => (
|
|
266
|
+
<li key={item.value.id}>{item.value.title}</li>
|
|
267
|
+
))}
|
|
268
|
+
</ul>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
```
|
|
240
272
|
|
|
241
|
-
|
|
242
|
-
|
|
273
|
+
### Main methods
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
query(filters: Partial<LivequeryFilters<T>>): Promise<void>
|
|
277
|
+
debounceQuery(filters: Partial<LivequeryFilters<T>>): Promise<void>
|
|
278
|
+
loadMore(): Promise<void>
|
|
279
|
+
loadPrev(): Promise<void>
|
|
280
|
+
loadAround(cursor: string): Promise<void>
|
|
281
|
+
add(payload: Partial<T>): Promise<T>
|
|
282
|
+
update(id: string, payload: Partial<T>): Promise<T | undefined>
|
|
283
|
+
delete(id: string): Promise<void | T | undefined>
|
|
284
|
+
trigger<R>(action: string, payload?: Record<string, any>): Observable<{ data: R; error?: Error }>
|
|
285
|
+
resetError(): void
|
|
286
|
+
watch(check: (prev: T, next: T) => boolean): Observable<[DocState<T>, DocState<T>]>
|
|
243
287
|
```
|
|
244
288
|
|
|
245
|
-
|
|
289
|
+
### Collection refs and document refs
|
|
246
290
|
|
|
247
|
-
|
|
248
|
-
|----------|------|-------------|
|
|
249
|
-
| `items` | `BehaviorSubject<LivequeryDocument<DocState<T>>[]>` | Current list of documents |
|
|
250
|
-
| `loading` | `BehaviorSubject<LivequeryLoadingState \| null>` | `null`, `'all'`, `'next'`, or `'prev'` |
|
|
251
|
-
| `filters` | `BehaviorSubject<Partial<LivequeryFilters<T>>>` | Active filters |
|
|
252
|
-
| `paging` | `BehaviorSubject<LivequeryPaging>` | Pagination info |
|
|
253
|
-
| `summary` | `BehaviorSubject<Record<string, any>>` | Aggregation data from transporter |
|
|
254
|
-
| `metadata` | `BehaviorSubject<Record<string, any>>` | Arbitrary metadata from transporter |
|
|
255
|
-
| `error` | `BehaviorSubject<{code: string, message: string} \| null>` | Last error from transporter |
|
|
291
|
+
If a ref has an even number of path segments, the last segment is treated as a document id.
|
|
256
292
|
|
|
257
293
|
```ts
|
|
258
|
-
posts.
|
|
259
|
-
|
|
260
|
-
// state is null | 'all' | 'next' | 'prev'
|
|
294
|
+
posts.initialize(core, "posts")
|
|
295
|
+
singlePost.initialize(core, "posts/post-1")
|
|
261
296
|
```
|
|
262
297
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
### LivequeryDocument
|
|
298
|
+
## `LivequeryDocument`
|
|
266
299
|
|
|
267
|
-
Each
|
|
300
|
+
Each item inside `collection.items` is a `LivequeryDocument`, which extends `BehaviorSubject<DocState<T>>`.
|
|
268
301
|
|
|
269
302
|
```ts
|
|
270
|
-
class LivequeryDocument<T extends Doc> extends BehaviorSubject<T
|
|
271
|
-
update(data: Partial<T>): Promise<
|
|
272
|
-
del(): Promise<void>
|
|
303
|
+
class LivequeryDocument<T extends Doc> extends BehaviorSubject<DocState<T>> {
|
|
304
|
+
update(data: Partial<T>): Promise<T | undefined>
|
|
305
|
+
del(): Promise<void | T | undefined>
|
|
273
306
|
trigger<R>(action: string, payload: Record<string, any>): Observable<{ data: R; error?: Error }>
|
|
274
307
|
}
|
|
275
308
|
```
|
|
276
309
|
|
|
277
|
-
|
|
278
|
-
const doc = posts.items.value[0]
|
|
310
|
+
Example:
|
|
279
311
|
|
|
280
|
-
|
|
281
|
-
|
|
312
|
+
```ts
|
|
313
|
+
const first = todos.items.value[0]
|
|
282
314
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
315
|
+
first.subscribe((doc) => {
|
|
316
|
+
console.log(doc.title, doc._updating)
|
|
317
|
+
})
|
|
286
318
|
|
|
287
|
-
|
|
288
|
-
|
|
319
|
+
await first.update({ done: true })
|
|
320
|
+
await first.del()
|
|
321
|
+
first.trigger("archive", { reason: "completed" }).subscribe()
|
|
289
322
|
```
|
|
290
323
|
|
|
291
|
-
|
|
324
|
+
## `LivequeryStorge`
|
|
292
325
|
|
|
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"` |
|
|
326
|
+
Local persistence adapters must implement:
|
|
322
327
|
|
|
323
328
|
```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": "",
|
|
329
|
+
type LivequeryStorge = {
|
|
330
|
+
query<T extends Doc>(
|
|
331
|
+
collection: string,
|
|
332
|
+
filters?: Record<string, any>
|
|
333
|
+
): Promise<{
|
|
334
|
+
documents: T[]
|
|
335
|
+
paging: LivequeryPaging
|
|
336
|
+
}>
|
|
337
|
+
get<T extends Doc>(ref: string, id: string): Promise<T | null>
|
|
338
|
+
add<T extends Doc>(collection: string, document: T): Promise<T>
|
|
339
|
+
update<T extends Doc>(collection: string, id: string, document: Record<string, any>): Promise<T | null>
|
|
340
|
+
delete<T extends Doc>(collection: string, id: string): Promise<T | null>
|
|
343
341
|
}
|
|
344
342
|
```
|
|
345
343
|
|
|
346
|
-
|
|
344
|
+
The package ships with `LivequeryMemoryStorage`, an in-memory adapter useful for tests, demos, and ephemeral state.
|
|
347
345
|
|
|
348
|
-
|
|
346
|
+
### `LivequeryMemoryStorage`
|
|
349
347
|
|
|
350
|
-
|
|
348
|
+
Behavior of the built-in adapter:
|
|
351
349
|
|
|
352
|
-
|
|
350
|
+
- stores documents in `Map<string, Map<string, Doc>>`
|
|
351
|
+
- generates a local id with `local:${crypto.randomUUID()}` when `id` is missing
|
|
352
|
+
- supports nested sort paths such as `profile.createdAt:sort`
|
|
353
|
+
- applies filters with the exported `filterDocs()` helper
|
|
353
354
|
|
|
354
|
-
|
|
355
|
-
const storage = new LivequeryMemoryStorage()
|
|
356
|
-
```
|
|
355
|
+
## `LivequeryTransporter`
|
|
357
356
|
|
|
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 |
|
|
357
|
+
Remote adapters must implement:
|
|
366
358
|
|
|
367
359
|
```ts
|
|
368
|
-
|
|
369
|
-
|
|
360
|
+
type LivequeryTransporter = {
|
|
361
|
+
query<T extends Doc>(query: LivequeryQueryParams<T>): Observable<Partial<LivequeryQueryResult>>
|
|
362
|
+
add<T extends Doc>(ref: string, doc: Omit<T, "id">): Promise<T>
|
|
363
|
+
update<T extends Doc>(ref: string, id: string, doc: Partial<T>): Promise<T>
|
|
364
|
+
delete<T extends Doc>(ref: string, id: string): Promise<T>
|
|
365
|
+
trigger<T>(action: LivequeryAction): Promise<T>
|
|
366
|
+
}
|
|
370
367
|
```
|
|
371
368
|
|
|
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 |
|
|
369
|
+
### Query result shape
|
|
390
370
|
|
|
391
371
|
```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
|
-
})
|
|
372
|
+
type LivequeryQueryResult = {
|
|
373
|
+
error: { code: string; message: string }
|
|
374
|
+
changes: DataChangeEvent[]
|
|
375
|
+
summary: Record<string, any>
|
|
376
|
+
paging: LivequeryPaging
|
|
377
|
+
metadata: Record<string, any>
|
|
378
|
+
source: "query" | "action" | "realtime"
|
|
379
|
+
loading?: "all" | "next" | "prev" | null
|
|
380
|
+
}
|
|
409
381
|
```
|
|
410
382
|
|
|
411
|
-
|
|
383
|
+
Transporters can emit partial results. In practice, the most important fields are `changes`, `paging`, `summary`, and `error`.
|
|
412
384
|
|
|
413
|
-
##
|
|
385
|
+
## Query Filters
|
|
414
386
|
|
|
415
|
-
|
|
387
|
+
Filters are flat keys derived from the document type.
|
|
416
388
|
|
|
417
|
-
|
|
418
|
-
import { Observable } from "rxjs"
|
|
419
|
-
import type {
|
|
420
|
-
LivequeryTransporter, Doc,
|
|
421
|
-
LivequeryQueryParams, LivequeryAction
|
|
422
|
-
} from "@livequery/core"
|
|
389
|
+
### Pagination keys
|
|
423
390
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
},
|
|
391
|
+
- `:limit`
|
|
392
|
+
- `:before`
|
|
393
|
+
- `:after`
|
|
394
|
+
- `:around`
|
|
395
|
+
- `:page`
|
|
442
396
|
|
|
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
|
-
},
|
|
397
|
+
### Supported operators
|
|
451
398
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
399
|
+
- `field:sort` with `"asc" | "desc"` for string and number fields
|
|
400
|
+
- `field:gt`, `field:gte`, `field:lt`, `field:lte` for numeric fields
|
|
401
|
+
- `field:eq-number` for numeric equality
|
|
402
|
+
- `field` for string equality
|
|
403
|
+
- `field:in`, `field:nin` for string or number membership
|
|
404
|
+
- `field:include` for array containment
|
|
405
|
+
- `field:boolean` with `"true" | "false" | "not-true" | "not-false"`
|
|
406
|
+
- `field:like` for case-insensitive substring matching on strings
|
|
407
|
+
- `field:null` with `"null-only" | "not-null"`
|
|
460
408
|
|
|
461
|
-
|
|
462
|
-
const res = await fetch(`/api/${ref}/${id}`, { method: "DELETE" })
|
|
463
|
-
return res.json() as Promise<T>
|
|
464
|
-
},
|
|
465
|
-
|
|
466
|
-
async trigger<T>(action: LivequeryAction) {
|
|
467
|
-
const res = await fetch(`/api/${action.ref}/${action.action}`, {
|
|
468
|
-
method: "POST",
|
|
469
|
-
headers: { "Content-Type": "application/json" },
|
|
470
|
-
body: JSON.stringify(action.payload),
|
|
471
|
-
})
|
|
472
|
-
return res.json() as Promise<T>
|
|
473
|
-
},
|
|
474
|
-
}
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
---
|
|
478
|
-
|
|
479
|
-
## Writing a Custom Storage Adapter
|
|
480
|
-
|
|
481
|
-
Implement `LivequeryStorge` to persist data in `localStorage`, `IndexedDB`, SQLite, etc.:
|
|
409
|
+
Nested field paths are supported, for example `"profile.createdAt:sort"`.
|
|
482
410
|
|
|
483
411
|
```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
|
-
}
|
|
412
|
+
await todos.query({
|
|
413
|
+
":limit": 20,
|
|
414
|
+
"done:boolean": "false",
|
|
415
|
+
"title:like": "milk",
|
|
416
|
+
"createdAt:gte": 1714176000000,
|
|
417
|
+
"createdAt:sort": "desc",
|
|
418
|
+
})
|
|
530
419
|
```
|
|
531
420
|
|
|
532
|
-
|
|
421
|
+
## Helper Exports
|
|
533
422
|
|
|
534
|
-
|
|
423
|
+
### `filterDocs()`
|
|
535
424
|
|
|
536
425
|
```ts
|
|
537
|
-
|
|
538
|
-
type Doc<T = {}> = T & { id: string }
|
|
426
|
+
import { filterDocs } from "@livequery/core"
|
|
539
427
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
}
|
|
563
|
-
|
|
564
|
-
// Result streamed back from a transporter query
|
|
565
|
-
type LivequeryQueryResult = {
|
|
566
|
-
error: { code: string, message: string }
|
|
567
|
-
changes: DataChangeEvent[]
|
|
568
|
-
summary: Record<string, any>
|
|
569
|
-
paging: LivequeryPaging
|
|
570
|
-
metadata: Record<string, any>
|
|
571
|
-
source: 'query' | 'action' | 'realtime'
|
|
572
|
-
}
|
|
428
|
+
const visible = filterDocs(docs, {
|
|
429
|
+
"done:boolean": "false",
|
|
430
|
+
"title:like": "milk",
|
|
431
|
+
})
|
|
432
|
+
```
|
|
573
433
|
|
|
574
|
-
|
|
575
|
-
type LivequeryAction = {
|
|
576
|
-
ref: string
|
|
577
|
-
action: string
|
|
578
|
-
payload?: Record<string, any>
|
|
579
|
-
}
|
|
434
|
+
### `matchesAllFilters()`
|
|
580
435
|
|
|
581
|
-
|
|
582
|
-
type LivequeryPaging = {
|
|
583
|
-
total: number
|
|
584
|
-
current: number
|
|
585
|
-
next?: { count: number; cursor: string }
|
|
586
|
-
prev?: { count: number; cursor: string }
|
|
587
|
-
}
|
|
436
|
+
The helper module also exports `matchesAllFilters(doc, filters)` for direct predicate checks.
|
|
588
437
|
|
|
589
|
-
|
|
590
|
-
type LivequeryLoadingState = null | 'next' | 'prev' | 'all'
|
|
591
|
-
```
|
|
438
|
+
## Notes
|
|
592
439
|
|
|
593
|
-
|
|
440
|
+
- `initialize()` is browser-only in the current implementation
|
|
441
|
+
- the public storage interface name is spelled `LivequeryStorge`, matching the source
|
|
442
|
+
- optimistic flags such as `_adding`, `_updating`, and `_deleting` are system fields managed by the core
|
|
443
|
+
- remote query streams are expected to emit `changes` rather than full snapshots
|
|
444
|
+
- `LivequeryCollection` declares a `metadata` field, but it is not initialized in the current constructor, so transporter-emitted `metadata` is not safe to rely on through the collection API yet
|
|
445
|
+
- `trigger()` is typed as `Observable<{ data, error? }>` at the collection and document layer, but the current runtime implementation forwards raw transporter results without wrapping them
|
|
594
446
|
|
|
595
|
-
##
|
|
447
|
+
## Development
|
|
596
448
|
|
|
597
449
|
```bash
|
|
598
450
|
bun run build
|
|
599
451
|
```
|
|
600
452
|
|
|
601
|
-
|
|
453
|
+
Available scripts:
|
|
602
454
|
|
|
603
|
-
|
|
604
|
-
bun run build:
|
|
605
|
-
bun run
|
|
606
|
-
|
|
455
|
+
- `bun run clean`
|
|
456
|
+
- `bun run build:js`
|
|
457
|
+
- `bun run build:types`
|
|
458
|
+
- `bun run build`
|
|
459
|
+
- `bun run build:watch`
|