@polyphron/viper-react 0.1.2 → 0.3.0
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 +225 -1
- package/dist/auth.d.ts +45 -4
- package/dist/auth.js +178 -4
- package/dist/db-fetch.d.ts +121 -0
- package/dist/db-fetch.js +426 -0
- 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 +70 -0
- package/dist/db.js +329 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- 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/dist/runs.d.ts +23 -0
- package/dist/runs.js +58 -0
- package/package.json +20 -4
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;
|
package/dist/db-query.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** A field name is a plain word, so it can go into a filter unquoted. */
|
|
2
|
+
const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
|
+
/** The comparisons Viper writes the same way TanStack DB names them. */
|
|
4
|
+
const COMPARE = {
|
|
5
|
+
eq: "=",
|
|
6
|
+
gt: ">",
|
|
7
|
+
gte: ">=",
|
|
8
|
+
lt: "<",
|
|
9
|
+
lte: "<=",
|
|
10
|
+
};
|
|
11
|
+
/** The same comparison with its two sides swapped. */
|
|
12
|
+
const FLIP = {
|
|
13
|
+
eq: "eq",
|
|
14
|
+
gt: "lt",
|
|
15
|
+
gte: "lte",
|
|
16
|
+
lt: "gt",
|
|
17
|
+
lte: "gte",
|
|
18
|
+
};
|
|
19
|
+
const ANY = { text: null, exact: false };
|
|
20
|
+
/**
|
|
21
|
+
* A string as a Viper string literal. Backslash escapes the next character
|
|
22
|
+
* in Viper's lexer, so escaping `\` and `"` is enough to keep a value from
|
|
23
|
+
* ever ending its own quotes.
|
|
24
|
+
*/
|
|
25
|
+
export function quote(value) {
|
|
26
|
+
return `"${value.replace(/[\\"]/g, "\\$&")}"`;
|
|
27
|
+
}
|
|
28
|
+
// A value as a Viper literal, or null for one Viper has no way to write.
|
|
29
|
+
function literal(value) {
|
|
30
|
+
if (typeof value === "string")
|
|
31
|
+
return quote(value);
|
|
32
|
+
if (typeof value === "boolean")
|
|
33
|
+
return String(value);
|
|
34
|
+
if (value instanceof Date) {
|
|
35
|
+
return Number.isNaN(value.getTime()) ? null : quote(value.toISOString());
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === "bigint")
|
|
38
|
+
return value.toString();
|
|
39
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
40
|
+
// `1e21` is not a Viper number, so keep to plain digits.
|
|
41
|
+
return /^-?\d+(\.\d+)?$/.test(String(value)) ? String(value) : null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// The field a ref names, or null for a nested or odd-looking path.
|
|
46
|
+
function field(expr) {
|
|
47
|
+
if (expr.type !== "ref" || expr.path.length !== 1)
|
|
48
|
+
return null;
|
|
49
|
+
return FIELD.test(expr.path[0]) ? expr.path[0] : null;
|
|
50
|
+
}
|
|
51
|
+
// `field op value`, with the sides put the right way round.
|
|
52
|
+
function compare(name, args) {
|
|
53
|
+
let [left, right] = args;
|
|
54
|
+
let op = name;
|
|
55
|
+
if (left?.type === "val" && right?.type === "ref") {
|
|
56
|
+
;
|
|
57
|
+
[left, right] = [right, left];
|
|
58
|
+
op = FLIP[name];
|
|
59
|
+
}
|
|
60
|
+
const key = left && field(left);
|
|
61
|
+
const value = right?.type === "val" ? literal(right.value) : null;
|
|
62
|
+
if (!key || value === null)
|
|
63
|
+
return ANY;
|
|
64
|
+
return { text: `${key} ${COMPARE[op]} ${value}`, exact: true };
|
|
65
|
+
}
|
|
66
|
+
// `field ~ "text"`. Viper's `~` is a case-insensitive "contains", and a
|
|
67
|
+
// pattern holding a `%` is used as written. That is wider than SQL LIKE (no
|
|
68
|
+
// case, no exact match), so the result is never exact.
|
|
69
|
+
function like(args) {
|
|
70
|
+
const key = args[0] && field(args[0]);
|
|
71
|
+
const pattern = args[1]?.type === "val" ? args[1].value : null;
|
|
72
|
+
if (!key || typeof pattern !== "string")
|
|
73
|
+
return ANY;
|
|
74
|
+
return { text: `${key} ~ ${quote(pattern)}`, exact: false };
|
|
75
|
+
}
|
|
76
|
+
// `field IN (a, b)` as `(field = a || field = b)`.
|
|
77
|
+
function membership(args) {
|
|
78
|
+
const key = args[0] && field(args[0]);
|
|
79
|
+
const list = args[1]?.type === "val" ? args[1].value : null;
|
|
80
|
+
if (!key || !Array.isArray(list))
|
|
81
|
+
return ANY;
|
|
82
|
+
// No id is empty, so this matches nothing, as an empty IN should.
|
|
83
|
+
if (list.length === 0)
|
|
84
|
+
return { text: `id = ""`, exact: true };
|
|
85
|
+
const parts = list.map((item) => literal(item));
|
|
86
|
+
if (parts.some((part) => part === null))
|
|
87
|
+
return ANY;
|
|
88
|
+
return {
|
|
89
|
+
text: `(${parts.map((part) => `${key} = ${part}`).join(" || ")})`,
|
|
90
|
+
exact: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Walks the expression. "and" may drop a part it cannot say (the rest still
|
|
94
|
+
// narrows); "or" may not (dropping a side would hide rows), so one unsayable
|
|
95
|
+
// side makes the whole "or" unsayable. Viper's language has no "not", so a
|
|
96
|
+
// "not" is left for the browser.
|
|
97
|
+
function walk(expr) {
|
|
98
|
+
if (expr.type !== "func")
|
|
99
|
+
return ANY;
|
|
100
|
+
const { name, args } = expr;
|
|
101
|
+
if (name in COMPARE)
|
|
102
|
+
return compare(name, args);
|
|
103
|
+
if (name === "like" || name === "ilike")
|
|
104
|
+
return like(args);
|
|
105
|
+
if (name === "in")
|
|
106
|
+
return membership(args);
|
|
107
|
+
if (name === "and" || name === "or") {
|
|
108
|
+
const parts = args.map(walk);
|
|
109
|
+
const joiner = name === "and" ? " && " : " || ";
|
|
110
|
+
if (name === "or" && parts.some((part) => part.text === null))
|
|
111
|
+
return ANY;
|
|
112
|
+
const texts = parts.flatMap((part) => part.text === null ? [] : [part.text]);
|
|
113
|
+
if (texts.length === 0)
|
|
114
|
+
return ANY;
|
|
115
|
+
return {
|
|
116
|
+
text: texts.length === 1 ? texts[0] : `(${texts.join(joiner)})`,
|
|
117
|
+
exact: parts.every((part) => part.exact),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return ANY;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A subset's `where` as a Viper filter. Anything Viper cannot say is left out
|
|
124
|
+
* (so more rows load), and the query filters again in the browser.
|
|
125
|
+
*/
|
|
126
|
+
export function toViperFilter(where) {
|
|
127
|
+
if (!where)
|
|
128
|
+
return { filter: "", exact: true };
|
|
129
|
+
const piece = walk(where);
|
|
130
|
+
return { filter: piece.text ?? "", exact: piece.exact };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A subset's `orderBy` as Viper's `sort` (`-created,title`), or null when a
|
|
134
|
+
* clause cannot be said. A partial sort would pick the wrong rows for a
|
|
135
|
+
* limit, so it is all or nothing.
|
|
136
|
+
*/
|
|
137
|
+
export function toViperSort(orderBy) {
|
|
138
|
+
const names = [];
|
|
139
|
+
for (const clause of (orderBy ?? [])) {
|
|
140
|
+
const name = field(clause.expression);
|
|
141
|
+
if (!name)
|
|
142
|
+
return null;
|
|
143
|
+
names.push(clause.compareOptions.direction === "desc" ? `-${name}` : name);
|
|
144
|
+
}
|
|
145
|
+
return names.join(",");
|
|
146
|
+
}
|
|
147
|
+
// The most a page holds in Viper.
|
|
148
|
+
const MAX_PER_PAGE = 500;
|
|
149
|
+
/**
|
|
150
|
+
* The page that holds rows `offset` to `offset + limit`, or null when they
|
|
151
|
+
* are not a fit for one page (then the caller loads everything that matches).
|
|
152
|
+
*
|
|
153
|
+
* ponytail: paging is by offset (page/perPage), with `skipTotal` so the server
|
|
154
|
+
* runs no COUNT. It is not keyset: Viper's cursor is an opaque, signed
|
|
155
|
+
* token that only the previous page hands out, while TanStack DB asks for
|
|
156
|
+
* rows by `offset` (and cursor expressions on field values) with no token,
|
|
157
|
+
* and a page may be asked for out of order. Mapping an offset to a cursor
|
|
158
|
+
* would mean walking every page before it, which costs what OFFSET costs.
|
|
159
|
+
* Turning the cursor expressions into a filter is unsafe with ties and
|
|
160
|
+
* nulls. Revisit if Viper grows a token-free `after` filter.
|
|
161
|
+
*/
|
|
162
|
+
export function toWindow(offset, limit) {
|
|
163
|
+
if (limit < 1 || limit > MAX_PER_PAGE)
|
|
164
|
+
return null;
|
|
165
|
+
// Whole pages when the offset lines up; otherwise start at row 0 and take
|
|
166
|
+
// the rows before the window too (they are the same rows, so it is safe).
|
|
167
|
+
if (offset % limit === 0) {
|
|
168
|
+
return { page: offset / limit + 1, perPage: limit };
|
|
169
|
+
}
|
|
170
|
+
const perPage = offset + limit;
|
|
171
|
+
return perPage <= MAX_PER_PAGE ? { page: 1, perPage } : null;
|
|
172
|
+
}
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { RecordModel, Viper } from "@polyphron/viper-client";
|
|
2
|
+
import type { Collection, CollectionConfig, Context, InitialQueryBuilder, QueryBuilder } from "@tanstack/db";
|
|
3
|
+
import { useLiveQuery } from "@tanstack/react-db";
|
|
4
|
+
export { toViperFilter, toViperSort } from "./db-query.js";
|
|
5
|
+
export { useLivePage, viperPage, viperPageOptions, type LivePage, type PageCollection, type PageInfo, type PageQuery, type PageUtils, type ViperPageOptions, } from "./db-page.js";
|
|
6
|
+
export { pageComparator, planPage, type PagePlan, type PageState, } from "./page-plan.js";
|
|
7
|
+
export { fetchCollectionOptions, fetched, forgetFetched, refetchFetched, useFetched, useFetchedAll, type FetchKey, type FetchOptions, type FetchState, type FetchUtils, type Fetched, type FetchedResult, type Row, } from "./db-fetch.js";
|
|
8
|
+
export { useLiveQuery };
|
|
9
|
+
export type ViperCollectionOptions = {
|
|
10
|
+
viper: Viper;
|
|
11
|
+
/** The Viper collection's name, e.g. `posts`. */
|
|
12
|
+
collection: string;
|
|
13
|
+
/**
|
|
14
|
+
* `eager` (the default) loads every record. `on-demand` loads only what
|
|
15
|
+
* each live query asks for: its `where`, `orderBy` and `limit` become a
|
|
16
|
+
* Viper filter, sort and page.
|
|
17
|
+
*/
|
|
18
|
+
syncMode?: "eager" | "on-demand";
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Options for `createCollection()`: a TanStack DB collection that holds every
|
|
22
|
+
* record the signed-in user's list rule allows (or, in `on-demand` mode, the
|
|
23
|
+
* ones live queries ask for), and keeps it fresh from
|
|
24
|
+
* realtime events without asking the server again. Inserts, updates and
|
|
25
|
+
* deletes show at once and are rolled back if the server says no.
|
|
26
|
+
*
|
|
27
|
+
* Insert with your own `id` (15 characters, like Viper's), since the row
|
|
28
|
+
* needs a key before the server has answered.
|
|
29
|
+
*/
|
|
30
|
+
export declare function viperCollectionOptions<T extends RecordModel = RecordModel>(options: ViperCollectionOptions): CollectionConfig<T, string>;
|
|
31
|
+
/**
|
|
32
|
+
* The Viper collection `name` for the client in `<ViperProvider>`, made once
|
|
33
|
+
* and shared: every component asking for the same name and mode gets the same
|
|
34
|
+
* collection, so its records are fetched once. Use it to insert, update and
|
|
35
|
+
* delete, and as a `from` in live queries. Nothing about `useRecords` changes.
|
|
36
|
+
*
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const posts = useViperCollection<Post>("posts")
|
|
39
|
+
* posts.insert({ id: newId(), title: "Hi" })
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function useViperCollection<T extends RecordModel = RecordModel>(name: string, options?: {
|
|
43
|
+
syncMode?: "eager" | "on-demand";
|
|
44
|
+
}): Collection<T, string>;
|
|
45
|
+
/** What `useLiveRecords` gives back: `useRecords`' fields, over live rows. */
|
|
46
|
+
export type LiveRecords<T> = {
|
|
47
|
+
/** The rows the query matches, or null until the first load. */
|
|
48
|
+
data: T[] | null;
|
|
49
|
+
error: unknown;
|
|
50
|
+
/** True until the collection has loaded. */
|
|
51
|
+
loading: boolean;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The rows of a Viper collection as a live list, like `useRecords` but read
|
|
55
|
+
* from the shared collection, so it updates row by row with no refetch. Give
|
|
56
|
+
* `query` to filter, sort or join; without it you get every row. In
|
|
57
|
+
* `on-demand` mode `query` is what decides what is loaded.
|
|
58
|
+
*
|
|
59
|
+
* ```tsx
|
|
60
|
+
* const { data, loading } = useLiveRecords<Post>("posts", {
|
|
61
|
+
* query: (q, posts) =>
|
|
62
|
+
* q.from({ p: posts }).where(({ p }) => eq(p.status, "live")),
|
|
63
|
+
* })
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare function useLiveRecords<T extends RecordModel = RecordModel>(name: string, options?: {
|
|
67
|
+
syncMode?: "eager" | "on-demand";
|
|
68
|
+
/** Builds the query over the collection. A query on one collection returns its rows as they are; a join or a select returns the shape it builds. */
|
|
69
|
+
query?: (q: InitialQueryBuilder, rows: Collection<T, string>) => QueryBuilder<Context>;
|
|
70
|
+
}): LiveRecords<T>;
|