@polyphron/viper-react 0.1.2 → 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 +225 -1
- 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-page.d.ts +74 -0
- package/dist/db-page.js +233 -0
- package/dist/db-query.d.ts +44 -0
- package/dist/db-query.js +172 -0
- package/dist/db.d.ts +70 -0
- package/dist/db.js +329 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/page-plan.d.ts +29 -0
- package/dist/page-plan.js +102 -0
- package/dist/records.d.ts +7 -1
- package/dist/records.js +26 -18
- package/dist/runs.d.ts +23 -0
- package/dist/runs.js +58 -0
- package/package.json +20 -4
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ function SignIn() {
|
|
|
74
74
|
| Hook | What it gives you |
|
|
75
75
|
| --- | --- |
|
|
76
76
|
| `useViper()` | The `Viper` client — writes go through it: `viper.collection("posts").create(…)` |
|
|
77
|
-
| `useRecords(collection, options)` | One page of records: `{ data, loading, error, reload }` |
|
|
77
|
+
| `useRecords(collection, options)` | One page of records: `{ data, loading, refreshing, error, reload }` |
|
|
78
78
|
| `useRecord(collection, id, options)` | One record, same shape |
|
|
79
79
|
| `useAuth(collection)` | `{ user, auth }` for an auth collection |
|
|
80
80
|
| `useSession()` / `useUser()` | The session / the logged-in user, re-rendering on change |
|
|
@@ -85,6 +85,19 @@ function SignIn() {
|
|
|
85
85
|
what they are showing. The last good data stays on screen while a reload is in
|
|
86
86
|
flight, so lists don't flash.
|
|
87
87
|
|
|
88
|
+
- `loading` is true only until the first answer for what is shown. A reload
|
|
89
|
+
or a live update does not set it again. Changing the collection (or the
|
|
90
|
+
record id, for `useRecord`) clears `data` and sets it again, so a form never
|
|
91
|
+
sees the previous record.
|
|
92
|
+
- `refreshing` is true whenever a request is in flight, first load included.
|
|
93
|
+
Use it for a small spinner. (Before, `loading` was also true during
|
|
94
|
+
reloads; read `refreshing` if you relied on that.)
|
|
95
|
+
- With `live: true`, the list is also re-fetched once after the socket comes
|
|
96
|
+
back from a drop, since events may have been missed while it was down.
|
|
97
|
+
|
|
98
|
+
`@polyphron/viper-client` is a peer dependency: install it next to this
|
|
99
|
+
package, so the app and the hooks share one client.
|
|
100
|
+
|
|
88
101
|
## Writes
|
|
89
102
|
|
|
90
103
|
There is no mutation hook: the client's own methods are already promises.
|
|
@@ -96,3 +109,214 @@ const { reload } = useRecords("posts")
|
|
|
96
109
|
await viper.collection("posts").create({ title: "Hello" })
|
|
97
110
|
reload() // or pass live: true and let realtime do it
|
|
98
111
|
```
|
|
112
|
+
|
|
113
|
+
## Viper collections (experimental)
|
|
114
|
+
|
|
115
|
+
`@polyphron/viper-react/db` puts a Viper collection into
|
|
116
|
+
[TanStack DB](https://tanstack.com/db), so you can run live queries over
|
|
117
|
+
records kept in the browser. It is opt-in: the client and the hooks above
|
|
118
|
+
don't need it, and the API may change. Install the peers, pinned to the
|
|
119
|
+
versions it is tested with:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
npm install @tanstack/db@0.9.2 @tanstack/react-db@0.4.1
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { createCollection, eq } from "@tanstack/db"
|
|
127
|
+
import { useLiveQuery, viperCollectionOptions } from "@polyphron/viper-react/db"
|
|
128
|
+
|
|
129
|
+
const posts = createCollection(
|
|
130
|
+
viperCollectionOptions<Post>({ viper, collection: "posts" })
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
function Live() {
|
|
134
|
+
const { data } = useLiveQuery((q) =>
|
|
135
|
+
q.from({ p: posts }).where(({ p }) => eq(p.status, "live"))
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return <ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Shows at once, is saved in the background, and is rolled back if the
|
|
142
|
+
// server refuses it. Give it your own 15-character id.
|
|
143
|
+
posts.insert({ id, title: "Hi" })
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- It loads every record the list rule lets the user see, then applies
|
|
147
|
+
realtime events straight to the copy: no request per change. After a
|
|
148
|
+
dropped connection, or a `refresh` from a view collection, it loads again.
|
|
149
|
+
- Writes call the records API. Several in one transaction go to
|
|
150
|
+
`viper.batch()` and succeed or fail together. The server's answer (`id`,
|
|
151
|
+
`created`, `updated`, stamped defaults) replaces the optimistic row.
|
|
152
|
+
- A realtime event older than the row's `updated` is ignored, so an echo of
|
|
153
|
+
your own write can't undo a newer one.
|
|
154
|
+
- Eager (the default) suits small collections. For big ones pass
|
|
155
|
+
`syncMode: "on-demand"`: nothing loads until a live query asks, and then
|
|
156
|
+
only its `where`, `orderBy` and `limit` are sent, as a Viper `filter`,
|
|
157
|
+
`sort` and page. What Viper can't say (`not`, nested paths, `null`) is
|
|
158
|
+
left out, so a little more loads and the query filters it again; `like`
|
|
159
|
+
and `ilike` become `~` (a case-insensitive contains). A `limit` is only
|
|
160
|
+
sent when the whole `where` and `orderBy` could be. Realtime events are
|
|
161
|
+
applied for every row the user may see, and a reload fetches the queries
|
|
162
|
+
again. Paging is by page number with `skipTotal`, so the server never
|
|
163
|
+
counts; a keyset cursor cannot be used, because TanStack DB asks for an
|
|
164
|
+
offset and Viper's cursor is a token only the previous page hands out.
|
|
165
|
+
- Rules stay on the server; the copy holds only what the list rule and
|
|
166
|
+
realtime allow.
|
|
167
|
+
|
|
168
|
+
### Hooks
|
|
169
|
+
|
|
170
|
+
`useViperCollection<Row>(name, { syncMode? })` gives the collection for the
|
|
171
|
+
client in `<ViperProvider>`, made once and shared by every component that asks
|
|
172
|
+
for the same name and mode. `useLiveRecords<Row>(name, { syncMode?, query? })`
|
|
173
|
+
reads it as `{ data, error, loading }` (`data` is null until loaded), with
|
|
174
|
+
`query` for a filter, sort or join. Both are opt-in; `useRecords` and
|
|
175
|
+
`useRecord` work as before.
|
|
176
|
+
|
|
177
|
+
### A live server page
|
|
178
|
+
|
|
179
|
+
For a table that pages on the server (a text filter, totals, `expand`, the
|
|
180
|
+
Deleted view), `useLivePage<Row>(name, query)` holds one page of
|
|
181
|
+
`records.list` in a TanStack DB collection and keeps it fresh. `query` takes
|
|
182
|
+
what `records.list` takes: `page`, `perPage`, `sort`, `filter`, `expand`,
|
|
183
|
+
`deleted`.
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
const { items, totalItems, totalPages, loading, collection } =
|
|
187
|
+
useLivePage<Post>("posts", { page, perPage: 30, sort: "-updated", filter })
|
|
188
|
+
|
|
189
|
+
collection.delete(ids) // hidden at once, one batch, put back if refused
|
|
190
|
+
collection.utils.refetch() // ask for the page again
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- A change that can't move rows between pages (an edit that keeps the sort
|
|
194
|
+
values, on a page with no filter) patches its row; no request. Anything
|
|
195
|
+
else (a create, a delete, a filter the browser can't run) asks for the
|
|
196
|
+
page again, once per burst. `expand` is kept while the links don't change.
|
|
197
|
+
- While a new `query` loads, the last page stays on screen.
|
|
198
|
+
- `viperPage(viper, name, query).preload()` starts the fetch early, from a
|
|
199
|
+
route loader say; the hook then reads the same collection.
|
|
200
|
+
- `collection.utils.apply(event)` shows a change you made yourself (a save)
|
|
201
|
+
without waiting for its realtime event.
|
|
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
|
+
|
|
247
|
+
### Recipe: typed collections, a join and an optimistic insert
|
|
248
|
+
|
|
249
|
+
1. Write the types once with `viper typegen --out src/viper-types.ts` (see
|
|
250
|
+
the root README). Each collection gets a row type and a `Collections` map.
|
|
251
|
+
2. Get each collection with `useViperCollection<Collections["posts"]>("posts")`.
|
|
252
|
+
3. Read with `useLiveQuery`, joining collections in the browser.
|
|
253
|
+
4. Write with `collection.insert`. The row shows at once, and is rolled back
|
|
254
|
+
if the server refuses it, so put the form text back in that case.
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
import { eq } from "@tanstack/db"
|
|
258
|
+
import { useLiveQuery, useViperCollection } from "@polyphron/viper-react/db"
|
|
259
|
+
import { useState, type FormEvent } from "react"
|
|
260
|
+
|
|
261
|
+
import type { Collections, PostCreate } from "./viper-types"
|
|
262
|
+
|
|
263
|
+
// Viper ids are 15 characters. The row needs one before the server answers.
|
|
264
|
+
const newId = () =>
|
|
265
|
+
Array.from(
|
|
266
|
+
{ length: 15 },
|
|
267
|
+
() => "abcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(Math.random() * 36)]
|
|
268
|
+
).join("")
|
|
269
|
+
|
|
270
|
+
export function LivePosts({ authorId }: { authorId: string }) {
|
|
271
|
+
const posts = useViperCollection<Collections["posts"]>("posts")
|
|
272
|
+
const authors = useViperCollection<Collections["authors"]>("authors")
|
|
273
|
+
const [title, setTitle] = useState("")
|
|
274
|
+
|
|
275
|
+
// A join between two collections; only changed rows re-render.
|
|
276
|
+
const { data = [], isLoading } = useLiveQuery((q) =>
|
|
277
|
+
q
|
|
278
|
+
.from({ p: posts })
|
|
279
|
+
.innerJoin({ a: authors }, ({ p, a }) => eq(p.author, a.id))
|
|
280
|
+
.where(({ p }) => eq(p.status, "live"))
|
|
281
|
+
.orderBy(({ p }) => p.created, "desc")
|
|
282
|
+
.select(({ p, a }) => ({ id: p.id, title: p.title, by: a.name }))
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const add = async (event: FormEvent) => {
|
|
286
|
+
event.preventDefault()
|
|
287
|
+
|
|
288
|
+
const draft: PostCreate & { id: string } = {
|
|
289
|
+
id: newId(),
|
|
290
|
+
title,
|
|
291
|
+
status: "live",
|
|
292
|
+
author: authorId,
|
|
293
|
+
}
|
|
294
|
+
// The cast is because insert wants a whole row; the server fills in
|
|
295
|
+
// `created`, `updated` and defaults.
|
|
296
|
+
const tx = posts.insert(draft as Collections["posts"])
|
|
297
|
+
|
|
298
|
+
setTitle("")
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await tx.isPersisted.promise
|
|
302
|
+
} catch {
|
|
303
|
+
setTitle(draft.title)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (isLoading) return <p>Loading</p>
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<form onSubmit={add}>
|
|
311
|
+
<ul>
|
|
312
|
+
{data.map((row) => (
|
|
313
|
+
<li key={row.id}>
|
|
314
|
+
{row.title} by {row.by}
|
|
315
|
+
</li>
|
|
316
|
+
))}
|
|
317
|
+
</ul>
|
|
318
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
319
|
+
</form>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
```
|
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 {};
|