@polyphron/viper-react 0.1.1 → 0.2.1
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 +181 -1
- package/dist/db-page.d.ts +74 -0
- package/dist/db-page.js +233 -0
- package/dist/db-query.d.ts +44 -0
- package/dist/db-query.js +172 -0
- package/dist/db.d.ts +69 -0
- package/dist/db.js +330 -0
- package/dist/page-plan.d.ts +29 -0
- package/dist/page-plan.js +102 -0
- package/dist/records.d.ts +7 -1
- package/dist/records.js +26 -18
- package/package.json +19 -4
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ function SignIn() {
|
|
|
74
74
|
| Hook | What it gives you |
|
|
75
75
|
| --- | --- |
|
|
76
76
|
| `useViper()` | The `Viper` client — writes go through it: `viper.collection("posts").create(…)` |
|
|
77
|
-
| `useRecords(collection, options)` | One page of records: `{ data, loading, error, reload }` |
|
|
77
|
+
| `useRecords(collection, options)` | One page of records: `{ data, loading, refreshing, error, reload }` |
|
|
78
78
|
| `useRecord(collection, id, options)` | One record, same shape |
|
|
79
79
|
| `useAuth(collection)` | `{ user, auth }` for an auth collection |
|
|
80
80
|
| `useSession()` / `useUser()` | The session / the logged-in user, re-rendering on change |
|
|
@@ -85,6 +85,19 @@ function SignIn() {
|
|
|
85
85
|
what they are showing. The last good data stays on screen while a reload is in
|
|
86
86
|
flight, so lists don't flash.
|
|
87
87
|
|
|
88
|
+
- `loading` is true only until the first answer for what is shown. A reload
|
|
89
|
+
or a live update does not set it again. Changing the collection (or the
|
|
90
|
+
record id, for `useRecord`) clears `data` and sets it again, so a form never
|
|
91
|
+
sees the previous record.
|
|
92
|
+
- `refreshing` is true whenever a request is in flight, first load included.
|
|
93
|
+
Use it for a small spinner. (Before, `loading` was also true during
|
|
94
|
+
reloads; read `refreshing` if you relied on that.)
|
|
95
|
+
- With `live: true`, the list is also re-fetched once after the socket comes
|
|
96
|
+
back from a drop, since events may have been missed while it was down.
|
|
97
|
+
|
|
98
|
+
`@polyphron/viper-client` is a peer dependency: install it next to this
|
|
99
|
+
package, so the app and the hooks share one client.
|
|
100
|
+
|
|
88
101
|
## Writes
|
|
89
102
|
|
|
90
103
|
There is no mutation hook: the client's own methods are already promises.
|
|
@@ -96,3 +109,170 @@ const { reload } = useRecords("posts")
|
|
|
96
109
|
await viper.collection("posts").create({ title: "Hello" })
|
|
97
110
|
reload() // or pass live: true and let realtime do it
|
|
98
111
|
```
|
|
112
|
+
|
|
113
|
+
## Viper collections (experimental)
|
|
114
|
+
|
|
115
|
+
`@polyphron/viper-react/db` puts a Viper collection into
|
|
116
|
+
[TanStack DB](https://tanstack.com/db), so you can run live queries over
|
|
117
|
+
records kept in the browser. It is opt-in: the client and the hooks above
|
|
118
|
+
don't need it, and the API may change. Install the peers, pinned to the
|
|
119
|
+
versions it is tested with:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
npm install @tanstack/db@0.9.2 @tanstack/react-db@0.4.1
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { createCollection, eq } from "@tanstack/db"
|
|
127
|
+
import { useLiveQuery, viperCollectionOptions } from "@polyphron/viper-react/db"
|
|
128
|
+
|
|
129
|
+
const posts = createCollection(
|
|
130
|
+
viperCollectionOptions<Post>({ viper, collection: "posts" })
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
function Live() {
|
|
134
|
+
const { data } = useLiveQuery((q) =>
|
|
135
|
+
q.from({ p: posts }).where(({ p }) => eq(p.status, "live"))
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return <ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Shows at once, is saved in the background, and is rolled back if the
|
|
142
|
+
// server refuses it. Give it your own 15-character id.
|
|
143
|
+
posts.insert({ id, title: "Hi" })
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- It loads every record the list rule lets the user see, then applies
|
|
147
|
+
realtime events straight to the copy: no request per change. After a
|
|
148
|
+
dropped connection, or a `refresh` from a view collection, it loads again.
|
|
149
|
+
- Writes call the records API. Several in one transaction go to
|
|
150
|
+
`viper.batch()` and succeed or fail together. The server's answer (`id`,
|
|
151
|
+
`created`, `updated`, stamped defaults) replaces the optimistic row.
|
|
152
|
+
- A realtime event older than the row's `updated` is ignored, so an echo of
|
|
153
|
+
your own write can't undo a newer one.
|
|
154
|
+
- Eager (the default) suits small collections. For big ones pass
|
|
155
|
+
`syncMode: "on-demand"`: nothing loads until a live query asks, and then
|
|
156
|
+
only its `where`, `orderBy` and `limit` are sent, as a Viper `filter`,
|
|
157
|
+
`sort` and page. What Viper can't say (`not`, nested paths, `null`) is
|
|
158
|
+
left out, so a little more loads and the query filters it again; `like`
|
|
159
|
+
and `ilike` become `~` (a case-insensitive contains). A `limit` is only
|
|
160
|
+
sent when the whole `where` and `orderBy` could be. Realtime events are
|
|
161
|
+
applied for every row the user may see, and a reload fetches the queries
|
|
162
|
+
again. Paging is by page number with `skipTotal`, so the server never
|
|
163
|
+
counts; a keyset cursor cannot be used, because TanStack DB asks for an
|
|
164
|
+
offset and Viper's cursor is a token only the previous page hands out.
|
|
165
|
+
- Rules stay on the server; the copy holds only what the list rule and
|
|
166
|
+
realtime allow.
|
|
167
|
+
|
|
168
|
+
### Hooks
|
|
169
|
+
|
|
170
|
+
`useViperCollection<Row>(name, { syncMode? })` gives the collection for the
|
|
171
|
+
client in `<ViperProvider>`, made once and shared by every component that asks
|
|
172
|
+
for the same name and mode. `useLiveRecords<Row>(name, { syncMode?, query? })`
|
|
173
|
+
reads it as `{ data, error, loading }` (`data` is null until loaded), with
|
|
174
|
+
`query` for a filter, sort or join. Both are opt-in; `useRecords` and
|
|
175
|
+
`useRecord` work as before.
|
|
176
|
+
|
|
177
|
+
### A live server page
|
|
178
|
+
|
|
179
|
+
For a table that pages on the server (a text filter, totals, `expand`, the
|
|
180
|
+
Deleted view), `useLivePage<Row>(name, query)` holds one page of
|
|
181
|
+
`records.list` in a TanStack DB collection and keeps it fresh. `query` takes
|
|
182
|
+
what `records.list` takes: `page`, `perPage`, `sort`, `filter`, `expand`,
|
|
183
|
+
`deleted`.
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
const { items, totalItems, totalPages, loading, collection } =
|
|
187
|
+
useLivePage<Post>("posts", { page, perPage: 30, sort: "-updated", filter })
|
|
188
|
+
|
|
189
|
+
collection.delete(ids) // hidden at once, one batch, put back if refused
|
|
190
|
+
collection.utils.refetch() // ask for the page again
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- A change that can't move rows between pages (an edit that keeps the sort
|
|
194
|
+
values, on a page with no filter) patches its row; no request. Anything
|
|
195
|
+
else (a create, a delete, a filter the browser can't run) asks for the
|
|
196
|
+
page again, once per burst. `expand` is kept while the links don't change.
|
|
197
|
+
- While a new `query` loads, the last page stays on screen.
|
|
198
|
+
- `viperPage(viper, name, query).preload()` starts the fetch early, from a
|
|
199
|
+
route loader say; the hook then reads the same collection.
|
|
200
|
+
- `collection.utils.apply(event)` shows a change you made yourself (a save)
|
|
201
|
+
without waiting for its realtime event.
|
|
202
|
+
|
|
203
|
+
### Recipe: typed collections, a join and an optimistic insert
|
|
204
|
+
|
|
205
|
+
1. Write the types once with `viper typegen --out src/viper-types.ts` (see
|
|
206
|
+
the root README). Each collection gets a row type and a `Collections` map.
|
|
207
|
+
2. Get each collection with `useViperCollection<Collections["posts"]>("posts")`.
|
|
208
|
+
3. Read with `useLiveQuery`, joining collections in the browser.
|
|
209
|
+
4. Write with `collection.insert`. The row shows at once, and is rolled back
|
|
210
|
+
if the server refuses it, so put the form text back in that case.
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
import { eq } from "@tanstack/db"
|
|
214
|
+
import { useLiveQuery, useViperCollection } from "@polyphron/viper-react/db"
|
|
215
|
+
import { useState, type FormEvent } from "react"
|
|
216
|
+
|
|
217
|
+
import type { Collections, PostCreate } from "./viper-types"
|
|
218
|
+
|
|
219
|
+
// Viper ids are 15 characters. The row needs one before the server answers.
|
|
220
|
+
const newId = () =>
|
|
221
|
+
Array.from(
|
|
222
|
+
{ length: 15 },
|
|
223
|
+
() => "abcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(Math.random() * 36)]
|
|
224
|
+
).join("")
|
|
225
|
+
|
|
226
|
+
export function LivePosts({ authorId }: { authorId: string }) {
|
|
227
|
+
const posts = useViperCollection<Collections["posts"]>("posts")
|
|
228
|
+
const authors = useViperCollection<Collections["authors"]>("authors")
|
|
229
|
+
const [title, setTitle] = useState("")
|
|
230
|
+
|
|
231
|
+
// A join between two collections; only changed rows re-render.
|
|
232
|
+
const { data = [], isLoading } = useLiveQuery((q) =>
|
|
233
|
+
q
|
|
234
|
+
.from({ p: posts })
|
|
235
|
+
.innerJoin({ a: authors }, ({ p, a }) => eq(p.author, a.id))
|
|
236
|
+
.where(({ p }) => eq(p.status, "live"))
|
|
237
|
+
.orderBy(({ p }) => p.created, "desc")
|
|
238
|
+
.select(({ p, a }) => ({ id: p.id, title: p.title, by: a.name }))
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const add = async (event: FormEvent) => {
|
|
242
|
+
event.preventDefault()
|
|
243
|
+
|
|
244
|
+
const draft: PostCreate & { id: string } = {
|
|
245
|
+
id: newId(),
|
|
246
|
+
title,
|
|
247
|
+
status: "live",
|
|
248
|
+
author: authorId,
|
|
249
|
+
}
|
|
250
|
+
// The cast is because insert wants a whole row; the server fills in
|
|
251
|
+
// `created`, `updated` and defaults.
|
|
252
|
+
const tx = posts.insert(draft as Collections["posts"])
|
|
253
|
+
|
|
254
|
+
setTitle("")
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await tx.isPersisted.promise
|
|
258
|
+
} catch {
|
|
259
|
+
setTitle(draft.title)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (isLoading) return <p>Loading</p>
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<form onSubmit={add}>
|
|
267
|
+
<ul>
|
|
268
|
+
{data.map((row) => (
|
|
269
|
+
<li key={row.id}>
|
|
270
|
+
{row.title} by {row.by}
|
|
271
|
+
</li>
|
|
272
|
+
))}
|
|
273
|
+
</ul>
|
|
274
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
275
|
+
</form>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ListOptions, RealtimeEvent, RecordModel, Viper } from "@polyphron/viper-client";
|
|
2
|
+
import type { Collection, CollectionConfig } from "@tanstack/db";
|
|
3
|
+
/** Which page: what `records.list` takes, less the count-free options. */
|
|
4
|
+
export type PageQuery = Omit<ListOptions, "skipTotal" | "cursor" | "signal">;
|
|
5
|
+
/** A page's totals and the order its rows go in. */
|
|
6
|
+
export type PageInfo = {
|
|
7
|
+
/** The row ids, in the server's order. */
|
|
8
|
+
order: string[];
|
|
9
|
+
page: number;
|
|
10
|
+
perPage: number;
|
|
11
|
+
totalItems: number;
|
|
12
|
+
totalPages: number;
|
|
13
|
+
};
|
|
14
|
+
/** What a page collection adds under `collection.utils`. */
|
|
15
|
+
export type PageUtils = {
|
|
16
|
+
/** The totals and the order now. */
|
|
17
|
+
info: () => PageInfo;
|
|
18
|
+
/** Calls `listener` whenever `info()` changes. Returns the unsubscribe. */
|
|
19
|
+
subscribe: (listener: () => void) => () => void;
|
|
20
|
+
/** Asks the server for the page again. */
|
|
21
|
+
refetch: () => Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Shows a change made outside the collection (a save with files, a
|
|
24
|
+
* restore) the way a realtime event would, without waiting for one.
|
|
25
|
+
*/
|
|
26
|
+
apply: (event: RealtimeEvent) => void;
|
|
27
|
+
};
|
|
28
|
+
export type ViperPageOptions = {
|
|
29
|
+
viper: Viper;
|
|
30
|
+
/** The Viper collection's name, e.g. `posts`. */
|
|
31
|
+
collection: string;
|
|
32
|
+
query: PageQuery;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Options for `createCollection()`: one server page of `collection`, kept
|
|
36
|
+
* fresh from realtime. Its totals and row order are under `utils.info()`.
|
|
37
|
+
* Deleting rows from it hides them at once, sends one all-or-nothing batch,
|
|
38
|
+
* and puts them back if the server says no.
|
|
39
|
+
*/
|
|
40
|
+
export declare function viperPageOptions<T extends RecordModel = RecordModel>(options: ViperPageOptions): CollectionConfig<T, string, never, PageUtils> & {
|
|
41
|
+
utils: PageUtils;
|
|
42
|
+
};
|
|
43
|
+
/** A page collection, as `viperPage` and `useLivePage` give it. */
|
|
44
|
+
export type PageCollection<T extends RecordModel = RecordModel> = Collection<T, string, PageUtils>;
|
|
45
|
+
/**
|
|
46
|
+
* The page collection for `collection` and `query` on `viper`, made once and
|
|
47
|
+
* shared. Outside React (a route loader, say) call `.preload()` on it to
|
|
48
|
+
* start the fetch early.
|
|
49
|
+
*/
|
|
50
|
+
export declare function viperPage<T extends RecordModel = RecordModel>(viper: Viper, collection: string, query: PageQuery): PageCollection<T>;
|
|
51
|
+
/** What `useLivePage` gives back. */
|
|
52
|
+
export type LivePage<T extends RecordModel = RecordModel> = {
|
|
53
|
+
/** The page's rows in order, or null before the first load. */
|
|
54
|
+
items: T[] | null;
|
|
55
|
+
totalItems: number;
|
|
56
|
+
totalPages: number;
|
|
57
|
+
/** True while a page loads, including the next one after a change of query. */
|
|
58
|
+
loading: boolean;
|
|
59
|
+
error: unknown;
|
|
60
|
+
/** The collection, to delete rows or call `utils.refetch()` and `utils.apply()`. */
|
|
61
|
+
collection: PageCollection<T>;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* One server page of `collection` as a live list: `records.list` once, then
|
|
65
|
+
* realtime keeps it fresh, patching rows where it can. While a new `query`
|
|
66
|
+
* loads, the last page stays on screen.
|
|
67
|
+
*
|
|
68
|
+
* ```tsx
|
|
69
|
+
* const { items, totalPages } = useLivePage<Post>("posts", {
|
|
70
|
+
* page, perPage: 30, sort: "-updated", filter,
|
|
71
|
+
* })
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function useLivePage<T extends RecordModel = RecordModel>(collection: string, query: PageQuery): LivePage<T>;
|
package/dist/db-page.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createCollection } from "@tanstack/db";
|
|
2
|
+
import { useLiveQuery } from "@tanstack/react-db";
|
|
3
|
+
import { useMemo, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { pageComparator, planPage } from "./page-plan.js";
|
|
5
|
+
import { useViper } from "./provider.js";
|
|
6
|
+
// A burst of changes (an import, a batch) asks for the page once.
|
|
7
|
+
const SETTLE = 300;
|
|
8
|
+
const EMPTY = {
|
|
9
|
+
order: [],
|
|
10
|
+
page: 1,
|
|
11
|
+
perPage: 0,
|
|
12
|
+
totalItems: 0,
|
|
13
|
+
totalPages: 0,
|
|
14
|
+
};
|
|
15
|
+
// A delete of several rows, as one all-or-nothing batch.
|
|
16
|
+
async function deleteAll(viper, collection, ids) {
|
|
17
|
+
if (ids.length === 1) {
|
|
18
|
+
await viper.collection(collection).records.delete(ids[0]);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await viper.batch(ids.map((id) => ({ method: "delete", collection, id })));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Options for `createCollection()`: one server page of `collection`, kept
|
|
25
|
+
* fresh from realtime. Its totals and row order are under `utils.info()`.
|
|
26
|
+
* Deleting rows from it hides them at once, sends one all-or-nothing batch,
|
|
27
|
+
* and puts them back if the server says no.
|
|
28
|
+
*/
|
|
29
|
+
export function viperPageOptions(options) {
|
|
30
|
+
const { viper, collection, query } = options;
|
|
31
|
+
const records = viper.collection(collection).records;
|
|
32
|
+
const compare = pageComparator(query.sort);
|
|
33
|
+
let info = EMPTY;
|
|
34
|
+
const listeners = new Set();
|
|
35
|
+
const rows = new Map();
|
|
36
|
+
const publish = (next) => {
|
|
37
|
+
info = next;
|
|
38
|
+
listeners.forEach((listener) => listener());
|
|
39
|
+
};
|
|
40
|
+
// Set while the sync runs: how to reload, and how to take in an event.
|
|
41
|
+
let live = null;
|
|
42
|
+
const sync = (params) => {
|
|
43
|
+
const { begin, write, commit, truncate, markReady, markError } = params;
|
|
44
|
+
let closed = false;
|
|
45
|
+
let loading = null;
|
|
46
|
+
let again = false;
|
|
47
|
+
let timer;
|
|
48
|
+
const load = async () => {
|
|
49
|
+
// One load at a time; a change during a load reloads after it.
|
|
50
|
+
if (loading) {
|
|
51
|
+
again = true;
|
|
52
|
+
return loading;
|
|
53
|
+
}
|
|
54
|
+
loading = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
do {
|
|
57
|
+
again = false;
|
|
58
|
+
const result = await records.list(query);
|
|
59
|
+
if (closed)
|
|
60
|
+
return;
|
|
61
|
+
// Truncate and rewrite in one transaction, so a reload never
|
|
62
|
+
// shows an empty page in between.
|
|
63
|
+
begin();
|
|
64
|
+
truncate();
|
|
65
|
+
rows.clear();
|
|
66
|
+
for (const record of result.items) {
|
|
67
|
+
write({ type: "insert", value: record });
|
|
68
|
+
rows.set(record.id, record);
|
|
69
|
+
}
|
|
70
|
+
commit();
|
|
71
|
+
markReady();
|
|
72
|
+
publish({
|
|
73
|
+
order: result.items.map((r) => r.id),
|
|
74
|
+
page: result.page,
|
|
75
|
+
perPage: result.perPage,
|
|
76
|
+
totalItems: result.totalItems,
|
|
77
|
+
totalPages: result.totalPages,
|
|
78
|
+
});
|
|
79
|
+
} while (again && !closed);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
markError(error);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
loading = null;
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
return loading;
|
|
89
|
+
};
|
|
90
|
+
const loadSoon = () => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
timer = setTimeout(() => void load(), SETTLE);
|
|
93
|
+
};
|
|
94
|
+
const take = (event) => {
|
|
95
|
+
// The page on screen may be about to change; plan against the next one.
|
|
96
|
+
if (loading) {
|
|
97
|
+
again = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const plan = planPage(event, {
|
|
101
|
+
rows: info.order.flatMap((id) => rows.get(id) ?? []),
|
|
102
|
+
page: info.page,
|
|
103
|
+
totalPages: info.totalPages,
|
|
104
|
+
sort: query.sort,
|
|
105
|
+
filter: query.filter,
|
|
106
|
+
deleted: query.deleted,
|
|
107
|
+
expand: query.expand,
|
|
108
|
+
});
|
|
109
|
+
if (plan.drop || plan.patch) {
|
|
110
|
+
begin();
|
|
111
|
+
if (plan.drop) {
|
|
112
|
+
write({ type: "delete", key: plan.drop });
|
|
113
|
+
rows.delete(plan.drop);
|
|
114
|
+
}
|
|
115
|
+
if (plan.patch) {
|
|
116
|
+
write({ type: "update", value: plan.patch });
|
|
117
|
+
rows.set(plan.patch.id, plan.patch);
|
|
118
|
+
}
|
|
119
|
+
commit();
|
|
120
|
+
let order = info.order.filter((id) => rows.has(id));
|
|
121
|
+
if (compare) {
|
|
122
|
+
order = order
|
|
123
|
+
.map((id) => rows.get(id))
|
|
124
|
+
.sort(compare)
|
|
125
|
+
.map((r) => r.id);
|
|
126
|
+
}
|
|
127
|
+
publish({ ...info, order });
|
|
128
|
+
}
|
|
129
|
+
if (plan.refetch)
|
|
130
|
+
loadSoon();
|
|
131
|
+
};
|
|
132
|
+
live = { load, take };
|
|
133
|
+
// Subscribe before loading, so nothing that happens in between is lost.
|
|
134
|
+
const off = viper.realtime.subscribe(collection, take);
|
|
135
|
+
const offResync = viper.realtime.onResync(() => void load());
|
|
136
|
+
void load();
|
|
137
|
+
return () => {
|
|
138
|
+
closed = true;
|
|
139
|
+
live = null;
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
off();
|
|
142
|
+
offResync();
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const utils = {
|
|
146
|
+
info: () => info,
|
|
147
|
+
subscribe: (listener) => {
|
|
148
|
+
listeners.add(listener);
|
|
149
|
+
return () => listeners.delete(listener);
|
|
150
|
+
},
|
|
151
|
+
refetch: async () => live?.load(),
|
|
152
|
+
apply: (event) => live?.take(event),
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
id: `viper-page:${collection}:${JSON.stringify(query)}`,
|
|
156
|
+
getKey: (record) => record.id,
|
|
157
|
+
// Records are whole rows, so an update replaces rather than merges.
|
|
158
|
+
sync: { sync, rowUpdateMode: "full" },
|
|
159
|
+
utils,
|
|
160
|
+
onDelete: async ({ transaction }) => {
|
|
161
|
+
await deleteAll(viper, collection, transaction.mutations.map((m) => String(m.key)));
|
|
162
|
+
// The rows after them move up.
|
|
163
|
+
await live?.load();
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Page collections by client and page, so the route that preloads a page and
|
|
168
|
+
// the table that shows it share one collection (and one fetch). One leaves
|
|
169
|
+
// here when its sync stops, after nothing has read it for a while.
|
|
170
|
+
const pages = new WeakMap();
|
|
171
|
+
/**
|
|
172
|
+
* The page collection for `collection` and `query` on `viper`, made once and
|
|
173
|
+
* shared. Outside React (a route loader, say) call `.preload()` on it to
|
|
174
|
+
* start the fetch early.
|
|
175
|
+
*/
|
|
176
|
+
export function viperPage(viper, collection, query) {
|
|
177
|
+
let byKey = pages.get(viper);
|
|
178
|
+
if (!byKey) {
|
|
179
|
+
byKey = new Map();
|
|
180
|
+
pages.set(viper, byKey);
|
|
181
|
+
}
|
|
182
|
+
const key = `${collection}:${JSON.stringify(query)}`;
|
|
183
|
+
const found = byKey.get(key);
|
|
184
|
+
if (found)
|
|
185
|
+
return found;
|
|
186
|
+
const made = createCollection(viperPageOptions({ viper, collection, query }));
|
|
187
|
+
// Once it is cleaned up it can't be read again, so the next ask makes a
|
|
188
|
+
// new one.
|
|
189
|
+
made.on("status:change", ({ status }) => {
|
|
190
|
+
if (status === "cleaned-up")
|
|
191
|
+
byKey.delete(key);
|
|
192
|
+
});
|
|
193
|
+
byKey.set(key, made);
|
|
194
|
+
return made;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* One server page of `collection` as a live list: `records.list` once, then
|
|
198
|
+
* realtime keeps it fresh, patching rows where it can. While a new `query`
|
|
199
|
+
* loads, the last page stays on screen.
|
|
200
|
+
*
|
|
201
|
+
* ```tsx
|
|
202
|
+
* const { items, totalPages } = useLivePage<Post>("posts", {
|
|
203
|
+
* page, perPage: 30, sort: "-updated", filter,
|
|
204
|
+
* })
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
export function useLivePage(collection, query) {
|
|
208
|
+
const viper = useViper();
|
|
209
|
+
const rows = viperPage(viper, collection, query);
|
|
210
|
+
// A page is a list, never a single row.
|
|
211
|
+
const live = useLiveQuery(rows);
|
|
212
|
+
const info = useSyncExternalStore(rows.utils.subscribe, rows.utils.info, rows.utils.info);
|
|
213
|
+
const byId = live.state;
|
|
214
|
+
const ready = live.isReady && !!byId;
|
|
215
|
+
// The same array until a row or the order changes, so memos below hold.
|
|
216
|
+
const items = useMemo(() => (ready ? info.order.flatMap((id) => byId.get(id) ?? []) : null),
|
|
217
|
+
// live.data changes whenever a row does.
|
|
218
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
219
|
+
[ready, live.data, info]);
|
|
220
|
+
// The last page that loaded, shown while the next one does.
|
|
221
|
+
const [last, setLast] = useState(null);
|
|
222
|
+
if (items && last?.items !== items)
|
|
223
|
+
setLast({ items, info });
|
|
224
|
+
const shown = items ? { items, info } : last;
|
|
225
|
+
return {
|
|
226
|
+
items: shown?.items ?? null,
|
|
227
|
+
totalItems: shown?.info.totalItems ?? 0,
|
|
228
|
+
totalPages: shown?.info.totalPages ?? 0,
|
|
229
|
+
loading: !items,
|
|
230
|
+
error: live.isError ? new Error(`${collection} could not be loaded`) : null,
|
|
231
|
+
collection: rows,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LoadSubsetOptions } from "@tanstack/db";
|
|
2
|
+
/** What a translation produced: a filter (empty when none) and its fidelity. */
|
|
3
|
+
export type Translated = {
|
|
4
|
+
/** A Viper filter; "" means "no filter", so every row matches. */
|
|
5
|
+
filter: string;
|
|
6
|
+
/** True when the filter matches exactly what the query does. */
|
|
7
|
+
exact: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A string as a Viper string literal. Backslash escapes the next character
|
|
11
|
+
* in Viper's lexer, so escaping `\` and `"` is enough to keep a value from
|
|
12
|
+
* ever ending its own quotes.
|
|
13
|
+
*/
|
|
14
|
+
export declare function quote(value: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* A subset's `where` as a Viper filter. Anything Viper cannot say is left out
|
|
17
|
+
* (so more rows load), and the query filters again in the browser.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toViperFilter(where: LoadSubsetOptions["where"]): Translated;
|
|
20
|
+
/**
|
|
21
|
+
* A subset's `orderBy` as Viper's `sort` (`-created,title`), or null when a
|
|
22
|
+
* clause cannot be said. A partial sort would pick the wrong rows for a
|
|
23
|
+
* limit, so it is all or nothing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toViperSort(orderBy: LoadSubsetOptions["orderBy"]): string | null;
|
|
26
|
+
/** One page request: which page, how many. */
|
|
27
|
+
export type Window = {
|
|
28
|
+
page: number;
|
|
29
|
+
perPage: number;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The page that holds rows `offset` to `offset + limit`, or null when they
|
|
33
|
+
* are not a fit for one page (then the caller loads everything that matches).
|
|
34
|
+
*
|
|
35
|
+
* ponytail: paging is by offset (page/perPage), with `skipTotal` so the server
|
|
36
|
+
* runs no COUNT. It is not keyset: Viper's cursor is an opaque, signed
|
|
37
|
+
* token that only the previous page hands out, while TanStack DB asks for
|
|
38
|
+
* rows by `offset` (and cursor expressions on field values) with no token,
|
|
39
|
+
* and a page may be asked for out of order. Mapping an offset to a cursor
|
|
40
|
+
* would mean walking every page before it, which costs what OFFSET costs.
|
|
41
|
+
* Turning the cursor expressions into a filter is unsafe with ties and
|
|
42
|
+
* nulls. Revisit if Viper grows a token-free `after` filter.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toWindow(offset: number, limit: number): Window | null;
|