@livequery/rpc 2.0.67
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 +669 -0
- package/dist/RpcChannel.d.ts +21 -0
- package/dist/RpcChannel.d.ts.map +1 -0
- package/dist/ServiceLinker.d.ts +9 -0
- package/dist/ServiceLinker.d.ts.map +1 -0
- package/dist/SharedWorkerChannel.d.ts +8 -0
- package/dist/SharedWorkerChannel.d.ts.map +1 -0
- package/dist/WorkerManager.d.ts +8 -0
- package/dist/WorkerManager.d.ts.map +1 -0
- package/dist/WorkerService.d.ts +5 -0
- package/dist/WorkerService.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1848 -0
- package/dist/index.js.map +59 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
# @livequery/new
|
|
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
|
+
- [WorkerRpc](#workerrpc)
|
|
22
|
+
- [Writing a Custom Transporter](#writing-a-custom-transporter)
|
|
23
|
+
- [Writing a Custom Storage Adapter](#writing-a-custom-storage-adapter)
|
|
24
|
+
- [Types Reference](#types-reference)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Architecture
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
┌─────────────────────────────────────┐
|
|
32
|
+
│ Your Application │
|
|
33
|
+
│ LivequeryCollection │
|
|
34
|
+
│ .items / .loading / .paging │
|
|
35
|
+
│ .query() / .add() / .update() / .delete()
|
|
36
|
+
└──────────────┬──────────────────────┘
|
|
37
|
+
│
|
|
38
|
+
┌──────────────▼──────────────────────┐
|
|
39
|
+
│ LivequeryCore │
|
|
40
|
+
│ - coordinates storage & transport │
|
|
41
|
+
│ - optimistic local mutations │
|
|
42
|
+
│ - broadcasts changes to collections│
|
|
43
|
+
└───────┬──────────────┬──────────────┘
|
|
44
|
+
│ │
|
|
45
|
+
┌───────▼──────┐ ┌─────▼──────────────┐
|
|
46
|
+
│ LivequeryStorge│ │ LivequeryTransporter│
|
|
47
|
+
│ (local) │ │ (remote backend) │
|
|
48
|
+
│ │ │ (can be many) │
|
|
49
|
+
└──────────────┘ └────────────────────┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Data flow for a mutation (add / update / delete):**
|
|
53
|
+
1. `LivequeryCollection.add/update/delete` calls `LivequeryCore.trigger`.
|
|
54
|
+
2. The core applies the change to storage immediately (optimistic update).
|
|
55
|
+
3. The change is broadcast to all live collections watching the same `ref`.
|
|
56
|
+
4. The core then calls every configured transporter to sync the change remotely.
|
|
57
|
+
|
|
58
|
+
**Data flow for a query:**
|
|
59
|
+
1. `LivequeryCollection.query(filters)` calls `LivequeryCore.query`.
|
|
60
|
+
2. The core returns locally-stored documents instantly from storage.
|
|
61
|
+
3. In parallel, it fires the query against every transporter.
|
|
62
|
+
4. Each transporter streams `DataChangeEvent[]` back into the collection, which merges them reactively.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install @livequery/new rxjs
|
|
70
|
+
# or
|
|
71
|
+
bun add @livequery/new rxjs
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import {
|
|
80
|
+
LivequeryCollection,
|
|
81
|
+
LivequeryCore,
|
|
82
|
+
LivequeryMemoryStorage,
|
|
83
|
+
type Doc,
|
|
84
|
+
type LivequeryTransporter,
|
|
85
|
+
} from "@livequery/new"
|
|
86
|
+
import { of } from "rxjs"
|
|
87
|
+
|
|
88
|
+
// 1. Define your document shape
|
|
89
|
+
type Todo = Doc & {
|
|
90
|
+
title: string
|
|
91
|
+
done: boolean
|
|
92
|
+
createdAt: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Create a storage (in-memory for this example)
|
|
96
|
+
const storage = new LivequeryMemoryStorage()
|
|
97
|
+
|
|
98
|
+
// 3. Create a transporter (no-op; replace with your real backend)
|
|
99
|
+
const transporter: LivequeryTransporter = {
|
|
100
|
+
query(query) {
|
|
101
|
+
return of({ query_id: query.query_id, changes: [], summary: {}, paging: { total: 0, current: 0 }, metadata: {}, source: "query" })
|
|
102
|
+
},
|
|
103
|
+
trigger(_action) {
|
|
104
|
+
return of({ data: {} as any })
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Create the core
|
|
109
|
+
const core = new LivequeryCore({
|
|
110
|
+
storage,
|
|
111
|
+
transporters: { primary: transporter },
|
|
112
|
+
resolver: ({ change, old_document }) => ({
|
|
113
|
+
approved: true,
|
|
114
|
+
document: { ...old_document, ...change.data } as Todo,
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// 5. Create a reactive collection
|
|
119
|
+
const todos = new LivequeryCollection<Todo>({
|
|
120
|
+
core,
|
|
121
|
+
ref: "todos",
|
|
122
|
+
filters: { "createdAt:sort": "desc", ":limit": 20, ":page": 1, ":before": "", ":after": "" },
|
|
123
|
+
lazy: true,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// 6. Call initialize() to start watching (required)
|
|
127
|
+
todos.initialize()
|
|
128
|
+
|
|
129
|
+
// 7. Subscribe to reactive state
|
|
130
|
+
todos.items.subscribe((docs) => {
|
|
131
|
+
console.log("items:", docs.map((doc$) => doc$.value))
|
|
132
|
+
})
|
|
133
|
+
todos.loading.subscribe((state) => console.log("loading:", state))
|
|
134
|
+
todos.paging.subscribe((p) => console.log("paging:", p))
|
|
135
|
+
|
|
136
|
+
// 8. Trigger a query
|
|
137
|
+
await todos.query({ "createdAt:sort": "desc", ":limit": 20, ":page": 1, ":before": "", ":after": "" })
|
|
138
|
+
|
|
139
|
+
// 9. Mutate data
|
|
140
|
+
await todos.add({ title: "Buy milk", done: false, createdAt: Date.now() })
|
|
141
|
+
await todos.update("some-id", { done: true })
|
|
142
|
+
await todos.delete("some-id")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Core Concepts
|
|
148
|
+
|
|
149
|
+
### Doc
|
|
150
|
+
|
|
151
|
+
Every document stored in livequery must extend `Doc<T>`:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
type Doc<T = {}> = T & {
|
|
155
|
+
id: string
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Your types extend this base:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
type Post = Doc & {
|
|
163
|
+
title: string
|
|
164
|
+
body: string
|
|
165
|
+
publishedAt: number
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
When a document is held inside a `LivequeryCollection`, it is wrapped in `DocState<T>` which adds optimistic-update tracking fields:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
type DocState<T extends Doc> = T & {
|
|
173
|
+
_deleting?: boolean // pending deletion
|
|
174
|
+
_updating?: boolean // pending update
|
|
175
|
+
_adding?: boolean // pending add
|
|
176
|
+
_remotes?: Record<string, string | number> // per-transporter version cursors
|
|
177
|
+
_prev?: Partial<T> // previous values before last local mutation
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### LivequeryStorge
|
|
184
|
+
|
|
185
|
+
`LivequeryStorge` is the interface for local persistence. The library ships with `LivequeryMemoryStorage`. You can create adapters for `localStorage`, `IndexedDB`, SQLite, etc.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
type LivequeryStorge = {
|
|
189
|
+
query<T extends Doc>(
|
|
190
|
+
collection: string,
|
|
191
|
+
filters?: Record<string, any>
|
|
192
|
+
): Promise<{ documents: T[]; paging: LivequeryPaging }>
|
|
193
|
+
|
|
194
|
+
get<T extends Doc>(ref: string, id: string): Promise<T | null>
|
|
195
|
+
add<T extends Doc>(collection: string, document: T): Promise<T>
|
|
196
|
+
update<T extends Doc>(collection: string, id: string, document: Record<string, any>): Promise<T | null>
|
|
197
|
+
delete<T extends Doc>(collection: string, id: string): Promise<T | null>
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### LivequeryTransporter
|
|
204
|
+
|
|
205
|
+
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.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
type LivequeryTransporter = {
|
|
209
|
+
// Called for every query. Returns an Observable so the remote can stream results.
|
|
210
|
+
query<T extends Doc>(
|
|
211
|
+
query: LivequeryQueryParams<T>
|
|
212
|
+
): Observable<Partial<LivequeryQueryResult<T>>>
|
|
213
|
+
|
|
214
|
+
// Called for add / update / delete / custom actions.
|
|
215
|
+
trigger<T>(action: LivequeryAction): Observable<{ data: T; error?: Error }>
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### LivequeryCore
|
|
222
|
+
|
|
223
|
+
The central coordinator. Instantiate once and share across your app.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const core = new LivequeryCore({
|
|
227
|
+
storage, // LivequeryStorge implementation
|
|
228
|
+
transporters: { // one or more named transporters
|
|
229
|
+
primary: myTransporter,
|
|
230
|
+
},
|
|
231
|
+
resolver, // ConflictResolverFunction
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### Conflict resolver
|
|
236
|
+
|
|
237
|
+
Called for every local mutation. Decides whether to approve the change and what the final merged document should be.
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
type ConflictResolverFunction = <T extends Doc>(e: {
|
|
241
|
+
from: Record<string, string | number | boolean> // remote version cursors (_remotes)
|
|
242
|
+
old_document: T // current local document
|
|
243
|
+
change: DataChangeEvent<T> // incoming change
|
|
244
|
+
}) => {
|
|
245
|
+
approved: boolean // false → discard the change
|
|
246
|
+
document: T // resolved document to persist
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Simple last-write-wins example:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
const resolver: ConflictResolverFunction = ({ change, old_document }) => ({
|
|
254
|
+
approved: true,
|
|
255
|
+
document: { ...old_document, ...change.data } as typeof old_document,
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
### LivequeryCollection
|
|
262
|
+
|
|
263
|
+
`LivequeryCollection<T>` holds reactive state for one collection path (`ref`). Its state is exposed as a set of `BehaviorSubject` properties.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const posts = new LivequeryCollection<Post>({
|
|
267
|
+
core,
|
|
268
|
+
ref: "posts",
|
|
269
|
+
filters: { "publishedAt:sort": "desc", ":limit": 10, ":page": 1, ":before": "", ":after": "" },
|
|
270
|
+
lazy: true, // true = don't auto-load on initialize(); false = load immediately
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Must call initialize() to wire up the core watcher
|
|
274
|
+
posts.initialize()
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
#### Reactive state properties
|
|
278
|
+
|
|
279
|
+
| Property | Type | Description |
|
|
280
|
+
|----------|------|-------------|
|
|
281
|
+
| `items` | `BehaviorSubject<LivequeryDocument<DocState<T>>[]>` | Current list of documents |
|
|
282
|
+
| `loading` | `BehaviorSubject<LivequeryLoadingState>` | Loading flags |
|
|
283
|
+
| `filters` | `BehaviorSubject<Partial<LivequeryFilters<T>>>` | Active filters |
|
|
284
|
+
| `paging` | `BehaviorSubject<LivequeryPaging>` | Pagination info |
|
|
285
|
+
| `summary` | `BehaviorSubject<Record<string, any>>` | Aggregation data from transporter |
|
|
286
|
+
| `metadata` | `BehaviorSubject<Record<string, any>>` | Arbitrary metadata from transporter |
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
posts.items.subscribe((docs) => console.log(docs.map(d => d.value)))
|
|
290
|
+
posts.loading.subscribe(({ all, next, prev }) => console.log({ all, next, prev }))
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`LivequeryLoadingState`:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
type LivequeryLoadingState = {
|
|
297
|
+
all: boolean // initial query in progress
|
|
298
|
+
next: boolean // loading next page
|
|
299
|
+
prev: boolean // loading previous page
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
### LivequeryDocument
|
|
306
|
+
|
|
307
|
+
Each element of `collection.items.value` is a `LivequeryDocument<DocState<T>>`, which extends `BehaviorSubject<T>`. It provides convenient mutation helpers scoped to that document.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
class LivequeryDocument<T extends Doc> extends BehaviorSubject<T> {
|
|
311
|
+
update(data: Partial<T>): Promise<void>
|
|
312
|
+
del(): Promise<void>
|
|
313
|
+
trigger<R>(action: LivequeryActionType, payload: Record<string, any>): Observable<{ data: R; error?: Error }>
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
const doc = posts.items.value[0]
|
|
319
|
+
|
|
320
|
+
// Subscribe to individual document changes
|
|
321
|
+
doc.subscribe((post) => console.log("post changed:", post))
|
|
322
|
+
|
|
323
|
+
// Mutate directly on the document
|
|
324
|
+
await doc.update({ title: "Updated title" })
|
|
325
|
+
await doc.del()
|
|
326
|
+
|
|
327
|
+
// Fire a custom action
|
|
328
|
+
doc.trigger("~publish", { scheduledAt: Date.now() }).subscribe()
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Query Filters
|
|
334
|
+
|
|
335
|
+
Filters are fully type-safe. The TypeScript compiler will only allow valid field paths and operators for your document type.
|
|
336
|
+
|
|
337
|
+
### Pagination / sorting
|
|
338
|
+
|
|
339
|
+
| Key | Type | Description |
|
|
340
|
+
|-----|------|-------------|
|
|
341
|
+
| `"<field>:sort"` | `"asc" \| "desc"` | Sort by a string field |
|
|
342
|
+
| `":limit"` | `number` | Max items per page |
|
|
343
|
+
| `":page"` | `number` | Page number (1-based) |
|
|
344
|
+
| `":before"` | `string` | Cursor for previous-page fetch |
|
|
345
|
+
| `":after"` | `string` | Cursor for next-page fetch |
|
|
346
|
+
|
|
347
|
+
### Field operators
|
|
348
|
+
|
|
349
|
+
| Operator | Applies to | Description |
|
|
350
|
+
|----------|-----------|-------------|
|
|
351
|
+
| `gt` | `number` | Greater than |
|
|
352
|
+
| `gte` | `number` | Greater than or equal |
|
|
353
|
+
| `lt` | `number` | Less than |
|
|
354
|
+
| `lte` | `number` | Less than or equal |
|
|
355
|
+
| `eq-number` | `number` | Strict numeric equality |
|
|
356
|
+
| `in` | `number \| string` | Value is in array |
|
|
357
|
+
| `nin` | `number \| string` | Value is NOT in array |
|
|
358
|
+
| `include` | `number[] \| string[]` | Array field includes value |
|
|
359
|
+
| `boolean` | `boolean` | `"true"`, `"false"`, `"not-true"`, `"not-false"` |
|
|
360
|
+
| `like` | `string` | Case-insensitive substring match |
|
|
361
|
+
| `null` | any | `"null-only"` or `"not-null"` |
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
type Article = Doc & {
|
|
365
|
+
score: number
|
|
366
|
+
tags: string[]
|
|
367
|
+
title: string
|
|
368
|
+
archived: boolean
|
|
369
|
+
deletedAt: number | null
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const filters: LivequeryFilters<Article> = {
|
|
373
|
+
"score:gte": 5,
|
|
374
|
+
"tags:include": "typescript",
|
|
375
|
+
"title:like": "livequery",
|
|
376
|
+
"archived:boolean": "false",
|
|
377
|
+
"deletedAt:null": "null-only",
|
|
378
|
+
"score:sort": "desc",
|
|
379
|
+
":limit": 20,
|
|
380
|
+
":page": 1,
|
|
381
|
+
":before": "",
|
|
382
|
+
":after": "",
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## API Reference
|
|
389
|
+
|
|
390
|
+
### LivequeryMemoryStorage
|
|
391
|
+
|
|
392
|
+
An in-memory `LivequeryStorge` implementation backed by a `Map`. Data is lost on page reload. Useful for testing and offline-first prototypes.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
const storage = new LivequeryMemoryStorage()
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
| Method | Signature | Description |
|
|
399
|
+
|--------|-----------|-------------|
|
|
400
|
+
| `query` | `(collection, filters?) → Promise<{documents, paging}>` | Filter, sort and paginate documents |
|
|
401
|
+
| `get` | `(collection, id) → Promise<T \| null>` | Fetch a single document by id |
|
|
402
|
+
| `add` | `(collection, document) → Promise<T>` | Upsert a document (insert or replace by id) |
|
|
403
|
+
| `update` | `(collection, id, partial) → Promise<T \| null>` | Merge partial fields into existing document |
|
|
404
|
+
| `delete` | `(collection, id) → Promise<T \| null>` | Remove and return a document |
|
|
405
|
+
| `seed` | `(collection, docs[]) → void` | Bulk-load documents (replaces existing) |
|
|
406
|
+
| `clear` | `(collection?) → void` | Clear one collection or all collections |
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
storage.seed<Todo>("todos", [
|
|
410
|
+
{ id: "1", title: "Write docs", done: false, createdAt: Date.now() }
|
|
411
|
+
])
|
|
412
|
+
|
|
413
|
+
storage.clear("todos") // clear one collection
|
|
414
|
+
storage.clear() // clear everything
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
### LivequeryCollection methods
|
|
420
|
+
|
|
421
|
+
| Method | Description |
|
|
422
|
+
|--------|-------------|
|
|
423
|
+
| `initialize()` | Wire up the core watcher and optionally auto-load (required before use) |
|
|
424
|
+
| `query(filters)` | Execute a fresh query replacing current items |
|
|
425
|
+
| `loadMore()` | Append next page using `paging.next.cursor` |
|
|
426
|
+
| `loadPrev()` | Prepend previous page using `paging.prev.cursor` |
|
|
427
|
+
| `loadAround(cursor)` | Load items around a specific cursor (both directions) |
|
|
428
|
+
| `add(payload)` | Optimistically add a new document |
|
|
429
|
+
| `update(id, payload)` | Optimistically update a document |
|
|
430
|
+
| `delete(id)` | Optimistically delete a document |
|
|
431
|
+
| `trigger(action, payload?)` | Fire a custom action (e.g. `"~publish"`) |
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// Paginate
|
|
435
|
+
await collection.loadMore()
|
|
436
|
+
await collection.loadPrev()
|
|
437
|
+
await collection.loadAround("cursor-abc")
|
|
438
|
+
|
|
439
|
+
// Mutate
|
|
440
|
+
await collection.add({ title: "New item", done: false, createdAt: Date.now() })
|
|
441
|
+
await collection.update("doc-id", { done: true })
|
|
442
|
+
await collection.delete("doc-id")
|
|
443
|
+
|
|
444
|
+
// Custom action handled by your transporter
|
|
445
|
+
collection.trigger("~sendEmail", { to: "user@example.com" }).subscribe()
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
### WorkerRpc
|
|
451
|
+
|
|
452
|
+
`WorkerRpc` is a utility for calling services across a `SharedWorker` boundary using an Observable / Promise-compatible API.
|
|
453
|
+
|
|
454
|
+
#### Expose a service inside a SharedWorker
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
// worker.ts
|
|
458
|
+
import { WorkerRpc } from "@livequery/new"
|
|
459
|
+
|
|
460
|
+
class DataService {
|
|
461
|
+
async getUser(id: string) {
|
|
462
|
+
return { id, name: "Alice" }
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const rpc = new WorkerRpc()
|
|
467
|
+
rpc.exposeWorkerService(new DataService())
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
#### Consume the service in the main thread
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
// main.ts
|
|
474
|
+
import { WorkerRpc } from "@livequery/new"
|
|
475
|
+
|
|
476
|
+
const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), { type: "module" })
|
|
477
|
+
|
|
478
|
+
// Returns a typed proxy
|
|
479
|
+
const service = WorkerRpc.linkWorkerService<DataService>("DataService", worker)
|
|
480
|
+
|
|
481
|
+
// Call as a Promise
|
|
482
|
+
const user = await service.getUser("123")
|
|
483
|
+
|
|
484
|
+
// Or subscribe as an Observable (for streaming methods)
|
|
485
|
+
service.getUser("123").subscribe((user) => console.log(user))
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
The proxy is transparent — if the underlying method returns an `Observable`, the proxy streams values back; if it returns a `Promise` or plain value, it resolves once.
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Writing a Custom Transporter
|
|
493
|
+
|
|
494
|
+
Implement `LivequeryTransporter` to connect to any backend:
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
import { Observable } from "rxjs"
|
|
498
|
+
import type {
|
|
499
|
+
LivequeryTransporter, Doc,
|
|
500
|
+
LivequeryQueryParams, LivequeryAction
|
|
501
|
+
} from "@livequery/new"
|
|
502
|
+
|
|
503
|
+
const httpTransporter: LivequeryTransporter = {
|
|
504
|
+
query<T extends Doc>(params: LivequeryQueryParams<T>) {
|
|
505
|
+
return new Observable(subscriber => {
|
|
506
|
+
fetch(`/api/${params.ref}?${new URLSearchParams(params.filters as any)}`)
|
|
507
|
+
.then(r => r.json())
|
|
508
|
+
.then(data => {
|
|
509
|
+
subscriber.next({
|
|
510
|
+
query_id: params.query_id,
|
|
511
|
+
changes: data.items.map((item: T) => ({ id: item.id, type: "added", data: item })),
|
|
512
|
+
paging: data.paging,
|
|
513
|
+
summary: data.summary ?? {},
|
|
514
|
+
metadata: {},
|
|
515
|
+
source: "query",
|
|
516
|
+
})
|
|
517
|
+
subscriber.complete()
|
|
518
|
+
})
|
|
519
|
+
.catch(err => subscriber.error(err))
|
|
520
|
+
})
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
trigger<T>(action: LivequeryAction) {
|
|
524
|
+
return new Observable<{ data: T }>(subscriber => {
|
|
525
|
+
const method = action.action === "delete" ? "DELETE"
|
|
526
|
+
: action.action === "add" ? "POST" : "PATCH"
|
|
527
|
+
fetch(`/api/${action.ref}`, {
|
|
528
|
+
method,
|
|
529
|
+
headers: { "Content-Type": "application/json" },
|
|
530
|
+
body: JSON.stringify(action.payload),
|
|
531
|
+
})
|
|
532
|
+
.then(r => r.json())
|
|
533
|
+
.then(data => { subscriber.next({ data }); subscriber.complete() })
|
|
534
|
+
.catch(err => subscriber.error(err))
|
|
535
|
+
})
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Writing a Custom Storage Adapter
|
|
543
|
+
|
|
544
|
+
Implement `LivequeryStorge` to persist data in `localStorage`, `IndexedDB`, SQLite, etc.:
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
import type { LivequeryStorge, Doc, LivequeryPaging } from "@livequery/new"
|
|
548
|
+
|
|
549
|
+
class LocalStorageAdapter implements LivequeryStorge {
|
|
550
|
+
private read<T>(collection: string): T[] {
|
|
551
|
+
return JSON.parse(localStorage.getItem(collection) ?? "[]")
|
|
552
|
+
}
|
|
553
|
+
private write<T>(collection: string, docs: T[]) {
|
|
554
|
+
localStorage.setItem(collection, JSON.stringify(docs))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async query<T extends Doc>(collection: string, filters?: Record<string, any>) {
|
|
558
|
+
const docs = this.read<T>(collection)
|
|
559
|
+
// apply filters, sort, paginate …
|
|
560
|
+
return { documents: docs, paging: { total: docs.length, current: docs.length } }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async get<T extends Doc>(collection: string, id: string) {
|
|
564
|
+
return this.read<T>(collection).find(d => d.id === id) ?? null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async add<T extends Doc>(collection: string, document: T) {
|
|
568
|
+
const docs = this.read<T>(collection)
|
|
569
|
+
const i = docs.findIndex(d => d.id === document.id)
|
|
570
|
+
if (i >= 0) docs[i] = document; else docs.push(document)
|
|
571
|
+
this.write(collection, docs)
|
|
572
|
+
return document
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async update<T extends Doc>(collection: string, id: string, patch: Record<string, any>) {
|
|
576
|
+
const docs = this.read<T>(collection)
|
|
577
|
+
const i = docs.findIndex(d => d.id === id)
|
|
578
|
+
if (i < 0) return null
|
|
579
|
+
docs[i] = { ...docs[i], ...patch }
|
|
580
|
+
this.write(collection, docs)
|
|
581
|
+
return docs[i]
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async delete<T extends Doc>(collection: string, id: string) {
|
|
585
|
+
const docs = this.read<T>(collection)
|
|
586
|
+
const i = docs.findIndex(d => d.id === id)
|
|
587
|
+
if (i < 0) return null
|
|
588
|
+
const [removed] = docs.splice(i, 1)
|
|
589
|
+
this.write(collection, docs)
|
|
590
|
+
return removed ?? null
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
## Types Reference
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
// Base document type — all documents must have an `id`
|
|
601
|
+
type Doc<T = {}> = T & { id: string }
|
|
602
|
+
|
|
603
|
+
// Document state inside a collection (tracks optimistic-update flags)
|
|
604
|
+
type DocState<T extends Doc> = T & {
|
|
605
|
+
_deleting?: boolean
|
|
606
|
+
_updating?: boolean
|
|
607
|
+
_adding?: boolean
|
|
608
|
+
_remotes?: Record<string, string | number>
|
|
609
|
+
_prev?: Partial<T>
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Change event emitted by transporters and the core
|
|
613
|
+
type DataChangeEvent<T extends Doc> = {
|
|
614
|
+
id: string
|
|
615
|
+
type: "added" | "updated" | "removed"
|
|
616
|
+
data?: Partial<Omit<T, "id">> | null
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Query parameters forwarded to every transporter
|
|
620
|
+
type LivequeryQueryParams<T extends Doc> = {
|
|
621
|
+
ref: string
|
|
622
|
+
query_id: string
|
|
623
|
+
collection_id: string
|
|
624
|
+
filters?: Partial<LivequeryFilters<T>>
|
|
625
|
+
headers?: Record<string, string>
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Result streamed back from a transporter query
|
|
629
|
+
type LivequeryQueryResult<T extends Doc> = {
|
|
630
|
+
query_id: string
|
|
631
|
+
changes: DataChangeEvent<T>[]
|
|
632
|
+
summary: Record<string, any>
|
|
633
|
+
paging: LivequeryPaging
|
|
634
|
+
metadata: Record<string, any>
|
|
635
|
+
source: "query" | "action" | "realtime"
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Action sent to every transporter for mutations
|
|
639
|
+
type LivequeryAction = {
|
|
640
|
+
ref: string
|
|
641
|
+
collection_id: string
|
|
642
|
+
action: "add" | "update" | "delete" | `~${string}`
|
|
643
|
+
payload?: Record<string, any>
|
|
644
|
+
headers?: Record<string, string>
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Pagination info
|
|
648
|
+
type LivequeryPaging = {
|
|
649
|
+
total: number
|
|
650
|
+
current: number
|
|
651
|
+
next?: { count: number; cursor: string }
|
|
652
|
+
prev?: { count: number; cursor: string }
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
---
|
|
657
|
+
|
|
658
|
+
## Build
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
bun run build
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Output is placed in `dist/` as ESM with TypeScript declarations (browser target).
|
|
665
|
+
|
|
666
|
+
```bash
|
|
667
|
+
bun run build:watch # watch mode (JS only, no type declarations)
|
|
668
|
+
bun run clean # remove dist/
|
|
669
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Subject } from "rxjs";
|
|
2
|
+
export type RpcMessage = {
|
|
3
|
+
id: number;
|
|
4
|
+
request?: {
|
|
5
|
+
service: string;
|
|
6
|
+
method: string[];
|
|
7
|
+
args: any[];
|
|
8
|
+
};
|
|
9
|
+
cancel?: boolean;
|
|
10
|
+
response?: Partial<{
|
|
11
|
+
data: any;
|
|
12
|
+
error: string;
|
|
13
|
+
completed: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
};
|
|
16
|
+
export declare abstract class RpcChannel extends Subject<RpcMessage & {
|
|
17
|
+
respond: (msg: RpcMessage) => void;
|
|
18
|
+
}> {
|
|
19
|
+
abstract send(message: RpcMessage): void;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=RpcChannel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RpcChannel.d.ts","sourceRoot":"","sources":["../src/RpcChannel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAG9B,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,CAAC,EAAE;QACN,OAAO,EAAE,MAAM,CAAA;QACf,MAAM,EAAE,MAAM,EAAE,CAAA;QAChB,IAAI,EAAE,GAAG,EAAE,CAAA;KACd,CAAA;IACD,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;QACf,IAAI,EAAE,GAAG,CAAA;QACT,KAAK,EAAE,MAAM,CAAA;QACb,SAAS,EAAE,OAAO,CAAA;KACrB,CAAC,CAAA;CACL,CAAA;AAGD,8BAAsB,UAAW,SAAQ,OAAO,CAAC,UAAU,GAAG;IAAE,OAAO,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,CAAA;CAAE,CAAC;IACjG,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI;CAC3C"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RpcChannel } from "./RpcChannel";
|
|
2
|
+
import type { WorkerService } from "./WorkerService";
|
|
3
|
+
export declare class ServiceLinker {
|
|
4
|
+
#private;
|
|
5
|
+
private channel;
|
|
6
|
+
constructor(channel: RpcChannel);
|
|
7
|
+
linkService<T>(name: string): WorkerService<T>;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=ServiceLinker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ServiceLinker.d.ts","sourceRoot":"","sources":["../src/ServiceLinker.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIrD,qBAAa,aAAa;;IAQV,OAAO,CAAC,OAAO;gBAAP,OAAO,EAAE,UAAU;IAevC,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAAC,CAAC,CAAC;CAsFjD"}
|