@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.
Files changed (40) hide show
  1. package/README.md +320 -460
  2. package/dist/LivequeryCollection.d.ts +19 -17
  3. package/dist/LivequeryCollection.d.ts.map +1 -1
  4. package/dist/LivequeryCollection.js +231 -0
  5. package/dist/LivequeryCollection.js.map +1 -0
  6. package/dist/LivequeryCore.d.ts +4 -4
  7. package/dist/LivequeryCore.d.ts.map +1 -1
  8. package/dist/LivequeryCore.js +337 -0
  9. package/dist/LivequeryCore.js.map +1 -0
  10. package/dist/LivequeryDocument.d.ts +2 -2
  11. package/dist/LivequeryDocument.d.ts.map +1 -1
  12. package/dist/LivequeryDocument.js +22 -0
  13. package/dist/LivequeryDocument.js.map +1 -0
  14. package/dist/LivequeryMemoryStorage.d.ts +2 -2
  15. package/dist/LivequeryMemoryStorage.d.ts.map +1 -1
  16. package/dist/LivequeryMemoryStorage.js +89 -0
  17. package/dist/LivequeryMemoryStorage.js.map +1 -0
  18. package/dist/LivequeryStorge.d.ts +1 -1
  19. package/dist/LivequeryStorge.d.ts.map +1 -1
  20. package/dist/LivequeryStorge.js +2 -0
  21. package/dist/LivequeryStorge.js.map +1 -0
  22. package/dist/LivequeryTransporter.d.ts +1 -1
  23. package/dist/LivequeryTransporter.d.ts.map +1 -1
  24. package/dist/LivequeryTransporter.js +2 -0
  25. package/dist/LivequeryTransporter.js.map +1 -0
  26. package/dist/helpers/filterDocs.d.ts +1 -1
  27. package/dist/helpers/filterDocs.d.ts.map +1 -1
  28. package/dist/helpers/filterDocs.js +80 -0
  29. package/dist/helpers/filterDocs.js.map +1 -0
  30. package/dist/helpers/tryCatch.js +10 -0
  31. package/dist/helpers/tryCatch.js.map +1 -0
  32. package/dist/helpers/whenCompleted.js +5 -0
  33. package/dist/helpers/whenCompleted.js.map +1 -0
  34. package/dist/index.d.ts +8 -8
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +9 -3167
  37. package/dist/index.js.map +1 -100
  38. package/dist/types.js +2 -0
  39. package/dist/types.js.map +1 -0
  40. package/package.json +73 -4
package/README.md CHANGED
@@ -1,76 +1,97 @@
1
1
  # @livequery/core
2
2
 
3
- A local-first reactive data library for browser clients. Type-safe, RxJS-based collection system with pluggable storage and transporter adapters, optimistic local mutations, and real-time synchronisation support.
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
- ## Architecture
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
- │ Your Application │
32
- │ LivequeryCollection │
33
- │ .items / .loading / .paging │
34
- │ .query() / .add() / .update() / .delete()
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
- **Data flow for a mutation (add / update / delete):**
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
- **Data flow for a query:**
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
- ## Installation
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
- ```bash
68
- npm install @livequery/core rxjs
69
- # or
70
- bun add @livequery/core rxjs
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
- // 1. Define your document shape
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({ changes: [], summary: {}, paging: { total: 0, current: 0 }, metadata: {}, source: "query" as const })
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: { primary: transporter },
143
+ transporters: {
144
+ primary: transporter,
145
+ },
112
146
  })
113
147
 
114
- // 5. Create a reactive collection and initialize it
115
- const todos = new LivequeryCollection<Todo>({ filters: { "createdAt:sort": "desc" } })
116
- todos.initialize(core, "todos")
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
- // 7. Query with filters
126
- await todos.query({ "createdAt:sort": "desc", ":limit": 20 })
153
+ todos.initialize("todos")
127
154
 
128
- // 8. Mutate data
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("some-id", { done: true })
131
- await todos.delete("some-id")
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
- ### Doc
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
- type Doc<T = {}> = T & {
144
- id: string
145
- }
170
+ const core = new LivequeryCore({
171
+ storage,
172
+ transporters: {
173
+ primary: transporter,
174
+ },
175
+ })
146
176
  ```
147
177
 
148
- Your types extend this base:
178
+ ### Mutation flow
149
179
 
150
- ```ts
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
- When a document is held inside a `LivequeryCollection`, it is wrapped in `DocState<T>` which adds optimistic-update tracking fields:
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
- ```ts
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
- ### LivequeryStorge
191
+ Collections support three modes through `LivequeryCollectionOptions.mode`:
173
192
 
174
- `LivequeryStorge` is the interface for local persistence. The library ships with `LivequeryMemoryStorage`. You can create adapters for `localStorage`, `IndexedDB`, SQLite, etc.
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
- ```ts
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
- get<T extends Doc>(ref: string, id: string): Promise<T | null>
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>
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
- A transporter connects the core to a remote backend (REST API, WebSocket, Firebase, etc.). You can provide **multiple** transporters; the core fans out queries and mutations to all of them.
215
+ The current constructor takes `core` as the first argument and options as the second argument.
195
216
 
196
217
  ```ts
