@polyphron/viper-react 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/auth.d.ts +45 -4
- package/dist/auth.js +178 -4
- package/dist/db-fetch.d.ts +121 -0
- package/dist/db-fetch.js +426 -0
- package/dist/db.d.ts +1 -0
- package/dist/db.js +2 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/runs.d.ts +23 -0
- package/dist/runs.js +58 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -200,6 +200,50 @@ collection.utils.refetch() // ask for the page again
|
|
|
200
200
|
- `collection.utils.apply(event)` shows a change you made yourself (a save)
|
|
201
201
|
without waiting for its realtime event.
|
|
202
202
|
|
|
203
|
+
### Any endpoint as a collection
|
|
204
|
+
|
|
205
|
+
For data that isn't records (settings, keys, logs, anything an endpoint hands
|
|
206
|
+
back whole), `fetched(owner, options)` makes a TanStack DB collection from a
|
|
207
|
+
fetch, and `useFetched(collection)` reads it as
|
|
208
|
+
`{ data, error, isPending, isFetching, updatedAt, refetch }`.
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
import { fetched, refetchFetched, useFetched } from "@polyphron/viper-react/db"
|
|
212
|
+
|
|
213
|
+
const settings = (p: string) =>
|
|
214
|
+
fetched<Settings>(client, {
|
|
215
|
+
key: ["projects", p, "settings"],
|
|
216
|
+
fetch: (signal) => api.settings(p, { signal }),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const { data } = useFetched(settings(projectId))
|
|
220
|
+
|
|
221
|
+
await save(input)
|
|
222
|
+
await refetchFetched(client, ["projects", projectId]) // every key under it
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- The same `owner` and `key` always give the same collection, so every
|
|
226
|
+
reader shares one load. A list (pass `getKey`) keeps the server's order; a
|
|
227
|
+
single object is one row.
|
|
228
|
+
- It loads again when told to (`refetchFetched` by key prefix, or
|
|
229
|
+
`collection.utils.refetch()`), on `refetchInterval` while read, and when
|
|
230
|
+
the window comes back after `staleTime` (30 seconds by default). Only
|
|
231
|
+
changed rows are rewritten, so the rest keep their identity.
|
|
232
|
+
- `collection.utils.set(data)` puts in a save's answer with no request.
|
|
233
|
+
`await collection.utils.ready()` waits for the first load, from a route
|
|
234
|
+
loader say. `forgetFetched(owner, prefix)` drops collections (after a
|
|
235
|
+
delete).
|
|
236
|
+
- Network errors retry twice; a "no" or "not found" doesn't. The error is
|
|
237
|
+
in `error`, and the last good data stays.
|
|
238
|
+
- `useFetched(collection, { keepPrevious: true })` keeps the last data on
|
|
239
|
+
screen while a new collection (the next page, a new filter) loads.
|
|
240
|
+
`useFetchedAll(collections)` reads several, one per project say.
|
|
241
|
+
|
|
242
|
+
The Viper dashboard reads the whole admin API this way, through
|
|
243
|
+
`@polyphron/viper-react/db/admin` (`adminCollections(admin)`). That entry
|
|
244
|
+
stays in the workspace, like the admin client it wraps, and isn't
|
|
245
|
+
published.
|
|
246
|
+
|
|
203
247
|
### Recipe: typed collections, a join and an optimistic insert
|
|
204
248
|
|
|
205
249
|
1. Write the types once with `viper typegen --out src/viper-types.ts` (see
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,17 +1,58 @@
|
|
|
1
|
-
import type { AuthService, RecordModel, Session } from "@polyphron/viper-client";
|
|
1
|
+
import type { AuthMethods, AuthService, Invite, InviteInput, RecordModel, Session, UserSession } from "@polyphron/viper-client";
|
|
2
2
|
/** The current session, re-rendering on login, logout and refresh. */
|
|
3
3
|
export declare function useSession(): Session | null;
|
|
4
|
+
/** The signed-in user's active sessions, with revoke and log-out actions. */
|
|
5
|
+
export declare function useSessions(): {
|
|
6
|
+
sessions: UserSession[] | null;
|
|
7
|
+
error: Error | null;
|
|
8
|
+
refresh: () => Promise<void>;
|
|
9
|
+
revoke: (sessionId: string) => Promise<void>;
|
|
10
|
+
logoutEverywhere: () => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
/** The invites the signed-in user sent, with send and take-back actions. */
|
|
13
|
+
export declare function useInvites(): {
|
|
14
|
+
invites: Invite[] | null;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
refresh: () => Promise<void>;
|
|
17
|
+
invite: (input: InviteInput) => Promise<Invite>;
|
|
18
|
+
cancel: (id: string) => Promise<void>;
|
|
19
|
+
};
|
|
4
20
|
/** The logged-in user, or null when signed out. */
|
|
5
21
|
export declare function useUser<T extends RecordModel = RecordModel>(): T | null;
|
|
6
22
|
/**
|
|
7
|
-
* The
|
|
23
|
+
* The project's sign-in, plus the user it belongs to. Without a
|
|
24
|
+
* collection it is the users table's (`viper.auth`); an old project with
|
|
25
|
+
* several auth collections names one.
|
|
8
26
|
*
|
|
9
27
|
* ```tsx
|
|
10
|
-
* const { user, auth } = useAuth(
|
|
28
|
+
* const { user, auth } = useAuth()
|
|
11
29
|
* await auth.loginWithPassword(email, password)
|
|
12
30
|
* ```
|
|
13
31
|
*/
|
|
14
|
-
export declare function useAuth<T extends RecordModel = RecordModel>(collection
|
|
32
|
+
export declare function useAuth<T extends RecordModel = RecordModel>(collection?: string): {
|
|
15
33
|
user: T | null;
|
|
16
34
|
auth: AuthService<T>;
|
|
17
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* How to sign in here: the ways on offer, sign-up, and the sub-app's name
|
|
38
|
+
* and branding when the client has one (`new Viper({ app })`). Null while
|
|
39
|
+
* it loads. For a sign-in page.
|
|
40
|
+
*
|
|
41
|
+
* ```tsx
|
|
42
|
+
* const methods = useAuthMethods()
|
|
43
|
+
* return <h1>{methods?.app?.branding.name ?? methods?.app?.name}</h1>
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function useAuthMethods(): AuthMethods | null;
|
|
47
|
+
/**
|
|
48
|
+
* The signed-in user's memberships of tenant collection `name` (like
|
|
49
|
+
* "orgs"), each with its role and the tenant under `expand.tenant`. Empty
|
|
50
|
+
* while signed out, null while it loads. It loads again when the user
|
|
51
|
+
* changes.
|
|
52
|
+
*
|
|
53
|
+
* ```tsx
|
|
54
|
+
* const orgs = useTenants("orgs")
|
|
55
|
+
* return orgs?.map((m) => <li key={m.id}>{m.expand?.tenant?.name}</li>)
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function useTenants(name: string): RecordModel[] | null;
|
package/dist/auth.js
CHANGED
|
@@ -1,24 +1,198 @@
|
|
|
1
|
-
import { useSyncExternalStore } from "react";
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
2
2
|
import { useViper } from "./provider.js";
|
|
3
3
|
/** The current session, re-rendering on login, logout and refresh. */
|
|
4
4
|
export function useSession() {
|
|
5
5
|
const viper = useViper();
|
|
6
6
|
return useSyncExternalStore((listener) => viper.onSessionChange(listener), () => viper.session, () => null);
|
|
7
7
|
}
|
|
8
|
+
/** The signed-in user's active sessions, with revoke and log-out actions. */
|
|
9
|
+
export function useSessions() {
|
|
10
|
+
const viper = useViper();
|
|
11
|
+
const user = useUser();
|
|
12
|
+
const [loaded, setLoaded] = useState(null);
|
|
13
|
+
const refresh = useCallback(async () => {
|
|
14
|
+
const userId = user?.id;
|
|
15
|
+
if (!userId)
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
setLoaded({
|
|
19
|
+
userId,
|
|
20
|
+
sessions: await viper.auth.sessions(),
|
|
21
|
+
error: null,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (cause) {
|
|
25
|
+
setLoaded({
|
|
26
|
+
userId,
|
|
27
|
+
sessions: [],
|
|
28
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}, [user?.id, viper]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const ctl = new AbortController();
|
|
34
|
+
const userId = user?.id;
|
|
35
|
+
if (userId) {
|
|
36
|
+
viper.auth
|
|
37
|
+
.sessions({ signal: ctl.signal })
|
|
38
|
+
.then((sessions) => setLoaded({ userId, sessions, error: null }))
|
|
39
|
+
.catch((cause) => {
|
|
40
|
+
if (!ctl.signal.aborted) {
|
|
41
|
+
setLoaded({
|
|
42
|
+
userId,
|
|
43
|
+
sessions: [],
|
|
44
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return () => ctl.abort();
|
|
50
|
+
}, [viper, user?.id]);
|
|
51
|
+
const revoke = async (sessionId) => {
|
|
52
|
+
await viper.auth.revokeSession(sessionId);
|
|
53
|
+
await refresh();
|
|
54
|
+
};
|
|
55
|
+
const logoutEverywhere = () => viper.auth.logoutEverywhere();
|
|
56
|
+
const current = loaded?.userId === user?.id ? loaded : null;
|
|
57
|
+
return {
|
|
58
|
+
sessions: current?.sessions ?? null,
|
|
59
|
+
error: current?.error ?? null,
|
|
60
|
+
refresh,
|
|
61
|
+
revoke,
|
|
62
|
+
logoutEverywhere,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** The invites the signed-in user sent, with send and take-back actions. */
|
|
66
|
+
export function useInvites() {
|
|
67
|
+
const viper = useViper();
|
|
68
|
+
const user = useUser();
|
|
69
|
+
const [loaded, setLoaded] = useState(null);
|
|
70
|
+
const refresh = useCallback(async () => {
|
|
71
|
+
const userId = user?.id;
|
|
72
|
+
if (!userId)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
setLoaded({ userId, invites: await viper.auth.invites(), error: null });
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
setLoaded({
|
|
79
|
+
userId,
|
|
80
|
+
invites: [],
|
|
81
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}, [user?.id, viper]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let live = true;
|
|
87
|
+
const userId = user?.id;
|
|
88
|
+
if (userId) {
|
|
89
|
+
viper.auth
|
|
90
|
+
.invites()
|
|
91
|
+
.then((invites) => {
|
|
92
|
+
if (live)
|
|
93
|
+
setLoaded({ userId, invites, error: null });
|
|
94
|
+
})
|
|
95
|
+
.catch((cause) => {
|
|
96
|
+
if (live) {
|
|
97
|
+
setLoaded({
|
|
98
|
+
userId,
|
|
99
|
+
invites: [],
|
|
100
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return () => {
|
|
106
|
+
live = false;
|
|
107
|
+
};
|
|
108
|
+
}, [viper, user?.id]);
|
|
109
|
+
const invite = async (input) => {
|
|
110
|
+
const made = await viper.auth.invite(input);
|
|
111
|
+
await refresh();
|
|
112
|
+
return made;
|
|
113
|
+
};
|
|
114
|
+
const cancel = async (id) => {
|
|
115
|
+
await viper.auth.cancelInvite(id);
|
|
116
|
+
await refresh();
|
|
117
|
+
};
|
|
118
|
+
const current = loaded?.userId === user?.id ? loaded : null;
|
|
119
|
+
return {
|
|
120
|
+
invites: current?.invites ?? null,
|
|
121
|
+
error: current?.error ?? null,
|
|
122
|
+
refresh,
|
|
123
|
+
invite,
|
|
124
|
+
cancel,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
8
127
|
/** The logged-in user, or null when signed out. */
|
|
9
128
|
export function useUser() {
|
|
10
129
|
return useSession()?.record ?? null;
|
|
11
130
|
}
|
|
12
131
|
/**
|
|
13
|
-
* The
|
|
132
|
+
* The project's sign-in, plus the user it belongs to. Without a
|
|
133
|
+
* collection it is the users table's (`viper.auth`); an old project with
|
|
134
|
+
* several auth collections names one.
|
|
14
135
|
*
|
|
15
136
|
* ```tsx
|
|
16
|
-
* const { user, auth } = useAuth(
|
|
137
|
+
* const { user, auth } = useAuth()
|
|
17
138
|
* await auth.loginWithPassword(email, password)
|
|
18
139
|
* ```
|
|
19
140
|
*/
|
|
20
141
|
export function useAuth(collection) {
|
|
21
142
|
const viper = useViper();
|
|
22
143
|
const user = useUser();
|
|
23
|
-
|
|
144
|
+
const auth = collection
|
|
145
|
+
? viper.collection(collection).auth
|
|
146
|
+
: viper.auth;
|
|
147
|
+
return { user, auth };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* How to sign in here: the ways on offer, sign-up, and the sub-app's name
|
|
151
|
+
* and branding when the client has one (`new Viper({ app })`). Null while
|
|
152
|
+
* it loads. For a sign-in page.
|
|
153
|
+
*
|
|
154
|
+
* ```tsx
|
|
155
|
+
* const methods = useAuthMethods()
|
|
156
|
+
* return <h1>{methods?.app?.branding.name ?? methods?.app?.name}</h1>
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export function useAuthMethods() {
|
|
160
|
+
const viper = useViper();
|
|
161
|
+
const [methods, setMethods] = useState(null);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const ctl = new AbortController();
|
|
164
|
+
viper.auth
|
|
165
|
+
.methods({ signal: ctl.signal })
|
|
166
|
+
.then(setMethods)
|
|
167
|
+
// A failed load stays null; the sign-in calls report their own errors.
|
|
168
|
+
.catch(() => { });
|
|
169
|
+
return () => ctl.abort();
|
|
170
|
+
}, [viper]);
|
|
171
|
+
return methods;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The signed-in user's memberships of tenant collection `name` (like
|
|
175
|
+
* "orgs"), each with its role and the tenant under `expand.tenant`. Empty
|
|
176
|
+
* while signed out, null while it loads. It loads again when the user
|
|
177
|
+
* changes.
|
|
178
|
+
*
|
|
179
|
+
* ```tsx
|
|
180
|
+
* const orgs = useTenants("orgs")
|
|
181
|
+
* return orgs?.map((m) => <li key={m.id}>{m.expand?.tenant?.name}</li>)
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export function useTenants(name) {
|
|
185
|
+
const viper = useViper();
|
|
186
|
+
const user = useUser();
|
|
187
|
+
const [tenants, setTenants] = useState(null);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const ctl = new AbortController();
|
|
190
|
+
viper
|
|
191
|
+
.tenants(name, { signal: ctl.signal })
|
|
192
|
+
.then(setTenants)
|
|
193
|
+
// A failed load stays null; record calls report their own errors.
|
|
194
|
+
.catch(() => { });
|
|
195
|
+
return () => ctl.abort();
|
|
196
|
+
}, [viper, name, user?.id]);
|
|
197
|
+
return tenants;
|
|
24
198
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Collection, CollectionConfig } from "@tanstack/db";
|
|
2
|
+
type Key = string | number;
|
|
3
|
+
/** Names what a fetch is, like `["projects", id, "keys"]`. */
|
|
4
|
+
export type FetchKey = readonly unknown[];
|
|
5
|
+
/** Where a fetch stands. */
|
|
6
|
+
export type FetchState = {
|
|
7
|
+
/** The row keys, in the server's order. */
|
|
8
|
+
order: Key[];
|
|
9
|
+
/** The last load's error, or null when it worked. */
|
|
10
|
+
error: unknown;
|
|
11
|
+
/** When the last good load finished (ms), or 0 before one. */
|
|
12
|
+
loadedAt: number;
|
|
13
|
+
/** True while a load is under way. */
|
|
14
|
+
fetching: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** What a fetched collection adds under `collection.utils`. */
|
|
17
|
+
export type FetchUtils<D> = {
|
|
18
|
+
/** Where the fetch stands now. */
|
|
19
|
+
state: () => FetchState;
|
|
20
|
+
/** Calls `listener` whenever `state()` changes. Returns the unsubscribe. */
|
|
21
|
+
subscribe: (listener: () => void) => () => void;
|
|
22
|
+
/** Asks the server again. */
|
|
23
|
+
refetch: () => Promise<void>;
|
|
24
|
+
/** Resolves once a load has worked, or rejects if the first one failed. */
|
|
25
|
+
ready: () => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Puts `data` in as if the server had sent it, with no request: the
|
|
28
|
+
* answer of a save, say.
|
|
29
|
+
*/
|
|
30
|
+
set: (data: D) => void;
|
|
31
|
+
/** Counts a reader, so the timer and focus checks run. Returns the undo. */
|
|
32
|
+
watch: () => () => void;
|
|
33
|
+
/**
|
|
34
|
+
* Marks it stale: it loads again now if something reads it, else when
|
|
35
|
+
* something next does.
|
|
36
|
+
*/
|
|
37
|
+
stale: () => Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
/** A fetched collection: one list (or one object) from an endpoint. */
|
|
40
|
+
export type Fetched<D> = Collection<Row<D>, Key, FetchUtils<D>> & {
|
|
41
|
+
/** "list" for an array, "one" for a single object held as one row. */
|
|
42
|
+
kind: "list" | "one";
|
|
43
|
+
key: FetchKey;
|
|
44
|
+
};
|
|
45
|
+
/** The row type of a fetch: an item of a list, or the one object. */
|
|
46
|
+
export type Row<D> = D extends readonly (infer R extends object)[] ? R : D extends object ? D : never;
|
|
47
|
+
/** How to fetch, and how fresh to keep it. */
|
|
48
|
+
export type FetchOptions<D> = {
|
|
49
|
+
key: FetchKey;
|
|
50
|
+
fetch: (signal: AbortSignal) => Promise<D>;
|
|
51
|
+
/** Load again this often while read (ms), or from the data. */
|
|
52
|
+
refetchInterval?: number | false | ((data: D) => number | false);
|
|
53
|
+
/** How long a load counts as fresh (ms). Default 30 seconds. */
|
|
54
|
+
staleTime?: number;
|
|
55
|
+
/** Load a stale one again when the window comes back. Default true. */
|
|
56
|
+
refetchOnFocus?: boolean;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Options for `createCollection()`: what `fetch` gives, as rows. A list is
|
|
60
|
+
* keyed by `getKey`; a single object (leave `getKey` out) is one row.
|
|
61
|
+
*/
|
|
62
|
+
export declare function fetchCollectionOptions<D>(options: FetchOptions<D> & {
|
|
63
|
+
getKey?: (row: Row<D>) => Key;
|
|
64
|
+
}): CollectionConfig<Row<D>, Key, never, FetchUtils<D>> & {
|
|
65
|
+
utils: FetchUtils<D>;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The fetched collection for `options.key` under `owner` (a client, say),
|
|
69
|
+
* made once and shared. For a list pass `getKey`; leave it out for one object.
|
|
70
|
+
*/
|
|
71
|
+
export declare function fetched<D>(owner: object, options: FetchOptions<D> & {
|
|
72
|
+
getKey?: (row: Row<D>) => Key;
|
|
73
|
+
}): Fetched<D>;
|
|
74
|
+
/**
|
|
75
|
+
* Loads again every fetched collection under `owner` whose key starts with
|
|
76
|
+
* `prefix`: the ones on screen now, the rest when next read. Call it after a
|
|
77
|
+
* change, the way you'd invalidate a query.
|
|
78
|
+
*/
|
|
79
|
+
export declare function refetchFetched(owner: object, prefix: FetchKey): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Drops every fetched collection under `owner` whose key starts with
|
|
82
|
+
* `prefix`, so nothing shows its data again (after a project is deleted).
|
|
83
|
+
*/
|
|
84
|
+
export declare function forgetFetched(owner: object, prefix: FetchKey): void;
|
|
85
|
+
/** What `useFetched` gives back. */
|
|
86
|
+
export type FetchedResult<D> = {
|
|
87
|
+
/** The list (or the one object), or undefined until it loads. */
|
|
88
|
+
data: D | undefined;
|
|
89
|
+
/** The last load's error, or null. */
|
|
90
|
+
error: Error | null;
|
|
91
|
+
isError: boolean;
|
|
92
|
+
/** True until the first load works or fails. */
|
|
93
|
+
isPending: boolean;
|
|
94
|
+
isSuccess: boolean;
|
|
95
|
+
/** True while any load is under way, the first or a later one. */
|
|
96
|
+
isFetching: boolean;
|
|
97
|
+
/** When the data last loaded (ms), or 0 before it has. */
|
|
98
|
+
updatedAt: number;
|
|
99
|
+
refetch: () => Promise<void>;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* A fetched collection read as a list (or its one object), in the server's
|
|
103
|
+
* order. Pass null to read nothing yet. With `keepPrevious`, the last data
|
|
104
|
+
* stays on screen while a new collection (the next page, a new filter) loads.
|
|
105
|
+
*
|
|
106
|
+
* ```tsx
|
|
107
|
+
* const keys = useFetched(fetched(client, { key: ["keys"], fetch, getKey }))
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export declare function useFetched<D>(collection: Fetched<D> | null | undefined, options?: {
|
|
111
|
+
keepPrevious?: boolean;
|
|
112
|
+
}): FetchedResult<D>;
|
|
113
|
+
/**
|
|
114
|
+
* Several fetched collections at once (one per project, say), in the same
|
|
115
|
+
* order: `{ data, error }` each, with `data` undefined until it loads.
|
|
116
|
+
*/
|
|
117
|
+
export declare function useFetchedAll<D>(collections: readonly Fetched<D>[]): {
|
|
118
|
+
data: D | undefined;
|
|
119
|
+
error: Error | null;
|
|
120
|
+
}[];
|
|
121
|
+
export {};
|
package/dist/db-fetch.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// EXPERIMENTAL: any fetch as a TanStack DB collection.
|
|
2
|
+
//
|
|
3
|
+
// For data that isn't records: settings, keys, logs, anything an endpoint
|
|
4
|
+
// hands back whole. It loads with `fetch`, loads again when told to (or on a
|
|
5
|
+
// timer, or when the window comes back and it's stale), and keeps the order
|
|
6
|
+
// the server gave. Records have realtime, so use `viperCollectionOptions` or
|
|
7
|
+
// `useLivePage` for them.
|
|
8
|
+
import { ViperError } from "@polyphron/viper-client";
|
|
9
|
+
import { createCollection } from "@tanstack/db";
|
|
10
|
+
import { useLiveQuery } from "@tanstack/react-db";
|
|
11
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
12
|
+
// The key a one-object fetch keeps its row under.
|
|
13
|
+
const ONE = "one";
|
|
14
|
+
// Loads that failed on the network (not "no" or "not found") try again.
|
|
15
|
+
const RETRIES = 2;
|
|
16
|
+
// Every fetched collection that has a reader, for the focus check.
|
|
17
|
+
const watched = new Set();
|
|
18
|
+
if (typeof window !== "undefined") {
|
|
19
|
+
window.addEventListener("focus", () => watched.forEach((entry) => entry.onFocus()));
|
|
20
|
+
}
|
|
21
|
+
// True when two rows hold the same values.
|
|
22
|
+
function same(a, b) {
|
|
23
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
24
|
+
}
|
|
25
|
+
// Waits `ms`, or less if `signal` aborts.
|
|
26
|
+
function wait(ms, signal) {
|
|
27
|
+
return new Promise((done) => {
|
|
28
|
+
const timer = setTimeout(done, ms);
|
|
29
|
+
signal.addEventListener("abort", () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
done();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Options for `createCollection()`: what `fetch` gives, as rows. A list is
|
|
37
|
+
* keyed by `getKey`; a single object (leave `getKey` out) is one row.
|
|
38
|
+
*/
|
|
39
|
+
export function fetchCollectionOptions(options) {
|
|
40
|
+
const { key, fetch, getKey, refetchInterval } = options;
|
|
41
|
+
const staleTime = options.staleTime ?? 30_000;
|
|
42
|
+
const kind = getKey ? "list" : "one";
|
|
43
|
+
const keyOf = getKey ?? (() => ONE);
|
|
44
|
+
const toRows = (data) => kind === "list" ? (data ?? []) : data ? [data] : [];
|
|
45
|
+
let state = {
|
|
46
|
+
order: [],
|
|
47
|
+
error: null,
|
|
48
|
+
loadedAt: 0,
|
|
49
|
+
fetching: false,
|
|
50
|
+
};
|
|
51
|
+
let last;
|
|
52
|
+
let readers = 0;
|
|
53
|
+
// Set by stale(), cleared by the next load.
|
|
54
|
+
let marked = false;
|
|
55
|
+
// Bumped by set(), so a load that started before it can't undo it.
|
|
56
|
+
let generation = 0;
|
|
57
|
+
const listeners = new Set();
|
|
58
|
+
const waiting = new Set();
|
|
59
|
+
const publish = (next) => {
|
|
60
|
+
state = { ...state, ...next };
|
|
61
|
+
listeners.forEach((listener) => listener());
|
|
62
|
+
};
|
|
63
|
+
// Set while the sync runs.
|
|
64
|
+
let live = null;
|
|
65
|
+
const sync = (params) => {
|
|
66
|
+
const { begin, write, commit, markReady } = params;
|
|
67
|
+
const rows = new Map();
|
|
68
|
+
let closed = false;
|
|
69
|
+
let loading = null;
|
|
70
|
+
let again = false;
|
|
71
|
+
let timer;
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
// Writes only what changed, so rows that stayed the same keep their
|
|
74
|
+
// identity and whatever renders them doesn't start over.
|
|
75
|
+
const apply = (data) => {
|
|
76
|
+
const next = toRows(data);
|
|
77
|
+
const keys = next.map(keyOf);
|
|
78
|
+
const kept = new Set(keys);
|
|
79
|
+
last = data;
|
|
80
|
+
marked = false;
|
|
81
|
+
begin();
|
|
82
|
+
for (const [k] of rows) {
|
|
83
|
+
if (!kept.has(k)) {
|
|
84
|
+
write({ type: "delete", key: k });
|
|
85
|
+
rows.delete(k);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
next.forEach((row, i) => {
|
|
89
|
+
const k = keys[i];
|
|
90
|
+
const old = rows.get(k);
|
|
91
|
+
if (old && same(old, row))
|
|
92
|
+
return;
|
|
93
|
+
write({ type: old ? "update" : "insert", value: row });
|
|
94
|
+
rows.set(k, row);
|
|
95
|
+
});
|
|
96
|
+
commit();
|
|
97
|
+
markReady();
|
|
98
|
+
publish({
|
|
99
|
+
order: same(keys, state.order) ? state.order : keys,
|
|
100
|
+
error: null,
|
|
101
|
+
loadedAt: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
waiting.forEach((w) => w.done());
|
|
104
|
+
waiting.clear();
|
|
105
|
+
};
|
|
106
|
+
// The next timed load, while someone reads it.
|
|
107
|
+
const schedule = () => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
const every = typeof refetchInterval === "function"
|
|
110
|
+
? last === undefined
|
|
111
|
+
? false
|
|
112
|
+
: refetchInterval(last)
|
|
113
|
+
: refetchInterval;
|
|
114
|
+
if (every && readers > 0 && !closed) {
|
|
115
|
+
// A hidden tab waits rather than asking; nobody is looking.
|
|
116
|
+
timer = setTimeout(() => typeof document !== "undefined" && document.hidden
|
|
117
|
+
? schedule()
|
|
118
|
+
: void load(), every);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const load = async () => {
|
|
122
|
+
// One load at a time; a call during one loads again after it.
|
|
123
|
+
if (loading) {
|
|
124
|
+
again = true;
|
|
125
|
+
return loading;
|
|
126
|
+
}
|
|
127
|
+
loading = (async () => {
|
|
128
|
+
publish({ fetching: true });
|
|
129
|
+
try {
|
|
130
|
+
do {
|
|
131
|
+
again = false;
|
|
132
|
+
let data;
|
|
133
|
+
const at = generation;
|
|
134
|
+
for (let tries = 0;; tries++) {
|
|
135
|
+
try {
|
|
136
|
+
data = await fetch(controller.signal);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
// The server said no. No answer at all (status 0) and
|
|
141
|
+
// server trouble are worth another go.
|
|
142
|
+
const refused = error instanceof ViperError &&
|
|
143
|
+
error.status > 0 &&
|
|
144
|
+
error.status < 500;
|
|
145
|
+
if (closed || refused || tries >= RETRIES)
|
|
146
|
+
throw error;
|
|
147
|
+
await wait(1000 * 2 ** tries, controller.signal);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (closed)
|
|
151
|
+
return;
|
|
152
|
+
// set() put newer data in while this was on the way.
|
|
153
|
+
if (at === generation)
|
|
154
|
+
apply(data);
|
|
155
|
+
} while (again && !closed);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (closed)
|
|
159
|
+
return;
|
|
160
|
+
// Ready with no rows, so preload() settles; the error is in state.
|
|
161
|
+
markReady();
|
|
162
|
+
publish({ error });
|
|
163
|
+
waiting.forEach((w) => w.fail(error));
|
|
164
|
+
waiting.clear();
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
loading = null;
|
|
168
|
+
publish({ fetching: false });
|
|
169
|
+
schedule();
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
172
|
+
return loading;
|
|
173
|
+
};
|
|
174
|
+
live = { load, apply, schedule };
|
|
175
|
+
void load();
|
|
176
|
+
return () => {
|
|
177
|
+
closed = true;
|
|
178
|
+
live = null;
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
controller.abort();
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
// True when a reader should ask again: an old copy, or a failed first
|
|
184
|
+
// load (it may have been a blip).
|
|
185
|
+
const due = () => !state.fetching &&
|
|
186
|
+
(state.loadedAt
|
|
187
|
+
? marked || Date.now() - state.loadedAt > staleTime
|
|
188
|
+
: !!state.error);
|
|
189
|
+
const entry = {
|
|
190
|
+
onFocus: () => {
|
|
191
|
+
if (options.refetchOnFocus !== false && due())
|
|
192
|
+
void live?.load();
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const utils = {
|
|
196
|
+
state: () => state,
|
|
197
|
+
subscribe: (listener) => {
|
|
198
|
+
listeners.add(listener);
|
|
199
|
+
return () => listeners.delete(listener);
|
|
200
|
+
},
|
|
201
|
+
refetch: async () => live?.load(),
|
|
202
|
+
ready: () => {
|
|
203
|
+
if (state.loadedAt)
|
|
204
|
+
return Promise.resolve();
|
|
205
|
+
// A failed first load is asked again, not handed back as is.
|
|
206
|
+
if (due())
|
|
207
|
+
void live?.load();
|
|
208
|
+
return new Promise((done, fail) => waiting.add({ done, fail }));
|
|
209
|
+
},
|
|
210
|
+
set: (data) => {
|
|
211
|
+
generation++;
|
|
212
|
+
live?.apply(data);
|
|
213
|
+
},
|
|
214
|
+
watch: () => {
|
|
215
|
+
readers++;
|
|
216
|
+
watched.add(entry);
|
|
217
|
+
// A reader that comes back to an old copy asks again.
|
|
218
|
+
if (due()) {
|
|
219
|
+
void live?.load();
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
live?.schedule();
|
|
223
|
+
}
|
|
224
|
+
return () => {
|
|
225
|
+
readers--;
|
|
226
|
+
if (readers === 0)
|
|
227
|
+
watched.delete(entry);
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
stale: async () => {
|
|
231
|
+
marked = true;
|
|
232
|
+
if (readers > 0)
|
|
233
|
+
await live?.load();
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
id: `viper-fetch:${JSON.stringify(key)}`,
|
|
238
|
+
getKey: keyOf,
|
|
239
|
+
// An endpoint sends whole rows, so an update replaces rather than merges.
|
|
240
|
+
sync: { sync, rowUpdateMode: "full" },
|
|
241
|
+
utils,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Fetched collections by owner and key, so every reader of a key shares one
|
|
245
|
+
// collection (and one request). One leaves here when TanStack DB cleans it up,
|
|
246
|
+
// after nothing has read it for a while.
|
|
247
|
+
const made = new WeakMap();
|
|
248
|
+
/**
|
|
249
|
+
* The fetched collection for `options.key` under `owner` (a client, say),
|
|
250
|
+
* made once and shared. For a list pass `getKey`; leave it out for one object.
|
|
251
|
+
*/
|
|
252
|
+
export function fetched(owner, options) {
|
|
253
|
+
let byKey = made.get(owner);
|
|
254
|
+
if (!byKey) {
|
|
255
|
+
byKey = new Map();
|
|
256
|
+
made.set(owner, byKey);
|
|
257
|
+
}
|
|
258
|
+
const hash = JSON.stringify(options.key);
|
|
259
|
+
const found = byKey.get(hash);
|
|
260
|
+
if (found)
|
|
261
|
+
return found;
|
|
262
|
+
const collection = Object.assign(createCollection(fetchCollectionOptions(options)), { kind: options.getKey ? "list" : "one", key: options.key });
|
|
263
|
+
// Asking for the data (or putting some in) starts the sync if nothing
|
|
264
|
+
// has yet, so a route loader can await ready() on its own.
|
|
265
|
+
const { ready, set } = collection.utils;
|
|
266
|
+
collection.utils.ready = () => {
|
|
267
|
+
collection.startSyncImmediate();
|
|
268
|
+
return ready();
|
|
269
|
+
};
|
|
270
|
+
collection.utils.set = (data) => {
|
|
271
|
+
collection.startSyncImmediate();
|
|
272
|
+
set(data);
|
|
273
|
+
};
|
|
274
|
+
collection.on("status:change", ({ status }) => {
|
|
275
|
+
if (status === "cleaned-up")
|
|
276
|
+
byKey.delete(hash);
|
|
277
|
+
});
|
|
278
|
+
byKey.set(hash, collection);
|
|
279
|
+
return collection;
|
|
280
|
+
}
|
|
281
|
+
// True when `key` starts with every part of `prefix`.
|
|
282
|
+
function startsWith(key, prefix) {
|
|
283
|
+
return prefix.every((part, i) => same(part, key[i]));
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Loads again every fetched collection under `owner` whose key starts with
|
|
287
|
+
* `prefix`: the ones on screen now, the rest when next read. Call it after a
|
|
288
|
+
* change, the way you'd invalidate a query.
|
|
289
|
+
*/
|
|
290
|
+
export function refetchFetched(owner, prefix) {
|
|
291
|
+
const loads = [];
|
|
292
|
+
for (const collection of made.get(owner)?.values() ?? []) {
|
|
293
|
+
if (!startsWith(collection.key, prefix))
|
|
294
|
+
continue;
|
|
295
|
+
loads.push(collection.utils.stale());
|
|
296
|
+
}
|
|
297
|
+
return Promise.all(loads).then(() => undefined);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Drops every fetched collection under `owner` whose key starts with
|
|
301
|
+
* `prefix`, so nothing shows its data again (after a project is deleted).
|
|
302
|
+
*/
|
|
303
|
+
export function forgetFetched(owner, prefix) {
|
|
304
|
+
const byKey = made.get(owner);
|
|
305
|
+
for (const [hash, collection] of byKey ?? []) {
|
|
306
|
+
if (!startsWith(collection.key, prefix))
|
|
307
|
+
continue;
|
|
308
|
+
byKey?.delete(hash);
|
|
309
|
+
void collection.cleanup();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const IDLE = {
|
|
313
|
+
order: [],
|
|
314
|
+
error: null,
|
|
315
|
+
loadedAt: 0,
|
|
316
|
+
fetching: false,
|
|
317
|
+
};
|
|
318
|
+
/**
|
|
319
|
+
* A fetched collection read as a list (or its one object), in the server's
|
|
320
|
+
* order. Pass null to read nothing yet. With `keepPrevious`, the last data
|
|
321
|
+
* stays on screen while a new collection (the next page, a new filter) loads.
|
|
322
|
+
*
|
|
323
|
+
* ```tsx
|
|
324
|
+
* const keys = useFetched(fetched(client, { key: ["keys"], fetch, getKey }))
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
export function useFetched(collection, options = {}) {
|
|
328
|
+
const live = useLiveQuery({
|
|
329
|
+
query: (q) => collection
|
|
330
|
+
? q.from({ row: collection })
|
|
331
|
+
: undefined,
|
|
332
|
+
});
|
|
333
|
+
const state = useSyncExternalStore(collection?.utils.subscribe ?? noSubscribe, collection?.utils.state ?? idle, collection?.utils.state ?? idle);
|
|
334
|
+
useEffect(() => collection?.utils.watch(), [collection]);
|
|
335
|
+
// The collection keeps rows by key; the order is the server's.
|
|
336
|
+
const loaded = state.loadedAt > 0;
|
|
337
|
+
const data = useMemo(() => {
|
|
338
|
+
if (!collection || !loaded)
|
|
339
|
+
return undefined;
|
|
340
|
+
return read(collection);
|
|
341
|
+
// live.data changes whenever a row does.
|
|
342
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
343
|
+
}, [collection, live.data, state.order, loaded]);
|
|
344
|
+
// The last data that loaded, shown while the next collection does.
|
|
345
|
+
const [last, setLast] = useState(undefined);
|
|
346
|
+
if (options.keepPrevious && data !== undefined && data !== last)
|
|
347
|
+
setLast(data);
|
|
348
|
+
const error = (state.error ?? null);
|
|
349
|
+
const shown = data ?? (options.keepPrevious && !error ? last : undefined);
|
|
350
|
+
return {
|
|
351
|
+
data: shown,
|
|
352
|
+
error,
|
|
353
|
+
isError: !!error,
|
|
354
|
+
isPending: !!collection && shown === undefined && !error,
|
|
355
|
+
isSuccess: shown !== undefined,
|
|
356
|
+
isFetching: state.fetching,
|
|
357
|
+
updatedAt: state.loadedAt,
|
|
358
|
+
refetch: async () => collection?.utils.refetch(),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Several fetched collections at once (one per project, say), in the same
|
|
363
|
+
* order: `{ data, error }` each, with `data` undefined until it loads.
|
|
364
|
+
*/
|
|
365
|
+
export function useFetchedAll(collections) {
|
|
366
|
+
const [tick, setTick] = useState(0);
|
|
367
|
+
// The list is usually made fresh each render, so compare by keys.
|
|
368
|
+
const id = collections.map((c) => JSON.stringify(c.key)).join(" ");
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
const undo = collections.flatMap((c) => {
|
|
371
|
+
// Re-render only when data or an error lands, not on every fetch flag.
|
|
372
|
+
let seen = c.utils.state();
|
|
373
|
+
// Nothing else reads these, so start each and hold it, or TanStack DB
|
|
374
|
+
// would leave it idle, then clean it up.
|
|
375
|
+
c.startSyncImmediate();
|
|
376
|
+
const held = c.subscribeChanges(() => { });
|
|
377
|
+
return [
|
|
378
|
+
c.utils.watch(),
|
|
379
|
+
() => held.unsubscribe(),
|
|
380
|
+
c.utils.subscribe(() => {
|
|
381
|
+
const now = c.utils.state();
|
|
382
|
+
if (now.loadedAt !== seen.loadedAt || now.error !== seen.error) {
|
|
383
|
+
setTick((n) => n + 1);
|
|
384
|
+
}
|
|
385
|
+
seen = now;
|
|
386
|
+
}),
|
|
387
|
+
];
|
|
388
|
+
});
|
|
389
|
+
return () => undo.forEach((u) => u());
|
|
390
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
391
|
+
}, [id]);
|
|
392
|
+
return useMemo(() => collections.map((c) => {
|
|
393
|
+
const state = c.utils.state();
|
|
394
|
+
const data = state.loadedAt ? read(c) : undefined;
|
|
395
|
+
return { data, error: (state.error ?? null) };
|
|
396
|
+
}),
|
|
397
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
398
|
+
[id, tick]);
|
|
399
|
+
}
|
|
400
|
+
// A row without the `$synced`, `$key`... fields TanStack DB adds, so a
|
|
401
|
+
// form can send it back as is. Cached, so a row keeps its identity.
|
|
402
|
+
const plainRows = new WeakMap();
|
|
403
|
+
const VIRTUAL = ["$synced", "$origin", "$key", "$collectionId"];
|
|
404
|
+
function plain(row) {
|
|
405
|
+
let found = plainRows.get(row);
|
|
406
|
+
if (!found) {
|
|
407
|
+
const copy = {
|
|
408
|
+
...row,
|
|
409
|
+
};
|
|
410
|
+
VIRTUAL.forEach((name) => delete copy[name]);
|
|
411
|
+
found = copy;
|
|
412
|
+
plainRows.set(row, found);
|
|
413
|
+
}
|
|
414
|
+
return found;
|
|
415
|
+
}
|
|
416
|
+
// The rows of `collection` in the server's order, as plain objects.
|
|
417
|
+
function read(collection) {
|
|
418
|
+
const rows = collection.utils.state().order.flatMap((k) => {
|
|
419
|
+
const row = collection.get(k);
|
|
420
|
+
return row ? [plain(row)] : [];
|
|
421
|
+
});
|
|
422
|
+
return (collection.kind === "one" ? rows[0] : rows);
|
|
423
|
+
}
|
|
424
|
+
// For a null collection: nothing to hear, nothing loaded.
|
|
425
|
+
const noSubscribe = () => () => { };
|
|
426
|
+
const idle = () => IDLE;
|
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
|
@@ -5,6 +5,7 @@ import { useViper } from "./provider.js";
|
|
|
5
5
|
export { toViperFilter, toViperSort } from "./db-query.js";
|
|
6
6
|
export { useLivePage, viperPage, viperPageOptions, } from "./db-page.js";
|
|
7
7
|
export { pageComparator, planPage, } from "./page-plan.js";
|
|
8
|
+
export { fetchCollectionOptions, fetched, forgetFetched, refetchFetched, useFetched, useFetchedAll, } from "./db-fetch.js";
|
|
8
9
|
export { useLiveQuery };
|
|
9
10
|
/**
|
|
10
11
|
* Options for `createCollection()`: a TanStack DB collection that holds every
|
|
@@ -318,9 +319,7 @@ export function useLiveRecords(name, options = {}) {
|
|
|
318
319
|
const rows = useViperCollection(name, options);
|
|
319
320
|
const { query } = options;
|
|
320
321
|
const live = useLiveQuery({
|
|
321
|
-
query: (q) => query
|
|
322
|
-
? query(q, rows)
|
|
323
|
-
: q.from({ row: rows }),
|
|
322
|
+
query: (q) => (query ? query(q, rows) : q.from({ row: rows })),
|
|
324
323
|
});
|
|
325
324
|
return {
|
|
326
325
|
data: live.isReady ? live.data : null,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ViperProvider, useViper, type ViperProviderProps } from "./provider.js";
|
|
2
|
-
export { useAuth, useSession, useUser } from "./auth.js";
|
|
2
|
+
export { useAuth, useAuthMethods, useSession, useInvites, useSessions, useTenants, useUser, } from "./auth.js";
|
|
3
3
|
export { useRealtime, useRealtimeStatus } from "./realtime.js";
|
|
4
4
|
export { useRecord, useRecords, type Query, type RecordsOptions, } from "./records.js";
|
|
5
|
-
export {
|
|
5
|
+
export { useRun, type RunState } from "./runs.js";
|
|
6
|
+
export { 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,7 @@
|
|
|
1
1
|
export { ViperProvider, useViper } from "./provider.js";
|
|
2
|
-
export { useAuth, useSession, useUser } from "./auth.js";
|
|
2
|
+
export { useAuth, useAuthMethods, useSession, useInvites, useSessions, useTenants, useUser, } from "./auth.js";
|
|
3
3
|
export { useRealtime, useRealtimeStatus } from "./realtime.js";
|
|
4
4
|
export { useRecord, useRecords, } from "./records.js";
|
|
5
|
+
export { useRun } from "./runs.js";
|
|
5
6
|
// Re-exported so an app only needs @polyphron/viper-react in its imports.
|
|
6
7
|
export { Viper, ViperError, fieldError, memoryStorage, } from "@polyphron/viper-client";
|
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 is done or failed, or can't be read. */
|
|
7
|
+
pending: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Watches a background workflow's run until it's done or failed, 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>;
|
package/dist/runs.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useViper } from "./provider.js";
|
|
3
|
+
/**
|
|
4
|
+
* Watches a background workflow's run until it's done or failed, reading
|
|
5
|
+
* it every `every` milliseconds (a second by default). Pass the
|
|
6
|
+
* `StartedRun` its call returned; nothing is read while it's empty.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [started, setStarted] = useState<StartedRun>()
|
|
10
|
+
* const { data, pending } = useRun<{ text: string }>("forge", started)
|
|
11
|
+
* // on click: setStarted(await viper.workflows.call<StartedRun>("forge", input))
|
|
12
|
+
* // when done: data.response.body.text
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export function useRun(name, started, options = {}) {
|
|
16
|
+
const viper = useViper();
|
|
17
|
+
const every = options.every ?? 1000;
|
|
18
|
+
const run = started?.run;
|
|
19
|
+
const token = started?.token;
|
|
20
|
+
// The answer carries the run it's for, so another run's never shows.
|
|
21
|
+
const [state, setState] = useState({ key: "", data: null, error: null });
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (run === undefined || token === undefined)
|
|
24
|
+
return;
|
|
25
|
+
const key = `${name}/${run}`;
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
let timer;
|
|
28
|
+
const read = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const data = await viper.workflows.run(name, { run, token }, { signal: controller.signal });
|
|
31
|
+
setState({ key, data, error: null });
|
|
32
|
+
if (data.status !== "done" && data.status !== "failed") {
|
|
33
|
+
timer = setTimeout(() => void read(), every);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!controller.signal.aborted)
|
|
38
|
+
setState({ key, data: null, error });
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
void read();
|
|
42
|
+
return () => {
|
|
43
|
+
controller.abort();
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
};
|
|
46
|
+
}, [viper, name, run, token, every]);
|
|
47
|
+
const mine = run !== undefined && state.key === `${name}/${run}`;
|
|
48
|
+
const data = mine ? state.data : null;
|
|
49
|
+
const error = mine ? state.error : null;
|
|
50
|
+
return {
|
|
51
|
+
data,
|
|
52
|
+
error,
|
|
53
|
+
pending: run !== undefined &&
|
|
54
|
+
!error &&
|
|
55
|
+
data?.status !== "done" &&
|
|
56
|
+
data?.status !== "failed",
|
|
57
|
+
};
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polyphron/viper-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React provider and hooks for the Viper app API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@polyphron/viper-client": "^0.
|
|
33
|
+
"@polyphron/viper-client": "^0.3.0",
|
|
34
34
|
"@tanstack/db": "0.9.2",
|
|
35
35
|
"@tanstack/react-db": "0.4.1",
|
|
36
36
|
"react": "^18.3.0 || ^19.0.0"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@tanstack/react-db": "0.4.1",
|
|
43
43
|
"@types/node": "^24",
|
|
44
44
|
"@types/react": "^19",
|
|
45
|
+
"@viper/admin": "*",
|
|
45
46
|
"eslint": "^10",
|
|
46
47
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
47
48
|
"react": "^19.2.8",
|