@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Sort fields the browser orders just as Postgres does: times compared as
|
|
2
|
+
// times, and ids (a-z and 0-9) compared as text.
|
|
3
|
+
const TIMES = new Set(["created", "updated"]);
|
|
4
|
+
/**
|
|
5
|
+
* The sort as the server applies it, with its tie-breaker. The server
|
|
6
|
+
* breaks ties on `id` ascending, or sorts `-created,-id` when none is given.
|
|
7
|
+
*/
|
|
8
|
+
function sortKeys(sort) {
|
|
9
|
+
const given = (sort ?? "")
|
|
10
|
+
.split(",")
|
|
11
|
+
.map((s) => s.trim())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
if (!given.length) {
|
|
14
|
+
return [
|
|
15
|
+
{ field: "created", desc: true },
|
|
16
|
+
{ field: "id", desc: true },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
return [
|
|
20
|
+
...given.map((s) => ({
|
|
21
|
+
field: s.replace(/^[-+]/, ""),
|
|
22
|
+
desc: s.startsWith("-"),
|
|
23
|
+
})),
|
|
24
|
+
{ field: "id", desc: false },
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Orders rows as the server does for `sort`, or null when the sort has a
|
|
29
|
+
* field the browser can't order the same way (text in the database's
|
|
30
|
+
* collation, numbers, lists).
|
|
31
|
+
*/
|
|
32
|
+
export function pageComparator(sort) {
|
|
33
|
+
const keys = sortKeys(sort);
|
|
34
|
+
if (!keys.every((k) => k.field === "id" || TIMES.has(k.field)))
|
|
35
|
+
return null;
|
|
36
|
+
return (a, b) => {
|
|
37
|
+
for (const { field, desc } of keys) {
|
|
38
|
+
const x = String(a[field]);
|
|
39
|
+
const y = String(b[field]);
|
|
40
|
+
const order = field === "id"
|
|
41
|
+
? x < y
|
|
42
|
+
? -1
|
|
43
|
+
: x > y
|
|
44
|
+
? 1
|
|
45
|
+
: 0
|
|
46
|
+
: Date.parse(x) - Date.parse(y);
|
|
47
|
+
if (order)
|
|
48
|
+
return desc ? -order : order;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// True when two field values are the same, lists and objects included.
|
|
54
|
+
function same(a, b) {
|
|
55
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
56
|
+
}
|
|
57
|
+
/** What `event` does to `page`. */
|
|
58
|
+
export function planPage(event, page) {
|
|
59
|
+
// A new row, or a view that changed: only the server knows where it goes.
|
|
60
|
+
if (event.action === "create" || event.action === "refresh") {
|
|
61
|
+
return { refetch: true };
|
|
62
|
+
}
|
|
63
|
+
const { record } = event;
|
|
64
|
+
const old = page.rows.find((r) => r.id === record.id);
|
|
65
|
+
// The rows after it move up, and the count changes.
|
|
66
|
+
if (event.action === "delete") {
|
|
67
|
+
return { drop: old ? record.id : undefined, refetch: true };
|
|
68
|
+
}
|
|
69
|
+
// A row that isn't on the page can only move onto it if its sort values
|
|
70
|
+
// changed. Its old values aren't known, so only a sort on fields an
|
|
71
|
+
// update never changes (created, id) is safe to skip.
|
|
72
|
+
if (!old) {
|
|
73
|
+
const fixed = sortKeys(page.sort).every((k) => k.field === "created" || k.field === "id");
|
|
74
|
+
return { refetch: !fixed || !!page.filter || !!page.deleted };
|
|
75
|
+
}
|
|
76
|
+
// A change carries no `expand`, so the old one stays while the links do.
|
|
77
|
+
const relations = (page.expand ?? "")
|
|
78
|
+
.split(",")
|
|
79
|
+
.map((path) => path.trim().split(".")[0])
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
const relinked = relations.some((f) => !same(old[f], record[f]));
|
|
82
|
+
const patch = relinked ? record : { ...record, expand: old.expand };
|
|
83
|
+
// A filter or the Deleted view may now leave it out, and the filter can't
|
|
84
|
+
// be run here.
|
|
85
|
+
if (page.filter || page.deleted || relinked)
|
|
86
|
+
return { patch, refetch: true };
|
|
87
|
+
const moved = sortKeys(page.sort).some((k) => !same(old[k.field], record[k.field]));
|
|
88
|
+
if (!moved)
|
|
89
|
+
return { patch, refetch: false };
|
|
90
|
+
// It moved. Order the page here, and ask again only if it may now belong
|
|
91
|
+
// on the page before or after this one.
|
|
92
|
+
const compare = pageComparator(page.sort);
|
|
93
|
+
if (!compare)
|
|
94
|
+
return { patch, refetch: true };
|
|
95
|
+
const rows = page.rows
|
|
96
|
+
.map((r) => (r.id === record.id ? patch : r))
|
|
97
|
+
.sort(compare);
|
|
98
|
+
const at = rows.findIndex((r) => r.id === record.id);
|
|
99
|
+
const more = page.totalPages < 0 || page.page < page.totalPages;
|
|
100
|
+
const leaves = (at === 0 && page.page > 1) || (at === rows.length - 1 && more);
|
|
101
|
+
return { patch, refetch: leaves };
|
|
102
|
+
}
|
package/dist/records.d.ts
CHANGED
|
@@ -2,8 +2,14 @@ import type { ListOptions, ListResult, RecordModel } from "@polyphron/viper-clie
|
|
|
2
2
|
export type Query<T> = {
|
|
3
3
|
data: T | null;
|
|
4
4
|
error: unknown;
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* True until the first answer arrives for what is being shown (a new
|
|
7
|
+
* collection, or a new record id). A `reload` or a live update does not set
|
|
8
|
+
* it again, so the last data stays on screen; see `refreshing`.
|
|
9
|
+
*/
|
|
6
10
|
loading: boolean;
|
|
11
|
+
/** True whenever a request is in flight, first load and reloads included. */
|
|
12
|
+
refreshing: boolean;
|
|
7
13
|
/** Fetches again now. */
|
|
8
14
|
reload: () => void;
|
|
9
15
|
};
|
package/dist/records.js
CHANGED
|
@@ -13,57 +13,65 @@ export function useRecords(collection, options = {}) {
|
|
|
13
13
|
const { live = false, ...list } = options;
|
|
14
14
|
// Options are a fresh object every render; compare them by value.
|
|
15
15
|
const key = JSON.stringify(list);
|
|
16
|
-
const query = useFetch(useCallback((signal) => viper
|
|
16
|
+
const query = useFetch(collection, useCallback((signal) => viper
|
|
17
17
|
.collection(collection)
|
|
18
18
|
.list({ ...JSON.parse(key), signal }), [viper, collection, key]));
|
|
19
|
-
useLiveReload(live, collection, query.reload);
|
|
19
|
+
useLiveReload(live, collection, query.reload, viper);
|
|
20
20
|
return query;
|
|
21
21
|
}
|
|
22
22
|
/** One record, re-fetched when the id changes. */
|
|
23
23
|
export function useRecord(collection, id, options = {}) {
|
|
24
24
|
const viper = useViper();
|
|
25
25
|
const { live = false, expand } = options;
|
|
26
|
-
const query = useFetch(
|
|
26
|
+
const query = useFetch(
|
|
27
|
+
// A different record must never show the last one's data.
|
|
28
|
+
`${collection}/${id ?? ""}`, useCallback((signal) => id
|
|
27
29
|
? viper.collection(collection).view(id, { expand, signal })
|
|
28
30
|
: Promise.resolve(null), [viper, collection, id, expand]));
|
|
29
|
-
useLiveReload(live && Boolean(id), `${collection}/${id}`, query.reload);
|
|
31
|
+
useLiveReload(live && Boolean(id), `${collection}/${id}`, query.reload, viper);
|
|
30
32
|
return query;
|
|
31
33
|
}
|
|
32
34
|
// Runs `run` whenever it changes, and gives back its state. Answers from a
|
|
33
35
|
// call that has already been replaced are dropped, so a slow first request
|
|
34
36
|
// can't overwrite a fast second one.
|
|
35
37
|
//
|
|
36
|
-
// `loading`
|
|
37
|
-
// it belongs to, so anything older than the current
|
|
38
|
-
// waiting" — and no state has to be set while the effect
|
|
39
|
-
|
|
38
|
+
// `refreshing` and `loading` are worked out rather than stored: the answer
|
|
39
|
+
// carries the request it belongs to, so anything older than the current
|
|
40
|
+
// request means "still waiting" — and no state has to be set while the effect
|
|
41
|
+
// is starting. `subject` names what is being shown; data from another subject
|
|
42
|
+
// is never handed back, only data from a reload of the same one.
|
|
43
|
+
function useFetch(subject, run) {
|
|
40
44
|
const [attempt, setAttempt] = useState(0);
|
|
41
|
-
const [answer, setAnswer] = useState({ run: null, attempt: -1, data: null, error: null });
|
|
45
|
+
const [answer, setAnswer] = useState({ subject: "", run: null, attempt: -1, data: null, error: null });
|
|
42
46
|
useEffect(() => {
|
|
43
47
|
const controller = new AbortController();
|
|
44
48
|
run(controller.signal).then((data) => {
|
|
45
49
|
if (!controller.signal.aborted) {
|
|
46
|
-
setAnswer({ run, attempt, data, error: null });
|
|
50
|
+
setAnswer({ subject, run, attempt, data, error: null });
|
|
47
51
|
}
|
|
48
52
|
}, (error) => {
|
|
49
53
|
if (!controller.signal.aborted) {
|
|
50
|
-
setAnswer({ run, attempt, data: null, error });
|
|
54
|
+
setAnswer({ subject, run, attempt, data: null, error });
|
|
51
55
|
}
|
|
52
56
|
});
|
|
53
57
|
return () => controller.abort();
|
|
54
|
-
}, [run, attempt]);
|
|
58
|
+
}, [subject, run, attempt]);
|
|
55
59
|
const current = answer.run === run && answer.attempt === attempt;
|
|
60
|
+
const sameSubject = answer.run !== null && answer.subject === subject;
|
|
56
61
|
return {
|
|
57
62
|
// The last good data stays on screen while a reload is in flight.
|
|
58
|
-
data: answer.data,
|
|
63
|
+
data: sameSubject ? answer.data : null,
|
|
59
64
|
error: current ? answer.error : null,
|
|
60
|
-
loading: !
|
|
65
|
+
loading: !sameSubject,
|
|
66
|
+
refreshing: !current,
|
|
61
67
|
reload: useCallback(() => setAttempt((n) => n + 1), []),
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
|
-
// Reloads on every realtime event for `topic`, when `live` is on
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
70
|
+
// Reloads on every realtime event for `topic`, when `live` is on, and once
|
|
71
|
+
// more after the socket comes back from a drop, since events may have been
|
|
72
|
+
// missed while it was down. The hooks are always called, because hooks can't
|
|
73
|
+
// be conditional; an empty topic means no subscription.
|
|
74
|
+
function useLiveReload(live, topic, reload, viper) {
|
|
68
75
|
useRealtime(live ? topic : [], reload);
|
|
76
|
+
useEffect(() => (live ? viper.realtime.onResync(reload) : undefined), [live, viper, reload]);
|
|
69
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polyphron/viper-react",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "React provider and hooks for the Viper app API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
".": {
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
14
|
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./db": {
|
|
17
|
+
"types": "./dist/db.d.ts",
|
|
18
|
+
"default": "./dist/db.js"
|
|
15
19
|
}
|
|
16
20
|
},
|
|
17
21
|
"publishConfig": {
|
|
@@ -25,14 +29,17 @@
|
|
|
25
29
|
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
|
26
30
|
"typecheck": "tsc --noEmit"
|
|
27
31
|
},
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"@polyphron/viper-client": "^0.1.0"
|
|
30
|
-
},
|
|
31
32
|
"peerDependencies": {
|
|
33
|
+
"@polyphron/viper-client": "^0.2.0",
|
|
34
|
+
"@tanstack/db": "0.9.2",
|
|
35
|
+
"@tanstack/react-db": "0.4.1",
|
|
32
36
|
"react": "^18.3.0 || ^19.0.0"
|
|
33
37
|
},
|
|
34
38
|
"devDependencies": {
|
|
35
39
|
"@eslint/js": "^10",
|
|
40
|
+
"@polyphron/viper-client": "*",
|
|
41
|
+
"@tanstack/db": "0.9.2",
|
|
42
|
+
"@tanstack/react-db": "0.4.1",
|
|
36
43
|
"@types/node": "^24",
|
|
37
44
|
"@types/react": "^19",
|
|
38
45
|
"eslint": "^10",
|
|
@@ -43,5 +50,13 @@
|
|
|
43
50
|
},
|
|
44
51
|
"engines": {
|
|
45
52
|
"node": ">=20"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"@tanstack/db": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@tanstack/react-db": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
46
61
|
}
|
|
47
62
|
}
|