197
- type LivequeryTransporter = {
198
- // Called for every query. Returns an Observable so the remote can stream realtime updates.
199
- query<T extends Doc>(
200
- query: LivequeryQueryParams<T>
201
- ): Observable<Partial<LivequeryQueryResult>>
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
- // Called for custom actions
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
- ### LivequeryCore
230
+ ### Collection refs and document refs
216
231
 
217
- The central coordinator. Instantiate once and share across your app.
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
- const core = new LivequeryCore({
221
- storage, // LivequeryStorge implementation
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
- ### LivequeryCollection
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
- `LivequeryCollection<T>` holds reactive state for one collection path (`ref`). Its state is exposed as a set of `BehaviorSubject` properties.
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 posts = new LivequeryCollection<Post>({
236
- filters: { "publishedAt:sort": "desc" },
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
- // Wire up the core and the collection path, then start watching
242
- posts.initialize(core, "posts")
257
+ subscription.unsubscribe()
243
258
  ```
244
259
 
245
- #### Reactive state properties
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
- | Property | Type | Description |
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 |
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
- posts.items.subscribe((docs) => console.log(docs.map(d => d.value)))
259
- posts.loading.subscribe((state) => console.log("loading:", state))
260
- // state is null | 'all' | 'next' | 'prev'
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
- ### LivequeryDocument
305
+ ## `LivequeryDocument`
266
306
 
267
- Each element of `collection.items.value` is a `LivequeryDocument<DocState<T>>`, which extends `BehaviorSubject<T>`. It provides convenient mutation helpers scoped to that document.
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<void>
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
- ```ts
278
- const doc = posts.items.value[0]
317
+ Example:
279
318
 
280
- // Subscribe to individual document changes
281
- doc.subscribe((post) => console.log("post changed:", post))
319
+ ```ts
320
+ const first = todos.items.value[0]
282
321
 
283
- // Mutate directly on the document
284
- await doc.update({ title: "Updated title" })
285
- await doc.del()
322
+ first.subscribe((doc) => {
323
+ console.log(doc.title, doc._updating)
324
+ })
286
325
 
287
- // Fire a custom action
288
- doc.trigger("publish", { scheduledAt: Date.now() }).subscribe()
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
- ## Query Filters
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 Article = Doc & {
325
- score: number
326
- tags: string[]
327
- title: string
328
- archived: boolean
329
- deletedAt: number | null
330
- }
331
-
332
- const filters: LivequeryFilters<Article> = {
333
- "score:gte": 5,
334
- "tags:include": "typescript",
335
- "title:like": "livequery",
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
- ## API Reference
353
+ ### `LivequeryMemoryStorage`
349
354
 
350
- ### LivequeryMemoryStorage
355
+ The built-in adapter:
351
356
 
352
- An in-memory `LivequeryStorge` implementation backed by a `Map`. Data is lost on page reload. Useful for testing and offline-first prototypes.
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
- ```ts
355
- const storage = new LivequeryMemoryStorage()
356
- ```
362
+ ## `LivequeryTransporter`
357
363
 
358
- | Method | Signature | Description |
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
- storage.clear("todos") // clear one collection
369
- storage.clear() // clear everything
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
- // Paginate
393
- await collection.loadMore()
394
- await collection.loadPrev()
395
- await collection.loadAround("cursor-abc")
396
-
397
- // Mutate
398
- await collection.add({ title: "New item", done: false, createdAt: Date.now() })
399
- await collection.update("doc-id", { done: true })
400
- await collection.delete("doc-id")
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
- ```ts
418
- import { Observable } from "rxjs"
419
- import type {
420
- LivequeryTransporter, Doc,
421
- LivequeryQueryParams, LivequeryAction
422
- } from "@livequery/core"
392
+ ## Query Filters
423
393
 
424
- const httpTransporter: LivequeryTransporter = {
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
- async add<T extends Doc>(ref: string, doc: Omit<T, 'id'>) {
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
- async update<T extends Doc>(ref: string, id: string, doc: Partial<T>) {
453
- const res = await fetch(`/api/${ref}/${id}`, {
454
- method: "PATCH",
455
- headers: { "Content-Type": "application/json" },
456
- body: JSON.stringify(doc),
457
- })
458
- return res.json() as Promise<T>
459
- },
398
+ - `:limit`
399
+ - `:before`
400
+ - `:after`
401
+ - `:around`
402
+ - `:page`
460
403
 
461
- async delete<T extends Doc>(ref: string, id: string) {
462
- const res = await fetch(`/api/${ref}/${id}`, { method: "DELETE" })
463
- return res.json() as Promise<T>
464
- },
404
+ ### Supported operators
465
405
 
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
- ---
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
- ## Writing a Custom Storage Adapter
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
- import type { LivequeryStorge, Doc, LivequeryPaging } from "@livequery/core"
485
-
486
- class LocalStorageAdapter implements LivequeryStorge {
487
- private read<T>(collection: string): T[] {
488
- return JSON.parse(localStorage.getItem(collection) ?? "[]")
489
- }
490
- private write<T>(collection: string, docs: T[]) {
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
- ## Types Reference
430
+ ### `filterDocs()`
535
431
 
536
432
  ```ts
537
- // Base document type all documents must have an `id`
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
- // 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
- }
435
+ const visible = filterDocs(docs, {
436
+ "done:boolean": "false",
437
+ "title:like": "milk",
438
+ })
439
+ ```
573
440
 
574
- // Action sent to a transporter for custom operations
575
- type LivequeryAction = {
576
- ref: string
577
- action: string
578
- payload?: Record<string, any>
579
- }
441
+ ### `matchesAllFilters()`
580
442
 
581
- // Pagination info
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
- // Loading state for a collection
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
- ## Build
454
+ ## Development
596
455
 
597
456
  ```bash
598
457
  bun run build
599
458
  ```
600
459
 
601
- Output is placed in `dist/` as ESM with TypeScript declarations (browser target).
460
+ Available scripts:
602
461
 
603
- ```bash
604
- bun run build:watch # watch mode (JS only, no type declarations)
605
- bun run clean # remove dist/
606
- ```
462
+ - `bun run clean`
463
+ - `bun run build:js`
464
+ - `bun run build:types`
465
+ - `bun run build`
466
+ - `bun run build:watch`