@livequery/core 2.0.91 → 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.
Files changed (2) hide show
  1. package/README.md +316 -463
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,76 +1,103 @@
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. The package combines RxJS-based collections, pluggable local storage, pluggable remote transporters, optimistic mutations, and typed inline filters.
26
4
 
27
- ## Architecture
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
- │ 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
- └──────────────┘ └────────────────────┘
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
- **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.
23
+ The package is published as ESM and targets browser usage.
56
24
 
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.
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
- ## Installation
29
+ The public API is re-exported from `src/index.ts`:
66
30
 
67
- ```bash
68
- npm install @livequery/core rxjs
69
- # or
70
- bun add @livequery/core rxjs
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
- // 1. Define your document shape
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(_query) {
100
- return of({ changes: [], summary: {}, paging: { total: 0, current: 0 }, metadata: {}, source: "query" as const })
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: { primary: transporter },
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
- // 6. Subscribe to reactive state
119
- todos.items.subscribe((docs) => {
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
- // 7. Query with filters
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("some-id", { done: true })
131
- await todos.delete("some-id")
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
- ### Doc
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
- type Doc<T = {}> = T & {
144
- id: string
145
- }
175
+ const core = new LivequeryCore({
176
+ storage,
177
+ transporters: {
178
+ primary: transporter,
179
+ },
180
+ })
146
181
  ```
147
182
 
148
- Your types extend this base:
183
+ ### Mutation flow
149
184
 
150
- ```ts
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
- When a document is held inside a `LivequeryCollection`, it is wrapped in `DocState<T>` which adds optimistic-update tracking fields:
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
- ```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
- ```
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
- ### LivequeryStorge
196
+ Collections support three modes through `LivequeryCollectionOptions.mode`:
173
197
 
174
- `LivequeryStorge` is the interface for local persistence. The library ships with `LivequeryMemoryStorage`. You can create adapters for `localStorage`, `IndexedDB`, SQLite, etc.
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
- ```ts
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
- 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>
187
- }
188
- ```
204
+ ## `LivequeryCollection`
189
205
 
190
- ---
206
+ `LivequeryCollection<T>` manages one collection or one document ref.
191
207
 
192
- ### LivequeryTransporter
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
- 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.
217
+ ### Initialize a collection
195
218
 
196
219
  ```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>
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
- // Called for custom actions
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
- ### LivequeryCore
232
+ ### Reactive state
216
233
 
217
- The central coordinator. Instantiate once and share across your app.
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 core = new LivequeryCore({
221
- storage, // LivequeryStorge implementation
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
- ### LivequeryCollection
254
+ ```tsx
255
+ function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) {
256
+ const [items, setItems] = useState(() => collection.items.value)
231
257
 
232
- `LivequeryCollection<T>` holds reactive state for one collection path (`ref`). Its state is exposed as a set of `BehaviorSubject` properties.
258
+ useEffect(() => {
259
+ const subscription = collection.items.subscribe(setItems)
260
+ return () => subscription.unsubscribe()
261
+ }, [collection])
233
262
 
234
- ```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()
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
- // Wire up the core and the collection path, then start watching
242
- posts.initialize(core, "posts")
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
- #### Reactive state properties
289
+ ### Collection refs and document refs
246
290
 
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 |
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.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'
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 element of `collection.items.value` is a `LivequeryDocument<DocState<T>>`, which extends `BehaviorSubject<T>`. It provides convenient mutation helpers scoped to that document.
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<void>
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
- ```ts
278
- const doc = posts.items.value[0]
310
+ Example:
279
311
 
280
- // Subscribe to individual document changes
281
- doc.subscribe((post) => console.log("post changed:", post))
312
+ ```ts
313
+ const first = todos.items.value[0]
282
314
 
283
- // Mutate directly on the document
284
- await doc.update({ title: "Updated title" })
285
- await doc.del()
315
+ first.subscribe((doc) => {
316
+ console.log(doc.title, doc._updating)
317
+ })
286
318
 
287
- // Fire a custom action
288
- doc.trigger("publish", { scheduledAt: Date.now() }).subscribe()
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
- ## 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"` |
326
+ Local persistence adapters must implement:
322
327
 
323
328
  ```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": "",
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
- ## API Reference
346
+ ### `LivequeryMemoryStorage`
349
347
 
350
- ### LivequeryMemoryStorage
348
+ Behavior of the built-in adapter:
351
349
 
352
- An in-memory `LivequeryStorge` implementation backed by a `Map`. Data is lost on page reload. Useful for testing and offline-first prototypes.
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
- ```ts
355
- const storage = new LivequeryMemoryStorage()
356
- ```
355
+ ## `LivequeryTransporter`
357
356
 
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 |
357
+ Remote adapters must implement:
366
358
 
367
359
  ```ts
368
- storage.clear("todos") // clear one collection
369
- storage.clear() // clear everything
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
- // 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
- })
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
- ## Writing a Custom Transporter
385
+ ## Query Filters
414
386
 
415
- Implement `LivequeryTransporter` to connect to any backend:
387
+ Filters are flat keys derived from the document type.
416
388
 
417
- ```ts
418
- import { Observable } from "rxjs"
419
- import type {
420
- LivequeryTransporter, Doc,
421
- LivequeryQueryParams, LivequeryAction
422
- } from "@livequery/core"
389
+ ### Pagination keys
423
390
 
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
- },
391
+ - `:limit`
392
+ - `:before`
393
+ - `:after`
394
+ - `:around`
395
+ - `:page`
442
396
 
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
- },
397
+ ### Supported operators
451
398
 
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
- },
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
- 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
- },
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
- 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
- }
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
- ## Types Reference
423
+ ### `filterDocs()`
535
424
 
536
425
  ```ts
537
- // Base document type all documents must have an `id`
538
- type Doc<T = {}> = T & { id: string }
426
+ import { filterDocs } from "@livequery/core"
539
427
 
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
- }
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
- // Action sent to a transporter for custom operations
575
- type LivequeryAction = {
576
- ref: string
577
- action: string
578
- payload?: Record<string, any>
579
- }
434
+ ### `matchesAllFilters()`
580
435
 
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
- }
436
+ The helper module also exports `matchesAllFilters(doc, filters)` for direct predicate checks.
588
437
 
589
- // Loading state for a collection
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
- ## Build
447
+ ## Development
596
448
 
597
449
  ```bash
598
450
  bun run build
599
451
  ```
600
452
 
601
- Output is placed in `dist/` as ESM with TypeScript declarations (browser target).
453
+ Available scripts:
602
454
 
603
- ```bash
604
- bun run build:watch # watch mode (JS only, no type declarations)
605
- bun run clean # remove dist/
606
- ```
455
+ - `bun run clean`
456
+ - `bun run build:js`
457
+ - `bun run build:types`
458
+ - `bun run build`
459
+ - `bun run build:watch`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livequery/core",
3
- "version": "2.0.91",
3
+ "version": "2.0.92",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",