@polyphron/viper-react 0.3.0 → 0.4.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 +26 -0
- package/dist/auth.d.ts +25 -4
- package/dist/auth.js +59 -136
- package/dist/db-fetch.js +61 -89
- package/dist/db-page.js +38 -74
- package/dist/db-query.d.ts +0 -6
- package/dist/db-query.js +8 -8
- package/dist/db-shared.d.ts +50 -0
- package/dist/db-shared.js +82 -0
- package/dist/db.js +38 -68
- package/dist/identity.d.ts +21 -0
- package/dist/identity.js +54 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/loaded.d.ts +30 -0
- package/dist/loaded.js +64 -0
- package/dist/page-plan.js +1 -4
- package/dist/records.d.ts +1 -14
- package/dist/records.js +4 -39
- package/dist/runs.d.ts +2 -2
- package/dist/runs.js +7 -6
- package/package.json +2 -2
package/dist/db-page.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createCollection } from "@tanstack/db";
|
|
2
2
|
import { useLiveQuery } from "@tanstack/react-db";
|
|
3
3
|
import { useMemo, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { cacheOf, sendOps, singleFlight } from "./db-shared.js";
|
|
4
5
|
import { pageComparator, planPage } from "./page-plan.js";
|
|
5
6
|
import { useViper } from "./provider.js";
|
|
6
7
|
// A burst of changes (an import, a batch) asks for the page once.
|
|
@@ -12,14 +13,6 @@ const EMPTY = {
|
|
|
12
13
|
totalItems: 0,
|
|
13
14
|
totalPages: 0,
|
|
14
15
|
};
|
|
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
16
|
/**
|
|
24
17
|
* Options for `createCollection()`: one server page of `collection`, kept
|
|
25
18
|
* fresh from realtime. Its totals and row order are under `utils.info()`.
|
|
@@ -42,59 +35,42 @@ export function viperPageOptions(options) {
|
|
|
42
35
|
const sync = (params) => {
|
|
43
36
|
const { begin, write, commit, truncate, markReady, markError } = params;
|
|
44
37
|
let closed = false;
|
|
45
|
-
let loading = null;
|
|
46
|
-
let again = false;
|
|
47
38
|
let timer;
|
|
48
|
-
const load =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
|
39
|
+
const load = singleFlight({
|
|
40
|
+
pass: async () => {
|
|
41
|
+
const result = await records.list(query);
|
|
42
|
+
if (closed)
|
|
43
|
+
return;
|
|
44
|
+
// Truncate and rewrite in one transaction, so a reload never
|
|
45
|
+
// shows an empty page in between.
|
|
46
|
+
begin();
|
|
47
|
+
truncate();
|
|
48
|
+
rows.clear();
|
|
49
|
+
for (const record of result.items) {
|
|
50
|
+
write({ type: "insert", value: record });
|
|
51
|
+
rows.set(record.id, record);
|
|
86
52
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
53
|
+
commit();
|
|
54
|
+
markReady();
|
|
55
|
+
publish({
|
|
56
|
+
order: result.items.map((r) => r.id),
|
|
57
|
+
page: result.page,
|
|
58
|
+
perPage: result.perPage,
|
|
59
|
+
totalItems: result.totalItems,
|
|
60
|
+
totalPages: result.totalPages,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
closed: () => closed,
|
|
64
|
+
fail: markError,
|
|
65
|
+
});
|
|
90
66
|
const loadSoon = () => {
|
|
91
67
|
clearTimeout(timer);
|
|
92
68
|
timer = setTimeout(() => void load(), SETTLE);
|
|
93
69
|
};
|
|
94
70
|
const take = (event) => {
|
|
95
71
|
// The page on screen may be about to change; plan against the next one.
|
|
96
|
-
if (
|
|
97
|
-
|
|
72
|
+
if (load.busy()) {
|
|
73
|
+
void load();
|
|
98
74
|
return;
|
|
99
75
|
}
|
|
100
76
|
const plan = planPage(event, {
|
|
@@ -158,7 +134,11 @@ export function viperPageOptions(options) {
|
|
|
158
134
|
sync: { sync, rowUpdateMode: "full" },
|
|
159
135
|
utils,
|
|
160
136
|
onDelete: async ({ transaction }) => {
|
|
161
|
-
await
|
|
137
|
+
await sendOps(viper, transaction.mutations.map((m) => ({
|
|
138
|
+
method: "delete",
|
|
139
|
+
collection,
|
|
140
|
+
id: String(m.key),
|
|
141
|
+
})));
|
|
162
142
|
// The rows after them move up.
|
|
163
143
|
await live?.load();
|
|
164
144
|
},
|
|
@@ -166,32 +146,16 @@ export function viperPageOptions(options) {
|
|
|
166
146
|
}
|
|
167
147
|
// Page collections by client and page, so the route that preloads a page and
|
|
168
148
|
// the table that shows it share one collection (and one fetch). One leaves
|
|
169
|
-
//
|
|
170
|
-
|
|
149
|
+
// the cache when its sync stops, after nothing has read it for a while, and
|
|
150
|
+
// the next ask makes a fresh one.
|
|
151
|
+
const pages = cacheOf();
|
|
171
152
|
/**
|
|
172
153
|
* The page collection for `collection` and `query` on `viper`, made once and
|
|
173
154
|
* shared. Outside React (a route loader, say) call `.preload()` on it to
|
|
174
155
|
* start the fetch early.
|
|
175
156
|
*/
|
|
176
157
|
export function viperPage(viper, collection, query) {
|
|
177
|
-
|
|
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;
|
|
158
|
+
return pages(viper, `${collection}:${JSON.stringify(query)}`, () => createCollection(viperPageOptions({ viper, collection, query })));
|
|
195
159
|
}
|
|
196
160
|
/**
|
|
197
161
|
* One server page of `collection` as a live list: `records.list` once, then
|
package/dist/db-query.d.ts
CHANGED
|
@@ -6,12 +6,6 @@ export type Translated = {
|
|
|
6
6
|
/** True when the filter matches exactly what the query does. */
|
|
7
7
|
exact: boolean;
|
|
8
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
9
|
/**
|
|
16
10
|
* A subset's `where` as a Viper filter. Anything Viper cannot say is left out
|
|
17
11
|
* (so more rows load), and the query filters again in the browser.
|
package/dist/db-query.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// EXPERIMENTAL: turns a TanStack DB subset request (`where`, `orderBy`) into
|
|
2
|
+
// the `filter` and `sort` strings of Viper's records API.
|
|
3
|
+
//
|
|
4
|
+
// Everything here may load MORE than the query asks for, never less: TanStack
|
|
5
|
+
// DB runs every live query's own `where` again over what is loaded, so a
|
|
6
|
+
// wider load only costs bandwidth. `exact` says whether the load is the
|
|
7
|
+
// query's exact answer; only then is it safe to ask for a limited window.
|
|
8
|
+
import { quote } from "@polyphron/viper-client";
|
|
1
9
|
/** A field name is a plain word, so it can go into a filter unquoted. */
|
|
2
10
|
const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
11
|
/** The comparisons Viper writes the same way TanStack DB names them. */
|
|
@@ -17,14 +25,6 @@ const FLIP = {
|
|
|
17
25
|
lte: "gte",
|
|
18
26
|
};
|
|
19
27
|
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
28
|
// A value as a Viper literal, or null for one Viper has no way to write.
|
|
29
29
|
function literal(value) {
|
|
30
30
|
if (typeof value === "string")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BatchOp, RecordModel, Viper } from "@polyphron/viper-client";
|
|
2
|
+
/** What `singleFlight` needs from an adapter. */
|
|
3
|
+
export type FlightOptions = {
|
|
4
|
+
/** One load: fetch and put the answer into the collection. */
|
|
5
|
+
pass: () => Promise<void>;
|
|
6
|
+
/** True once the sync has stopped; a stopped sync drops its answers. */
|
|
7
|
+
closed: () => boolean;
|
|
8
|
+
/** Called with whatever a pass threw (the pass is not retried here). */
|
|
9
|
+
fail: (error: unknown) => void;
|
|
10
|
+
/** Runs before the first pass of a flight. */
|
|
11
|
+
start?: () => void;
|
|
12
|
+
/** Runs after a flight ends, whether it worked or failed. */
|
|
13
|
+
settle?: () => void;
|
|
14
|
+
};
|
|
15
|
+
/** A `load()` that runs one pass at a time; `busy()` says whether one runs. */
|
|
16
|
+
export type Flight = (() => Promise<void>) & {
|
|
17
|
+
busy: () => boolean;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Makes a `load()` that runs one pass at a time. A call during a pass does
|
|
21
|
+
* not start a second one; it asks for another pass right after the first, so
|
|
22
|
+
* a change that landed mid-load is never missed. Every caller gets the
|
|
23
|
+
* promise of the flight it joined.
|
|
24
|
+
*/
|
|
25
|
+
export declare function singleFlight(options: FlightOptions): Flight;
|
|
26
|
+
/** The bits of a TanStack DB collection that `cacheOf` listens to. */
|
|
27
|
+
type Evictable = {
|
|
28
|
+
on: (event: "status:change", callback: (change: {
|
|
29
|
+
status: string;
|
|
30
|
+
}) => void) => unknown;
|
|
31
|
+
};
|
|
32
|
+
/** A cache of collections; `of(owner)` lists what an owner holds now. */
|
|
33
|
+
export type Cache<V> = ((owner: object, key: string, make: () => V) => V) & {
|
|
34
|
+
of: (owner: object) => Map<string, V> | undefined;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A cache of collections by owner (a client, say) and key, so every reader
|
|
38
|
+
* of a key shares one collection and one request. A collection leaves the
|
|
39
|
+
* cache once TanStack DB cleans it up, after nothing has read it for a while:
|
|
40
|
+
* the next ask builds a fresh one rather than waking the old one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function cacheOf<V extends Evictable>(): Cache<V>;
|
|
43
|
+
/**
|
|
44
|
+
* Sends writes: one call for one, a single all-or-nothing batch for several.
|
|
45
|
+
* Resolves to the saved records, in order (null for a delete).
|
|
46
|
+
*/
|
|
47
|
+
export declare function sendOps(viper: Viper, ops: BatchOp[]): Promise<(RecordModel | null)[]>;
|
|
48
|
+
/** True when two values are the same, lists and objects included. */
|
|
49
|
+
export declare function same(a: unknown, b: unknown): boolean;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes a `load()` that runs one pass at a time. A call during a pass does
|
|
3
|
+
* not start a second one; it asks for another pass right after the first, so
|
|
4
|
+
* a change that landed mid-load is never missed. Every caller gets the
|
|
5
|
+
* promise of the flight it joined.
|
|
6
|
+
*/
|
|
7
|
+
export function singleFlight(options) {
|
|
8
|
+
let flight = null;
|
|
9
|
+
let again = false;
|
|
10
|
+
const load = () => {
|
|
11
|
+
if (flight) {
|
|
12
|
+
again = true;
|
|
13
|
+
return flight;
|
|
14
|
+
}
|
|
15
|
+
flight = (async () => {
|
|
16
|
+
options.start?.();
|
|
17
|
+
try {
|
|
18
|
+
do {
|
|
19
|
+
again = false;
|
|
20
|
+
await options.pass();
|
|
21
|
+
} while (again && !options.closed());
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
options.fail(error);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
flight = null;
|
|
28
|
+
options.settle?.();
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
return flight;
|
|
32
|
+
};
|
|
33
|
+
return Object.assign(load, { busy: () => flight !== null });
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A cache of collections by owner (a client, say) and key, so every reader
|
|
37
|
+
* of a key shares one collection and one request. A collection leaves the
|
|
38
|
+
* cache once TanStack DB cleans it up, after nothing has read it for a while:
|
|
39
|
+
* the next ask builds a fresh one rather than waking the old one.
|
|
40
|
+
*/
|
|
41
|
+
export function cacheOf() {
|
|
42
|
+
const byOwner = new WeakMap();
|
|
43
|
+
const get = (owner, key, make) => {
|
|
44
|
+
let byKey = byOwner.get(owner);
|
|
45
|
+
if (!byKey) {
|
|
46
|
+
byKey = new Map();
|
|
47
|
+
byOwner.set(owner, byKey);
|
|
48
|
+
}
|
|
49
|
+
const found = byKey.get(key);
|
|
50
|
+
if (found)
|
|
51
|
+
return found;
|
|
52
|
+
const made = make();
|
|
53
|
+
const map = byKey;
|
|
54
|
+
made.on("status:change", ({ status }) => {
|
|
55
|
+
if (status === "cleaned-up")
|
|
56
|
+
map.delete(key);
|
|
57
|
+
});
|
|
58
|
+
map.set(key, made);
|
|
59
|
+
return made;
|
|
60
|
+
};
|
|
61
|
+
return Object.assign(get, { of: (owner) => byOwner.get(owner) });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sends writes: one call for one, a single all-or-nothing batch for several.
|
|
65
|
+
* Resolves to the saved records, in order (null for a delete).
|
|
66
|
+
*/
|
|
67
|
+
export async function sendOps(viper, ops) {
|
|
68
|
+
if (ops.length !== 1)
|
|
69
|
+
return viper.batch(ops);
|
|
70
|
+
const op = ops[0];
|
|
71
|
+
const records = viper.collection(op.collection).records;
|
|
72
|
+
if (op.method === "create")
|
|
73
|
+
return [await records.create(op.body)];
|
|
74
|
+
if (op.method === "update")
|
|
75
|
+
return [await records.update(op.id, op.body)];
|
|
76
|
+
await records.delete(op.id);
|
|
77
|
+
return [null];
|
|
78
|
+
}
|
|
79
|
+
/** True when two values are the same, lists and objects included. */
|
|
80
|
+
export function same(a, b) {
|
|
81
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
82
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCollection } from "@tanstack/db";
|
|
2
2
|
import { useLiveQuery } from "@tanstack/react-db";
|
|
3
|
+
import { cacheOf, sendOps, singleFlight } from "./db-shared.js";
|
|
3
4
|
import { toViperFilter, toViperSort, toWindow } from "./db-query.js";
|
|
4
5
|
import { useViper } from "./provider.js";
|
|
5
6
|
export { toViperFilter, toViperSort } from "./db-query.js";
|
|
@@ -30,8 +31,6 @@ export function viperCollectionOptions(options) {
|
|
|
30
31
|
// event (our own write coming back, say) unable to undo a newer one.
|
|
31
32
|
const versions = new Map();
|
|
32
33
|
let closed = false;
|
|
33
|
-
let loading = false;
|
|
34
|
-
let again = false;
|
|
35
34
|
// Events that arrive while a load is in flight, replayed after it.
|
|
36
35
|
let buffered = [];
|
|
37
36
|
// On-demand: subset loads in flight (they buffer events too), and the
|
|
@@ -104,40 +103,27 @@ export function viperCollectionOptions(options) {
|
|
|
104
103
|
});
|
|
105
104
|
return result.items;
|
|
106
105
|
};
|
|
107
|
-
const load =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
buffered = [];
|
|
129
|
-
missed.forEach(apply);
|
|
130
|
-
commit();
|
|
131
|
-
markReady();
|
|
132
|
-
} while (again);
|
|
133
|
-
}
|
|
134
|
-
catch (error) {
|
|
135
|
-
markError(error);
|
|
136
|
-
}
|
|
137
|
-
finally {
|
|
138
|
-
loading = false;
|
|
139
|
-
}
|
|
140
|
-
};
|
|
106
|
+
const load = singleFlight({
|
|
107
|
+
pass: async () => {
|
|
108
|
+
const items = await fetchAll();
|
|
109
|
+
if (closed)
|
|
110
|
+
return;
|
|
111
|
+
// Truncate and rewrite in one transaction, so a reload never
|
|
112
|
+
// shows an empty list in between.
|
|
113
|
+
begin();
|
|
114
|
+
truncate();
|
|
115
|
+
versions.clear();
|
|
116
|
+
items.forEach(put);
|
|
117
|
+
const missed = buffered;
|
|
118
|
+
if (pending === 0)
|
|
119
|
+
buffered = [];
|
|
120
|
+
missed.forEach(apply);
|
|
121
|
+
commit();
|
|
122
|
+
markReady();
|
|
123
|
+
},
|
|
124
|
+
closed: () => closed,
|
|
125
|
+
fail: markError,
|
|
126
|
+
});
|
|
141
127
|
sink = {
|
|
142
128
|
put,
|
|
143
129
|
remove,
|
|
@@ -152,7 +138,7 @@ export function viperCollectionOptions(options) {
|
|
|
152
138
|
if (event.action === "refresh") {
|
|
153
139
|
void load();
|
|
154
140
|
}
|
|
155
|
-
else if (
|
|
141
|
+
else if (load.busy() || pending > 0) {
|
|
156
142
|
buffered.push(event);
|
|
157
143
|
}
|
|
158
144
|
else {
|
|
@@ -198,7 +184,7 @@ export function viperCollectionOptions(options) {
|
|
|
198
184
|
}
|
|
199
185
|
finally {
|
|
200
186
|
pending--;
|
|
201
|
-
if (pending === 0 && !
|
|
187
|
+
if (pending === 0 && !load.busy())
|
|
202
188
|
buffered = [];
|
|
203
189
|
}
|
|
204
190
|
},
|
|
@@ -227,15 +213,15 @@ export function viperCollectionOptions(options) {
|
|
|
227
213
|
sync: { sync, rowUpdateMode: "full" },
|
|
228
214
|
syncMode: onDemand ? "on-demand" : "eager",
|
|
229
215
|
onInsert: async ({ transaction }) => {
|
|
230
|
-
const saved = await
|
|
216
|
+
const saved = await sendOps(viper, toOps(collection, transaction.mutations));
|
|
231
217
|
store(saved);
|
|
232
218
|
},
|
|
233
219
|
onUpdate: async ({ transaction }) => {
|
|
234
|
-
const saved = await
|
|
220
|
+
const saved = await sendOps(viper, toOps(collection, transaction.mutations));
|
|
235
221
|
store(saved);
|
|
236
222
|
},
|
|
237
223
|
onDelete: async ({ transaction }) => {
|
|
238
|
-
await
|
|
224
|
+
await sendOps(viper, toOps(collection, transaction.mutations));
|
|
239
225
|
sink?.write(() => {
|
|
240
226
|
for (const m of transaction.mutations)
|
|
241
227
|
sink?.remove(m.key);
|
|
@@ -248,19 +234,9 @@ export function viperCollectionOptions(options) {
|
|
|
248
234
|
function hiddenAnswer(record) {
|
|
249
235
|
return Object.keys(record).every((key) => key === "id" || key === "collectionId" || key === "collectionName");
|
|
250
236
|
}
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (mutations.length > 1) {
|
|
255
|
-
return viper.batch(mutations.map((m) => toOp(collection, m)));
|
|
256
|
-
}
|
|
257
|
-
const m = mutations[0];
|
|
258
|
-
if (m.type === "insert")
|
|
259
|
-
return [await records.create(m.modified)];
|
|
260
|
-
if (m.type === "update")
|
|
261
|
-
return [await records.update(m.key, m.changes)];
|
|
262
|
-
await records.delete(m.key);
|
|
263
|
-
return [null];
|
|
237
|
+
// The mutations as batch writes; `sendOps` sends one alone when it can.
|
|
238
|
+
function toOps(collection, mutations) {
|
|
239
|
+
return mutations.map((m) => toOp(collection, m));
|
|
264
240
|
}
|
|
265
241
|
// One mutation as a batch write.
|
|
266
242
|
function toOp(collection, m) {
|
|
@@ -273,8 +249,9 @@ function toOp(collection, m) {
|
|
|
273
249
|
return { method: "delete", collection, id: m.key };
|
|
274
250
|
}
|
|
275
251
|
// Collections made by `useViperCollection`, one per client, name and mode, so
|
|
276
|
-
// two components asking for `posts` share one copy of it (and one fetch).
|
|
277
|
-
|
|
252
|
+
// two components asking for `posts` share one copy of it (and one fetch). One
|
|
253
|
+
// leaves when TanStack DB cleans it up, so the next ask gets a fresh one.
|
|
254
|
+
const made = cacheOf();
|
|
278
255
|
/**
|
|
279
256
|
* The Viper collection `name` for the client in `<ViperProvider>`, made once
|
|
280
257
|
* and shared: every component asking for the same name and mode gets the same
|
|
@@ -289,18 +266,11 @@ const made = new WeakMap();
|
|
|
289
266
|
export function useViperCollection(name, options = {}) {
|
|
290
267
|
const viper = useViper();
|
|
291
268
|
const mode = options.syncMode ?? "eager";
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
const key = `${mode}:${name}`;
|
|
298
|
-
let found = byName.get(key);
|
|
299
|
-
if (!found) {
|
|
300
|
-
found = createCollection(viperCollectionOptions({ viper, collection: name, syncMode: mode }));
|
|
301
|
-
byName.set(key, found);
|
|
302
|
-
}
|
|
303
|
-
return found;
|
|
269
|
+
return made(viper, `${mode}:${name}`, () => createCollection(viperCollectionOptions({
|
|
270
|
+
viper,
|
|
271
|
+
collection: name,
|
|
272
|
+
syncMode: mode,
|
|
273
|
+
})));
|
|
304
274
|
}
|
|
305
275
|
/**
|
|
306
276
|
* The rows of a Viper collection as a live list, like `useRecords` but read
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AppIdentity, AppMember, AppRole } from "@polyphron/viper-client";
|
|
2
|
+
/** The signed-in user's current app role and collection permission ceilings. */
|
|
3
|
+
export declare function useAppIdentity(): {
|
|
4
|
+
identity: AppIdentity | null;
|
|
5
|
+
error: Error | null;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
refresh: () => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
/** Opt-in app-admin role/member operations; successful edits refresh all data. */
|
|
10
|
+
export declare function useAppManagement(): {
|
|
11
|
+
putRole: (name: string, grid: AppRole["grid"], options?: Pick<import("@polyphron/viper-client").RequestOptions, "signal">) => Promise<void>;
|
|
12
|
+
deleteRole: (name: string, options?: Pick<import("@polyphron/viper-client").RequestOptions, "signal">) => Promise<void>;
|
|
13
|
+
putMember: (user: string, role: string, options?: Pick<import("@polyphron/viper-client").RequestOptions, "signal">) => Promise<void>;
|
|
14
|
+
deleteMember: (user: string, options?: Pick<import("@polyphron/viper-client").RequestOptions, "signal">) => Promise<void>;
|
|
15
|
+
identity: AppIdentity | null;
|
|
16
|
+
roles: AppRole[] | null;
|
|
17
|
+
members: AppMember[] | null;
|
|
18
|
+
error: Error | null;
|
|
19
|
+
loading: boolean;
|
|
20
|
+
refresh: () => Promise<void>;
|
|
21
|
+
};
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import { useSession } from "./auth.js";
|
|
3
|
+
import { asError, useLoaded } from "./loaded.js";
|
|
4
|
+
import { useViper } from "./provider.js";
|
|
5
|
+
/** Loads identity data scoped to this exact client and session. */
|
|
6
|
+
function useIdentityData(manage) {
|
|
7
|
+
const viper = useViper();
|
|
8
|
+
const session = useSession();
|
|
9
|
+
const { data, error, loading, reload } = useLoaded(session, useCallback(async (signal) => {
|
|
10
|
+
if (!session)
|
|
11
|
+
return null;
|
|
12
|
+
const identity = await viper.identity.me({ signal });
|
|
13
|
+
if (!manage)
|
|
14
|
+
return { identity, roles: null, members: null, error: null };
|
|
15
|
+
// A 403 on roles or members mustn't drop the role me() just gave.
|
|
16
|
+
try {
|
|
17
|
+
const [roles, members] = await Promise.all([
|
|
18
|
+
viper.identity.roles({ signal }),
|
|
19
|
+
viper.identity.members({ signal }),
|
|
20
|
+
]);
|
|
21
|
+
return { identity, roles, members, error: null };
|
|
22
|
+
}
|
|
23
|
+
catch (cause) {
|
|
24
|
+
return { identity, roles: null, members: null, error: asError(cause) };
|
|
25
|
+
}
|
|
26
|
+
}, [viper, session, manage]));
|
|
27
|
+
// Sign-in and role changes elsewhere in the app ask for a fresh load.
|
|
28
|
+
useEffect(() => viper.identity.onChange(reload), [viper, reload]);
|
|
29
|
+
return {
|
|
30
|
+
identity: data?.identity ?? null,
|
|
31
|
+
roles: data?.roles ?? null,
|
|
32
|
+
members: data?.members ?? null,
|
|
33
|
+
error: data?.error ?? (error ? asError(error) : null),
|
|
34
|
+
loading,
|
|
35
|
+
refresh: reload,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** The signed-in user's current app role and collection permission ceilings. */
|
|
39
|
+
export function useAppIdentity() {
|
|
40
|
+
const { identity, error, loading, refresh } = useIdentityData(false);
|
|
41
|
+
return { identity, error, loading, refresh };
|
|
42
|
+
}
|
|
43
|
+
/** Opt-in app-admin role/member operations; successful edits refresh all data. */
|
|
44
|
+
export function useAppManagement() {
|
|
45
|
+
const viper = useViper();
|
|
46
|
+
const state = useIdentityData(true);
|
|
47
|
+
return {
|
|
48
|
+
...state,
|
|
49
|
+
putRole: viper.identity.putRole,
|
|
50
|
+
deleteRole: viper.identity.deleteRole,
|
|
51
|
+
putMember: viper.identity.putMember,
|
|
52
|
+
deleteMember: viper.identity.deleteMember,
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { ViperProvider, useViper, type ViperProviderProps } from "./provider.js";
|
|
2
|
-
export { useAuth, useAuthMethods, useSession, useInvites, useSessions, useTenants, useUser, } from "./auth.js";
|
|
2
|
+
export { useAuth, useAuthMethods, useSession, useSessionOf, useInvites, useSessions, useTenants, useUser, } from "./auth.js";
|
|
3
3
|
export { useRealtime, useRealtimeStatus } from "./realtime.js";
|
|
4
|
-
export {
|
|
4
|
+
export { useLoaded, type Query } from "./loaded.js";
|
|
5
|
+
export { useRecord, useRecords, type RecordsOptions } from "./records.js";
|
|
5
6
|
export { useRun, type RunState } from "./runs.js";
|
|
6
|
-
export {
|
|
7
|
+
export { useAppIdentity, useAppManagement } from "./identity.js";
|
|
8
|
+
export { type AppIdentity, type AppMember, type AppRole, type AppLevel, Viper, ViperError, fieldError, memoryStorage, type ListOptions, type ListResult, type RealtimeEvent, type RealtimeStatus, type RecordInput, type RecordModel, type Session, type UserSession, type StartedRun, type ViperOptions, type WorkflowRun, } from "@polyphron/viper-client";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export { ViperProvider, useViper } from "./provider.js";
|
|
2
|
-
export { useAuth, useAuthMethods, useSession, useInvites, useSessions, useTenants, useUser, } from "./auth.js";
|
|
2
|
+
export { useAuth, useAuthMethods, useSession, useSessionOf, useInvites, useSessions, useTenants, useUser, } from "./auth.js";
|
|
3
3
|
export { useRealtime, useRealtimeStatus } from "./realtime.js";
|
|
4
|
-
export {
|
|
4
|
+
export { useLoaded } from "./loaded.js";
|
|
5
|
+
export { useRecord, useRecords } from "./records.js";
|
|
5
6
|
export { useRun } from "./runs.js";
|
|
7
|
+
export { useAppIdentity, useAppManagement } from "./identity.js";
|
|
6
8
|
// Re-exported so an app only needs @polyphron/viper-react in its imports.
|
|
7
9
|
export { Viper, ViperError, fieldError, memoryStorage, } from "@polyphron/viper-client";
|
package/dist/loaded.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** What a loading hook hands back. */
|
|
2
|
+
export type Query<T> = {
|
|
3
|
+
data: T | null;
|
|
4
|
+
error: unknown;
|
|
5
|
+
/**
|
|
6
|
+
* True until the first answer arrives for what is being shown (a new
|
|
7
|
+
* collection, a new record id, another user). A `reload` or a live update
|
|
8
|
+
* does not set it again, so the last data stays on screen; see
|
|
9
|
+
* `refreshing`.
|
|
10
|
+
*/
|
|
11
|
+
loading: boolean;
|
|
12
|
+
/** True whenever a request is in flight, first load and reloads included. */
|
|
13
|
+
refreshing: boolean;
|
|
14
|
+
/** Fetches again now. The promise settles once the new answer is in. */
|
|
15
|
+
reload: () => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
/** Makes an `Error` out of whatever a failed load threw. */
|
|
18
|
+
export declare function asError(cause: unknown): Error;
|
|
19
|
+
/**
|
|
20
|
+
* The one fetch hook: runs `run` whenever it or `subject` changes, and gives
|
|
21
|
+
* back its state. Answers from a call that has already been replaced are
|
|
22
|
+
* dropped, so a slow first request can't overwrite a fast second one.
|
|
23
|
+
*
|
|
24
|
+
* `subject` names what is being shown (a collection, a user, a session);
|
|
25
|
+
* data from another subject is never handed back, only data from a reload of
|
|
26
|
+
* the same one. Subjects are compared with `Object.is`. `reload` fetches in
|
|
27
|
+
* its own call, not through a render, so awaiting it inside React's `act`
|
|
28
|
+
* (or any batched update) can't deadlock.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useLoaded<T>(subject: unknown, run: (signal: AbortSignal) => Promise<T | null>): Query<T>;
|