@polyphron/viper-react 0.1.2 → 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/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,69 @@
|
|
|
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 { useLiveQuery };
|
|
8
|
+
export type ViperCollectionOptions = {
|
|
9
|
+
viper: Viper;
|
|
10
|
+
/** The Viper collection's name, e.g. `posts`. */
|
|
11
|
+
collection: string;
|
|
12
|
+
/**
|
|
13
|
+
* `eager` (the default) loads every record. `on-demand` loads only what
|
|
14
|
+
* each live query asks for: its `where`, `orderBy` and `limit` become a
|
|
15
|
+
* Viper filter, sort and page.
|
|
16
|
+
*/
|
|
17
|
+
syncMode?: "eager" | "on-demand";
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Options for `createCollection()`: a TanStack DB collection that holds every
|
|
21
|
+
* record the signed-in user's list rule allows (or, in `on-demand` mode, the
|
|
22
|
+
* ones live queries ask for), and keeps it fresh from
|
|
23
|
+
* realtime events without asking the server again. Inserts, updates and
|
|
24
|
+
* deletes show at once and are rolled back if the server says no.
|
|
25
|
+
*
|
|
26
|
+
* Insert with your own `id` (15 characters, like Viper's), since the row
|
|
27
|
+
* needs a key before the server has answered.
|
|
28
|
+
*/
|
|
29
|
+
export declare function viperCollectionOptions<T extends RecordModel = RecordModel>(options: ViperCollectionOptions): CollectionConfig<T, string>;
|
|
30
|
+
/**
|
|
31
|
+
* The Viper collection `name` for the client in `<ViperProvider>`, made once
|
|
32
|
+
* and shared: every component asking for the same name and mode gets the same
|
|
33
|
+
* collection, so its records are fetched once. Use it to insert, update and
|
|
34
|
+
* delete, and as a `from` in live queries. Nothing about `useRecords` changes.
|
|
35
|
+
*
|
|
36
|
+
* ```tsx
|
|
37
|
+
* const posts = useViperCollection<Post>("posts")
|
|
38
|
+
* posts.insert({ id: newId(), title: "Hi" })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function useViperCollection<T extends RecordModel = RecordModel>(name: string, options?: {
|
|
42
|
+
syncMode?: "eager" | "on-demand";
|
|
43
|
+
}): Collection<T, string>;
|
|
44
|
+
/** What `useLiveRecords` gives back: `useRecords`' fields, over live rows. */
|
|
45
|
+
export type LiveRecords<T> = {
|
|
46
|
+
/** The rows the query matches, or null until the first load. */
|
|
47
|
+
data: T[] | null;
|
|
48
|
+
error: unknown;
|
|
49
|
+
/** True until the collection has loaded. */
|
|
50
|
+
loading: boolean;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The rows of a Viper collection as a live list, like `useRecords` but read
|
|
54
|
+
* from the shared collection, so it updates row by row with no refetch. Give
|
|
55
|
+
* `query` to filter, sort or join; without it you get every row. In
|
|
56
|
+
* `on-demand` mode `query` is what decides what is loaded.
|
|
57
|
+
*
|
|
58
|
+
* ```tsx
|
|
59
|
+
* const { data, loading } = useLiveRecords<Post>("posts", {
|
|
60
|
+
* query: (q, posts) =>
|
|
61
|
+
* q.from({ p: posts }).where(({ p }) => eq(p.status, "live")),
|
|
62
|
+
* })
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function useLiveRecords<T extends RecordModel = RecordModel>(name: string, options?: {
|
|
66
|
+
syncMode?: "eager" | "on-demand";
|
|
67
|
+
/** 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. */
|
|
68
|
+
query?: (q: InitialQueryBuilder, rows: Collection<T, string>) => QueryBuilder<Context>;
|
|
69
|
+
}): LiveRecords<T>;
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { createCollection } from "@tanstack/db";
|
|
2
|
+
import { useLiveQuery } from "@tanstack/react-db";
|
|
3
|
+
import { toViperFilter, toViperSort, toWindow } from "./db-query.js";
|
|
4
|
+
import { useViper } from "./provider.js";
|
|
5
|
+
export { toViperFilter, toViperSort } from "./db-query.js";
|
|
6
|
+
export { useLivePage, viperPage, viperPageOptions, } from "./db-page.js";
|
|
7
|
+
export { pageComparator, planPage, } from "./page-plan.js";
|
|
8
|
+
export { useLiveQuery };
|
|
9
|
+
/**
|
|
10
|
+
* Options for `createCollection()`: a TanStack DB collection that holds every
|
|
11
|
+
* record the signed-in user's list rule allows (or, in `on-demand` mode, the
|
|
12
|
+
* ones live queries ask for), and keeps it fresh from
|
|
13
|
+
* realtime events without asking the server again. Inserts, updates and
|
|
14
|
+
* deletes show at once and are rolled back if the server says no.
|
|
15
|
+
*
|
|
16
|
+
* Insert with your own `id` (15 characters, like Viper's), since the row
|
|
17
|
+
* needs a key before the server has answered.
|
|
18
|
+
*/
|
|
19
|
+
export function viperCollectionOptions(options) {
|
|
20
|
+
const { viper, collection } = options;
|
|
21
|
+
const onDemand = options.syncMode === "on-demand";
|
|
22
|
+
const records = viper.collection(collection).records;
|
|
23
|
+
// Set while the sync is running, so a mutation can write the server's
|
|
24
|
+
// answer into the collection.
|
|
25
|
+
let sink = null;
|
|
26
|
+
const sync = (params) => {
|
|
27
|
+
const { begin, write, commit, truncate, markReady, markError } = params;
|
|
28
|
+
// The `updated` we hold for each synced row: what makes an old realtime
|
|
29
|
+
// event (our own write coming back, say) unable to undo a newer one.
|
|
30
|
+
const versions = new Map();
|
|
31
|
+
let closed = false;
|
|
32
|
+
let loading = false;
|
|
33
|
+
let again = false;
|
|
34
|
+
// Events that arrive while a load is in flight, replayed after it.
|
|
35
|
+
let buffered = [];
|
|
36
|
+
// On-demand: subset loads in flight (they buffer events too), and the
|
|
37
|
+
// subsets live queries hold now, so a reload can fetch them again.
|
|
38
|
+
let pending = 0;
|
|
39
|
+
const subsets = new Map();
|
|
40
|
+
const stamp = (record) => Date.parse(record.updated) || 0;
|
|
41
|
+
const put = (record) => {
|
|
42
|
+
const seen = versions.get(record.id);
|
|
43
|
+
if (seen !== undefined && stamp(record) < seen)
|
|
44
|
+
return;
|
|
45
|
+
write({ type: seen === undefined ? "insert" : "update", value: record });
|
|
46
|
+
versions.set(record.id, stamp(record));
|
|
47
|
+
};
|
|
48
|
+
const remove = (id) => {
|
|
49
|
+
const seen = versions.get(id);
|
|
50
|
+
if (seen === undefined)
|
|
51
|
+
return;
|
|
52
|
+
write({ type: "delete", key: id });
|
|
53
|
+
versions.delete(id);
|
|
54
|
+
};
|
|
55
|
+
const apply = (event) => {
|
|
56
|
+
if (event.action === "delete")
|
|
57
|
+
remove(event.record.id);
|
|
58
|
+
else if (event.action !== "refresh")
|
|
59
|
+
put(event.record);
|
|
60
|
+
};
|
|
61
|
+
// A subset is held once per load; it is let go when each is unloaded.
|
|
62
|
+
const release = (subset) => {
|
|
63
|
+
const count = subsets.get(subset) ?? 0;
|
|
64
|
+
if (count > 1)
|
|
65
|
+
subsets.set(subset, count - 1);
|
|
66
|
+
else
|
|
67
|
+
subsets.delete(subset);
|
|
68
|
+
};
|
|
69
|
+
// Every row the held subsets want, or the whole collection when eager.
|
|
70
|
+
const fetchAll = async () => {
|
|
71
|
+
if (!onDemand)
|
|
72
|
+
return records.listAll();
|
|
73
|
+
const found = await Promise.all([...subsets.keys()].map(fetchSubset));
|
|
74
|
+
const byId = new Map();
|
|
75
|
+
for (const item of found.flat())
|
|
76
|
+
byId.set(item.id, item);
|
|
77
|
+
return [...byId.values()];
|
|
78
|
+
};
|
|
79
|
+
// One subset: the rows its `where` names, sorted, and its window when it
|
|
80
|
+
// has a limit. A `where` we could only half say loads wider and never
|
|
81
|
+
// takes a window, since the window would be cut from the wrong rows.
|
|
82
|
+
const fetchSubset = async (subset) => {
|
|
83
|
+
const { filter, exact } = toViperFilter(subset.where);
|
|
84
|
+
const sort = toViperSort(subset.orderBy);
|
|
85
|
+
const window = exact && sort && subset.limit !== undefined
|
|
86
|
+
? toWindow(subset.offset ?? 0, subset.limit)
|
|
87
|
+
: null;
|
|
88
|
+
// Core always sends `offset` along with `cursor`, so the cursor
|
|
89
|
+
// expressions are left alone and the offset does the paging. Only the
|
|
90
|
+
// rows are wanted, so `skipTotal` spares the server its COUNT (see
|
|
91
|
+
// toWindow for why this is not a keyset cursor).
|
|
92
|
+
if (!window) {
|
|
93
|
+
return records.listAll({
|
|
94
|
+
filter: filter || undefined,
|
|
95
|
+
sort: sort || undefined,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const result = await records.list({
|
|
99
|
+
filter: filter || undefined,
|
|
100
|
+
sort: sort || undefined,
|
|
101
|
+
skipTotal: true,
|
|
102
|
+
...window,
|
|
103
|
+
});
|
|
104
|
+
return result.items;
|
|
105
|
+
};
|
|
106
|
+
const load = async () => {
|
|
107
|
+
// One load at a time; a request during a load runs another after it.
|
|
108
|
+
if (loading) {
|
|
109
|
+
again = true;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
loading = true;
|
|
113
|
+
try {
|
|
114
|
+
do {
|
|
115
|
+
again = false;
|
|
116
|
+
const items = await fetchAll();
|
|
117
|
+
if (closed)
|
|
118
|
+
return;
|
|
119
|
+
// Truncate and rewrite in one transaction, so a reload never
|
|
120
|
+
// shows an empty list in between.
|
|
121
|
+
begin();
|
|
122
|
+
truncate();
|
|
123
|
+
versions.clear();
|
|
124
|
+
items.forEach(put);
|
|
125
|
+
const missed = buffered;
|
|
126
|
+
if (pending === 0)
|
|
127
|
+
buffered = [];
|
|
128
|
+
missed.forEach(apply);
|
|
129
|
+
commit();
|
|
130
|
+
markReady();
|
|
131
|
+
} while (again);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
markError(error);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
loading = false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
sink = {
|
|
141
|
+
put,
|
|
142
|
+
remove,
|
|
143
|
+
write: (fn) => {
|
|
144
|
+
begin();
|
|
145
|
+
fn();
|
|
146
|
+
commit();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
// Subscribe before loading, so nothing that happens in between is lost.
|
|
150
|
+
const off = viper.realtime.subscribe(collection, (event) => {
|
|
151
|
+
if (event.action === "refresh") {
|
|
152
|
+
void load();
|
|
153
|
+
}
|
|
154
|
+
else if (loading || pending > 0) {
|
|
155
|
+
buffered.push(event);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
begin();
|
|
159
|
+
apply(event);
|
|
160
|
+
commit();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
const offResync = viper.realtime.onResync(() => void load());
|
|
164
|
+
const cleanup = () => {
|
|
165
|
+
closed = true;
|
|
166
|
+
sink = null;
|
|
167
|
+
off();
|
|
168
|
+
offResync();
|
|
169
|
+
};
|
|
170
|
+
if (!onDemand) {
|
|
171
|
+
void load();
|
|
172
|
+
return cleanup;
|
|
173
|
+
}
|
|
174
|
+
// Nothing to load until a query asks. Every realtime event is applied,
|
|
175
|
+
// not only those matching a held subset: the list rule already decided
|
|
176
|
+
// this user may see the row, and a live query drops what does not match
|
|
177
|
+
// it. Skipping non-matching events would need the where evaluated here,
|
|
178
|
+
// and would miss a new row that a query should show.
|
|
179
|
+
markReady();
|
|
180
|
+
return {
|
|
181
|
+
cleanup,
|
|
182
|
+
loadSubset: async (subset) => {
|
|
183
|
+
pending++;
|
|
184
|
+
subsets.set(subset, (subsets.get(subset) ?? 0) + 1);
|
|
185
|
+
try {
|
|
186
|
+
const items = await fetchSubset(subset);
|
|
187
|
+
if (closed)
|
|
188
|
+
return;
|
|
189
|
+
begin();
|
|
190
|
+
items.forEach(put);
|
|
191
|
+
buffered.forEach(apply);
|
|
192
|
+
await commit();
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
release(subset);
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
pending--;
|
|
200
|
+
if (pending === 0 && !loading)
|
|
201
|
+
buffered = [];
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
unloadSubset: release,
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
// Puts what the server answered into the collection, in place of our guess.
|
|
208
|
+
// A write the user may make on a row they may not see afterwards (the view
|
|
209
|
+
// rule now hides it) answers with only ids, so that row is dropped rather
|
|
210
|
+
// than kept as the partial guess.
|
|
211
|
+
const store = (saved) => {
|
|
212
|
+
sink?.write(() => {
|
|
213
|
+
for (const record of saved) {
|
|
214
|
+
if (!record)
|
|
215
|
+
continue;
|
|
216
|
+
if (hiddenAnswer(record))
|
|
217
|
+
sink?.remove(record.id);
|
|
218
|
+
else
|
|
219
|
+
sink?.put(record);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
getKey: (record) => record.id,
|
|
225
|
+
// Records are whole rows, so an update replaces rather than merges.
|
|
226
|
+
sync: { sync, rowUpdateMode: "full" },
|
|
227
|
+
syncMode: onDemand ? "on-demand" : "eager",
|
|
228
|
+
onInsert: async ({ transaction }) => {
|
|
229
|
+
const saved = await save(viper, records, collection, transaction.mutations);
|
|
230
|
+
store(saved);
|
|
231
|
+
},
|
|
232
|
+
onUpdate: async ({ transaction }) => {
|
|
233
|
+
const saved = await save(viper, records, collection, transaction.mutations);
|
|
234
|
+
store(saved);
|
|
235
|
+
},
|
|
236
|
+
onDelete: async ({ transaction }) => {
|
|
237
|
+
await save(viper, records, collection, transaction.mutations);
|
|
238
|
+
sink?.write(() => {
|
|
239
|
+
for (const m of transaction.mutations)
|
|
240
|
+
sink?.remove(m.key);
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// True when a write's answer holds only the ids, which is what the server
|
|
246
|
+
// sends when the view rule no longer lets the caller see the saved row.
|
|
247
|
+
function hiddenAnswer(record) {
|
|
248
|
+
return Object.keys(record).every((key) => key === "id" || key === "collectionId" || key === "collectionName");
|
|
249
|
+
}
|
|
250
|
+
// Sends the mutations: one call for one, a single all-or-nothing batch for
|
|
251
|
+
// several. Resolves to the saved records, in order (null for a delete).
|
|
252
|
+
async function save(viper, records, collection, mutations) {
|
|
253
|
+
if (mutations.length > 1) {
|
|
254
|
+
return viper.batch(mutations.map((m) => toOp(collection, m)));
|
|
255
|
+
}
|
|
256
|
+
const m = mutations[0];
|
|
257
|
+
if (m.type === "insert")
|
|
258
|
+
return [await records.create(m.modified)];
|
|
259
|
+
if (m.type === "update")
|
|
260
|
+
return [await records.update(m.key, m.changes)];
|
|
261
|
+
await records.delete(m.key);
|
|
262
|
+
return [null];
|
|
263
|
+
}
|
|
264
|
+
// One mutation as a batch write.
|
|
265
|
+
function toOp(collection, m) {
|
|
266
|
+
if (m.type === "insert") {
|
|
267
|
+
return { method: "create", collection, body: m.modified };
|
|
268
|
+
}
|
|
269
|
+
if (m.type === "update") {
|
|
270
|
+
return { method: "update", collection, id: m.key, body: m.changes };
|
|
271
|
+
}
|
|
272
|
+
return { method: "delete", collection, id: m.key };
|
|
273
|
+
}
|
|
274
|
+
// Collections made by `useViperCollection`, one per client, name and mode, so
|
|
275
|
+
// two components asking for `posts` share one copy of it (and one fetch).
|
|
276
|
+
const made = new WeakMap();
|
|
277
|
+
/**
|
|
278
|
+
* The Viper collection `name` for the client in `<ViperProvider>`, made once
|
|
279
|
+
* and shared: every component asking for the same name and mode gets the same
|
|
280
|
+
* collection, so its records are fetched once. Use it to insert, update and
|
|
281
|
+
* delete, and as a `from` in live queries. Nothing about `useRecords` changes.
|
|
282
|
+
*
|
|
283
|
+
* ```tsx
|
|
284
|
+
* const posts = useViperCollection<Post>("posts")
|
|
285
|
+
* posts.insert({ id: newId(), title: "Hi" })
|
|
286
|
+
* ```
|
|
287
|
+
*/
|
|
288
|
+
export function useViperCollection(name, options = {}) {
|
|
289
|
+
const viper = useViper();
|
|
290
|
+
const mode = options.syncMode ?? "eager";
|
|
291
|
+
let byName = made.get(viper);
|
|
292
|
+
if (!byName) {
|
|
293
|
+
byName = new Map();
|
|
294
|
+
made.set(viper, byName);
|
|
295
|
+
}
|
|
296
|
+
const key = `${mode}:${name}`;
|
|
297
|
+
let found = byName.get(key);
|
|
298
|
+
if (!found) {
|
|
299
|
+
found = createCollection(viperCollectionOptions({ viper, collection: name, syncMode: mode }));
|
|
300
|
+
byName.set(key, found);
|
|
301
|
+
}
|
|
302
|
+
return found;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The rows of a Viper collection as a live list, like `useRecords` but read
|
|
306
|
+
* from the shared collection, so it updates row by row with no refetch. Give
|
|
307
|
+
* `query` to filter, sort or join; without it you get every row. In
|
|
308
|
+
* `on-demand` mode `query` is what decides what is loaded.
|
|
309
|
+
*
|
|
310
|
+
* ```tsx
|
|
311
|
+
* const { data, loading } = useLiveRecords<Post>("posts", {
|
|
312
|
+
* query: (q, posts) =>
|
|
313
|
+
* q.from({ p: posts }).where(({ p }) => eq(p.status, "live")),
|
|
314
|
+
* })
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
export function useLiveRecords(name, options = {}) {
|
|
318
|
+
const rows = useViperCollection(name, options);
|
|
319
|
+
const { query } = options;
|
|
320
|
+
const live = useLiveQuery({
|
|
321
|
+
query: (q) => query
|
|
322
|
+
? query(q, rows)
|
|
323
|
+
: q.from({ row: rows }),
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
data: live.isReady ? live.data : null,
|
|
327
|
+
error: live.isError ? new Error(`${name} could not be loaded`) : null,
|
|
328
|
+
loading: live.isLoading || live.isIdle,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RealtimeEvent, RecordModel } from "@polyphron/viper-client";
|
|
2
|
+
/** One page as the server sent it, and how it was asked for. */
|
|
3
|
+
export type PageState<T extends RecordModel = RecordModel> = {
|
|
4
|
+
/** The rows, in the server's order. */
|
|
5
|
+
rows: T[];
|
|
6
|
+
page: number;
|
|
7
|
+
/** -1 when unknown. */
|
|
8
|
+
totalPages: number;
|
|
9
|
+
/** The sort the page was asked for, like `-updated,title`. */
|
|
10
|
+
sort?: string;
|
|
11
|
+
filter?: string;
|
|
12
|
+
deleted?: boolean;
|
|
13
|
+
/** The `expand` it was asked for, like `author,tags.owner`. */
|
|
14
|
+
expand?: string;
|
|
15
|
+
};
|
|
16
|
+
/** What to do: write `patch` in, take `drop` out, and maybe ask again. */
|
|
17
|
+
export type PagePlan<T extends RecordModel = RecordModel> = {
|
|
18
|
+
patch?: T;
|
|
19
|
+
drop?: string;
|
|
20
|
+
refetch: boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Orders rows as the server does for `sort`, or null when the sort has a
|
|
24
|
+
* field the browser can't order the same way (text in the database's
|
|
25
|
+
* collation, numbers, lists).
|
|
26
|
+
*/
|
|
27
|
+
export declare function pageComparator<T extends RecordModel>(sort: string | undefined): ((a: T, b: T) => number) | null;
|
|
28
|
+
/** What `event` does to `page`. */
|
|
29
|
+
export declare function planPage<T extends RecordModel>(event: RealtimeEvent<T>, page: PageState<T>): PagePlan<T>;
|