@polyphron/viper-react 0.2.1 → 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 +70 -0
- package/dist/auth.d.ts +66 -4
- package/dist/auth.js +102 -5
- package/dist/db-fetch.d.ts +121 -0
- package/dist/db-fetch.js +398 -0
- 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.d.ts +1 -0
- package/dist/db.js +40 -71
- package/dist/identity.d.ts +21 -0
- package/dist/identity.js +54 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -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 +23 -0
- package/dist/runs.js +59 -0
- package/package.json +3 -2
|
@@ -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.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { useLiveQuery } from "@tanstack/react-db";
|
|
|
4
4
|
export { toViperFilter, toViperSort } from "./db-query.js";
|
|
5
5
|
export { useLivePage, viperPage, viperPageOptions, type LivePage, type PageCollection, type PageInfo, type PageQuery, type PageUtils, type ViperPageOptions, } from "./db-page.js";
|
|
6
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";
|
|
7
8
|
export { useLiveQuery };
|
|
8
9
|
export type ViperCollectionOptions = {
|
|
9
10
|
viper: Viper;
|
package/dist/db.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
6
7
|
export { useLivePage, viperPage, viperPageOptions, } from "./db-page.js";
|
|
7
8
|
export { pageComparator, planPage, } from "./page-plan.js";
|
|
9
|
+
export { fetchCollectionOptions, fetched, forgetFetched, refetchFetched, useFetched, useFetchedAll, } from "./db-fetch.js";
|
|
8
10
|
export { useLiveQuery };
|
|
9
11
|
/**
|
|
10
12
|
* Options for `createCollection()`: a TanStack DB collection that holds every
|
|
@@ -29,8 +31,6 @@ export function viperCollectionOptions(options) {
|
|
|
29
31
|
// event (our own write coming back, say) unable to undo a newer one.
|
|
30
32
|
const versions = new Map();
|
|
31
33
|
let closed = false;
|
|
32
|
-
let loading = false;
|
|
33
|
-
let again = false;
|
|
34
34
|
// Events that arrive while a load is in flight, replayed after it.
|
|
35
35
|
let buffered = [];
|
|
36
36
|
// On-demand: subset loads in flight (they buffer events too), and the
|
|
@@ -103,40 +103,27 @@ export function viperCollectionOptions(options) {
|
|
|
103
103
|
});
|
|
104
104
|
return result.items;
|
|
105
105
|
};
|
|
106
|
-
const load =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
};
|
|
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
|
+
});
|
|
140
127
|
sink = {
|
|
141
128
|
put,
|
|
142
129
|
remove,
|
|
@@ -151,7 +138,7 @@ export function viperCollectionOptions(options) {
|
|
|
151
138
|
if (event.action === "refresh") {
|
|
152
139
|
void load();
|
|
153
140
|
}
|
|
154
|
-
else if (
|
|
141
|
+
else if (load.busy() || pending > 0) {
|
|
155
142
|
buffered.push(event);
|
|
156
143
|
}
|
|
157
144
|
else {
|
|
@@ -197,7 +184,7 @@ export function viperCollectionOptions(options) {
|
|
|
197
184
|
}
|
|
198
185
|
finally {
|
|
199
186
|
pending--;
|
|
200
|
-
if (pending === 0 && !
|
|
187
|
+
if (pending === 0 && !load.busy())
|
|
201
188
|
buffered = [];
|
|
202
189
|
}
|
|
203
190
|
},
|
|
@@ -226,15 +213,15 @@ export function viperCollectionOptions(options) {
|
|
|
226
213
|
sync: { sync, rowUpdateMode: "full" },
|
|
227
214
|
syncMode: onDemand ? "on-demand" : "eager",
|
|
228
215
|
onInsert: async ({ transaction }) => {
|
|
229
|
-
const saved = await
|
|
216
|
+
const saved = await sendOps(viper, toOps(collection, transaction.mutations));
|
|
230
217
|
store(saved);
|
|
231
218
|
},
|
|
232
219
|
onUpdate: async ({ transaction }) => {
|
|
233
|
-
const saved = await
|
|
220
|
+
const saved = await sendOps(viper, toOps(collection, transaction.mutations));
|
|
234
221
|
store(saved);
|
|
235
222
|
},
|
|
236
223
|
onDelete: async ({ transaction }) => {
|
|
237
|
-
await
|
|
224
|
+
await sendOps(viper, toOps(collection, transaction.mutations));
|
|
238
225
|
sink?.write(() => {
|
|
239
226
|
for (const m of transaction.mutations)
|
|
240
227
|
sink?.remove(m.key);
|
|
@@ -247,19 +234,9 @@ export function viperCollectionOptions(options) {
|
|
|
247
234
|
function hiddenAnswer(record) {
|
|
248
235
|
return Object.keys(record).every((key) => key === "id" || key === "collectionId" || key === "collectionName");
|
|
249
236
|
}
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
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];
|
|
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));
|
|
263
240
|
}
|
|
264
241
|
// One mutation as a batch write.
|
|
265
242
|
function toOp(collection, m) {
|
|
@@ -272,8 +249,9 @@ function toOp(collection, m) {
|
|
|
272
249
|
return { method: "delete", collection, id: m.key };
|
|
273
250
|
}
|
|
274
251
|
// 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
|
-
|
|
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();
|
|
277
255
|
/**
|
|
278
256
|
* The Viper collection `name` for the client in `<ViperProvider>`, made once
|
|
279
257
|
* and shared: every component asking for the same name and mode gets the same
|
|
@@ -288,18 +266,11 @@ const made = new WeakMap();
|
|
|
288
266
|
export function useViperCollection(name, options = {}) {
|
|
289
267
|
const viper = useViper();
|
|
290
268
|
const mode = options.syncMode ?? "eager";
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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;
|
|
269
|
+
return made(viper, `${mode}:${name}`, () => createCollection(viperCollectionOptions({
|
|
270
|
+
viper,
|
|
271
|
+
collection: name,
|
|
272
|
+
syncMode: mode,
|
|
273
|
+
})));
|
|
303
274
|
}
|
|
304
275
|
/**
|
|
305
276
|
* The rows of a Viper collection as a live list, like `useRecords` but read
|
|
@@ -318,9 +289,7 @@ export function useLiveRecords(name, options = {}) {
|
|
|
318
289
|
const rows = useViperCollection(name, options);
|
|
319
290
|
const { query } = options;
|
|
320
291
|
const live = useLiveQuery({
|
|
321
|
-
query: (q) => query
|
|
322
|
-
? query(q, rows)
|
|
323
|
-
: q.from({ row: rows }),
|
|
292
|
+
query: (q) => (query ? query(q, rows) : q.from({ row: rows })),
|
|
324
293
|
});
|
|
325
294
|
return {
|
|
326
295
|
data: live.isReady ? live.data : null,
|
|
@@ -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,5 +1,8 @@
|
|
|
1
1
|
export { ViperProvider, useViper, type ViperProviderProps } from "./provider.js";
|
|
2
|
-
export { useAuth, useSession, 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 {
|
|
5
|
-
export {
|
|
4
|
+
export { useLoaded, type Query } from "./loaded.js";
|
|
5
|
+
export { useRecord, useRecords, type RecordsOptions } from "./records.js";
|
|
6
|
+
export { useRun, type RunState } from "./runs.js";
|
|
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,6 +1,9 @@
|
|
|
1
1
|
export { ViperProvider, useViper } from "./provider.js";
|
|
2
|
-
export { useAuth, useSession, 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";
|
|
6
|
+
export { useRun } from "./runs.js";
|
|
7
|
+
export { useAppIdentity, useAppManagement } from "./identity.js";
|
|
5
8
|
// Re-exported so an app only needs @polyphron/viper-react in its imports.
|
|
6
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>;
|
package/dist/loaded.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
/** Makes an `Error` out of whatever a failed load threw. */
|
|
3
|
+
export function asError(cause) {
|
|
4
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The one fetch hook: runs `run` whenever it or `subject` changes, and gives
|
|
8
|
+
* back its state. Answers from a call that has already been replaced are
|
|
9
|
+
* dropped, so a slow first request can't overwrite a fast second one.
|
|
10
|
+
*
|
|
11
|
+
* `subject` names what is being shown (a collection, a user, a session);
|
|
12
|
+
* data from another subject is never handed back, only data from a reload of
|
|
13
|
+
* the same one. Subjects are compared with `Object.is`. `reload` fetches in
|
|
14
|
+
* its own call, not through a render, so awaiting it inside React's `act`
|
|
15
|
+
* (or any batched update) can't deadlock.
|
|
16
|
+
*/
|
|
17
|
+
export function useLoaded(subject, run) {
|
|
18
|
+
const [inFlight, setInFlight] = useState(0);
|
|
19
|
+
const [answer, setAnswer] = useState({ subject: null, run: null, data: null, error: null });
|
|
20
|
+
// The newest request's controller and promise: starting one replaces the
|
|
21
|
+
// last, and a replaced request waits for the one that replaced it.
|
|
22
|
+
const controller = useRef(null);
|
|
23
|
+
const newest = useRef(Promise.resolve());
|
|
24
|
+
const fetchNow = useCallback((forSubject, forRun) => {
|
|
25
|
+
controller.current?.abort();
|
|
26
|
+
const mine = new AbortController();
|
|
27
|
+
controller.current = mine;
|
|
28
|
+
const request = (async () => {
|
|
29
|
+
// After a tick, so starting from an effect sets no state in it.
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
setInFlight((n) => n + 1);
|
|
32
|
+
try {
|
|
33
|
+
const data = await forRun(mine.signal);
|
|
34
|
+
if (!mine.signal.aborted) {
|
|
35
|
+
setAnswer({ subject: forSubject, run: forRun, data, error: null });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!mine.signal.aborted) {
|
|
40
|
+
setAnswer({ subject: forSubject, run: forRun, data: null, error });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
setInFlight((n) => n - 1);
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
newest.current = request;
|
|
48
|
+
return request.then(() => mine.signal.aborted ? newest.current : undefined);
|
|
49
|
+
}, []);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
void fetchNow(subject, run);
|
|
52
|
+
return () => controller.current?.abort();
|
|
53
|
+
}, [subject, run, fetchNow]);
|
|
54
|
+
const sameSubject = answer.run !== null && Object.is(answer.subject, subject);
|
|
55
|
+
const current = answer.run === run && inFlight === 0;
|
|
56
|
+
return {
|
|
57
|
+
// The last good data stays on screen while a reload is in flight.
|
|
58
|
+
data: sameSubject ? answer.data : null,
|
|
59
|
+
error: current ? answer.error : null,
|
|
60
|
+
loading: !sameSubject,
|
|
61
|
+
refreshing: !current,
|
|
62
|
+
reload: useCallback(() => fetchNow(subject, run), [fetchNow, subject, run]),
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/page-plan.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { same } from "./db-shared.js";
|
|
1
2
|
// Sort fields the browser orders just as Postgres does: times compared as
|
|
2
3
|
// times, and ids (a-z and 0-9) compared as text.
|
|
3
4
|
const TIMES = new Set(["created", "updated"]);
|
|
@@ -50,10 +51,6 @@ export function pageComparator(sort) {
|
|
|
50
51
|
return 0;
|
|
51
52
|
};
|
|
52
53
|
}
|
|
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
54
|
/** What `event` does to `page`. */
|
|
58
55
|
export function planPage(event, page) {
|
|
59
56
|
// A new row, or a view that changed: only the server knows where it goes.
|
package/dist/records.d.ts
CHANGED
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import type { ListOptions, ListResult, RecordModel } from "@polyphron/viper-client";
|
|
2
|
-
|
|
3
|
-
data: T | null;
|
|
4
|
-
error: unknown;
|
|
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
|
-
*/
|
|
10
|
-
loading: boolean;
|
|
11
|
-
/** True whenever a request is in flight, first load and reloads included. */
|
|
12
|
-
refreshing: boolean;
|
|
13
|
-
/** Fetches again now. */
|
|
14
|
-
reload: () => void;
|
|
15
|
-
};
|
|
2
|
+
import { type Query } from "./loaded.js";
|
|
16
3
|
export type RecordsOptions = ListOptions & {
|
|
17
4
|
/** Re-fetch when anything in the collection changes. Off by default. */
|
|
18
5
|
live?: boolean;
|
package/dist/records.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useCallback, useEffect
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import { useLoaded } from "./loaded.js";
|
|
2
3
|
import { useViper } from "./provider.js";
|
|
3
4
|
import { useRealtime } from "./realtime.js";
|
|
4
5
|
/**
|
|
@@ -13,7 +14,7 @@ export function useRecords(collection, options = {}) {
|
|
|
13
14
|
const { live = false, ...list } = options;
|
|
14
15
|
// Options are a fresh object every render; compare them by value.
|
|
15
16
|
const key = JSON.stringify(list);
|
|
16
|
-
const query =
|
|
17
|
+
const query = useLoaded(collection, useCallback((signal) => viper
|
|
17
18
|
.collection(collection)
|
|
18
19
|
.list({ ...JSON.parse(key), signal }), [viper, collection, key]));
|
|
19
20
|
useLiveReload(live, collection, query.reload, viper);
|
|
@@ -23,7 +24,7 @@ export function useRecords(collection, options = {}) {
|
|
|
23
24
|
export function useRecord(collection, id, options = {}) {
|
|
24
25
|
const viper = useViper();
|
|
25
26
|
const { live = false, expand } = options;
|
|
26
|
-
const query =
|
|
27
|
+
const query = useLoaded(
|
|
27
28
|
// A different record must never show the last one's data.
|
|
28
29
|
`${collection}/${id ?? ""}`, useCallback((signal) => id
|
|
29
30
|
? viper.collection(collection).view(id, { expand, signal })
|
|
@@ -31,42 +32,6 @@ export function useRecord(collection, id, options = {}) {
|
|
|
31
32
|
useLiveReload(live && Boolean(id), `${collection}/${id}`, query.reload, viper);
|
|
32
33
|
return query;
|
|
33
34
|
}
|
|
34
|
-
// Runs `run` whenever it changes, and gives back its state. Answers from a
|
|
35
|
-
// call that has already been replaced are dropped, so a slow first request
|
|
36
|
-
// can't overwrite a fast second one.
|
|
37
|
-
//
|
|
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) {
|
|
44
|
-
const [attempt, setAttempt] = useState(0);
|
|
45
|
-
const [answer, setAnswer] = useState({ subject: "", run: null, attempt: -1, data: null, error: null });
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
const controller = new AbortController();
|
|
48
|
-
run(controller.signal).then((data) => {
|
|
49
|
-
if (!controller.signal.aborted) {
|
|
50
|
-
setAnswer({ subject, run, attempt, data, error: null });
|
|
51
|
-
}
|
|
52
|
-
}, (error) => {
|
|
53
|
-
if (!controller.signal.aborted) {
|
|
54
|
-
setAnswer({ subject, run, attempt, data: null, error });
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
return () => controller.abort();
|
|
58
|
-
}, [subject, run, attempt]);
|
|
59
|
-
const current = answer.run === run && answer.attempt === attempt;
|
|
60
|
-
const sameSubject = answer.run !== null && answer.subject === subject;
|
|
61
|
-
return {
|
|
62
|
-
// The last good data stays on screen while a reload is in flight.
|
|
63
|
-
data: sameSubject ? answer.data : null,
|
|
64
|
-
error: current ? answer.error : null,
|
|
65
|
-
loading: !sameSubject,
|
|
66
|
-
refreshing: !current,
|
|
67
|
-
reload: useCallback(() => setAttempt((n) => n + 1), []),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
35
|
// Reloads on every realtime event for `topic`, when `live` is on, and once
|
|
71
36
|
// more after the socket comes back from a drop, since events may have been
|
|
72
37
|
// missed while it was down. The hooks are always called, because hooks can't
|
package/dist/runs.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { StartedRun, WorkflowRun } from "@polyphron/viper-client";
|
|
2
|
+
export type RunState<T> = {
|
|
3
|
+
/** The run as last read, or null before the first answer. */
|
|
4
|
+
data: WorkflowRun<T> | null;
|
|
5
|
+
error: unknown;
|
|
6
|
+
/** True from the start until the run has ended, or can't be read. */
|
|
7
|
+
pending: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Watches a background workflow's run until it's done, failed or cancelled, reading
|
|
11
|
+
* it every `every` milliseconds (a second by default). Pass the
|
|
12
|
+
* `StartedRun` its call returned; nothing is read while it's empty.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const [started, setStarted] = useState<StartedRun>()
|
|
16
|
+
* const { data, pending } = useRun<{ text: string }>("forge", started)
|
|
17
|
+
* // on click: setStarted(await viper.workflows.call<StartedRun>("forge", input))
|
|
18
|
+
* // when done: data.response.body.text
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function useRun<T = unknown>(name: string, started: StartedRun | null | undefined, options?: {
|
|
22
|
+
every?: number;
|
|
23
|
+
}): RunState<T>;
|