@polyphron/viper-react 0.3.0 → 0.5.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 CHANGED
@@ -71,6 +71,17 @@ function SignIn() {
71
71
 
72
72
  ## The hooks
73
73
 
74
+ With personal tokens or device sign-in enabled, `useAuth().auth` exposes
75
+ `createToken`, `tokens`, `revokeToken`, `startDeviceSignIn`, `authorizeDevice`
76
+ and `pollDeviceSignIn`. Polling an approved device saves its normal session,
77
+ so `useUser()` updates. Only display the user code and verification URI;
78
+ keep the device code private and honor each polling result's interval.
79
+
80
+ With email change confirmation enabled, `useAuth()` exposes
81
+ `auth.requestEmailChange(newEmail)` and `auth.confirmEmailChange(token)`.
82
+ Both need the current user's session. After confirming, call `auth.me()`
83
+ to refresh the email shown by `useUser()`.
84
+
74
85
  | Hook | What it gives you |
75
86
  | --- | --- |
76
87
  | `useViper()` | The `Viper` client — writes go through it: `viper.collection("posts").create(…)` |
@@ -78,6 +89,12 @@ function SignIn() {
78
89
  | `useRecord(collection, id, options)` | One record, same shape |
79
90
  | `useAuth(collection)` | `{ user, auth }` for an auth collection |
80
91
  | `useSession()` / `useUser()` | The session / the logged-in user, re-rendering on change |
92
+ | `useSessionOf(client, read)` | The same for any client with `onSessionChange` (the admin client, say); `read` returns what to show, like `() => admin.user` |
93
+ | `useAuthMethods({ collection?, enabled? })` | How to sign in here (methods, sign-up, project branding), or `null` while it loads |
94
+ | `useSessions()` | The user's active sessions: `{ sessions, error, loading, refresh, revoke, logoutEverywhere }` |
95
+ | `useInvites()` | Invites the user sent: `{ invites, error, loading, refresh, invite, cancel }` |
96
+ | `useTenants(name)` | The user's memberships of tenant collection `name`; never the last user's list |
97
+ | `useRun(name, started, { every? })` | Follows a background workflow's run until it ends: `{ data, error, pending }` |
81
98
  | `useRealtime(topics, onEvent)` | Watch changes yourself; returns the connection status |
82
99
  | `useRealtimeStatus()` | `"closed"`, `"connecting"` or `"live"` |
83
100
 
@@ -95,6 +112,14 @@ flight, so lists don't flash.
95
112
  - With `live: true`, the list is also re-fetched once after the socket comes
96
113
  back from a drop, since events may have been missed while it was down.
97
114
 
115
+ `useSessions` and `useInvites` both give `loading` (true
116
+ until the first answer for this user), `error` (an `Error` or `null`) and
117
+ `refresh()`, which resolves once the new answer is in. They key their state on
118
+ the signed-in user, so after a user switch you never see the last user's data.
119
+ `useLoaded(subject, load)` is the helper they and `useRecords` are built on;
120
+ use it to write your own fetching hook. It returns the same `Query` shape as
121
+ `useRecords`.
122
+
98
123
  `@polyphron/viper-client` is a peer dependency: install it next to this
99
124
  package, so the app and the hooks share one client.
100
125
 
package/dist/auth.d.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import type { AuthMethods, AuthService, Invite, InviteInput, RecordModel, Session, UserSession } from "@polyphron/viper-client";
2
+ /**
3
+ * The session of any client that announces changes (`Viper`, `ViperAdmin`),
4
+ * re-rendering when it changes. `read` returns what to show from the client,
5
+ * like `() => admin.user`. It must be stable between changes.
6
+ */
7
+ export declare function useSessionOf<S>(client: {
8
+ onSessionChange(listener: () => void): () => void;
9
+ }, read: () => S | null): S | null;
2
10
  /** The current session, re-rendering on login, logout and refresh. */
3
11
  export declare function useSession(): Session | null;
4
12
  /** The signed-in user's active sessions, with revoke and log-out actions. */
5
13
  export declare function useSessions(): {
6
14
  sessions: UserSession[] | null;
7
15
  error: Error | null;
16
+ loading: boolean;
8
17
  refresh: () => Promise<void>;
9
18
  revoke: (sessionId: string) => Promise<void>;
10
19
  logoutEverywhere: () => Promise<void>;
@@ -13,6 +22,7 @@ export declare function useSessions(): {
13
22
  export declare function useInvites(): {
14
23
  invites: Invite[] | null;
15
24
  error: Error | null;
25
+ loading: boolean;
16
26
  refresh: () => Promise<void>;
17
27
  invite: (input: InviteInput) => Promise<Invite>;
18
28
  cancel: (id: string) => Promise<void>;
@@ -27,6 +37,12 @@ export declare function useUser<T extends RecordModel = RecordModel>(): T | null
27
37
  * ```tsx
28
38
  * const { user, auth } = useAuth()
29
39
  * await auth.loginWithPassword(email, password)
40
+ * await auth.requestEmailChange(newEmail)
41
+ * await auth.confirmEmailChange(token)
42
+ * await auth.me() // refresh the user after confirmation
43
+ * const proof = await auth.beginOAuth2("google", location.href)
44
+ * // Keep proof.codeVerifier across navigation to proof.url.
45
+ * const links = await auth.oauth2Links()
30
46
  * ```
31
47
  */
32
48
  export declare function useAuth<T extends RecordModel = RecordModel>(collection?: string): {
@@ -34,21 +50,26 @@ export declare function useAuth<T extends RecordModel = RecordModel>(collection?
34
50
  auth: AuthService<T>;
35
51
  };
36
52
  /**
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.
53
+ * How to sign in here: the ways on offer, sign-up, and the project's
54
+ * branding. Null while
55
+ * it loads, and when the load fails: the sign-in calls report their own
56
+ * errors. Name an auth `collection` when the project has several, and pass
57
+ * `enabled: false` to hold the request back. For a sign-in page.
40
58
  *
41
59
  * ```tsx
42
60
  * const methods = useAuthMethods()
43
- * return <h1>{methods?.app?.branding.name ?? methods?.app?.name}</h1>
61
+ * return <h1>{methods?.branding?.name}</h1>
44
62
  * ```
45
63
  */
46
- export declare function useAuthMethods(): AuthMethods | null;
64
+ export declare function useAuthMethods(options?: {
65
+ collection?: string;
66
+ enabled?: boolean;
67
+ }): AuthMethods | null;
47
68
  /**
48
69
  * The signed-in user's memberships of tenant collection `name` (like
49
70
  * "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.
71
+ * while signed out, null while it loads (and when the load fails). It
72
+ * loads again when the user changes, and never shows the last user's list.
52
73
  *
53
74
  * ```tsx
54
75
  * const orgs = useTenants("orgs")
package/dist/auth.js CHANGED
@@ -1,127 +1,55 @@
1
- import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { asError, useLoaded } from "./loaded.js";
2
3
  import { useViper } from "./provider.js";
4
+ /**
5
+ * The session of any client that announces changes (`Viper`, `ViperAdmin`),
6
+ * re-rendering when it changes. `read` returns what to show from the client,
7
+ * like `() => admin.user`. It must be stable between changes.
8
+ */
9
+ export function useSessionOf(client, read) {
10
+ return useSyncExternalStore((listener) => client.onSessionChange(listener), read, () => null);
11
+ }
3
12
  /** The current session, re-rendering on login, logout and refresh. */
4
13
  export function useSession() {
5
14
  const viper = useViper();
6
- return useSyncExternalStore((listener) => viper.onSessionChange(listener), () => viper.session, () => null);
15
+ return useSessionOf(viper, () => viper.session);
7
16
  }
8
17
  /** The signed-in user's active sessions, with revoke and log-out actions. */
9
18
  export function useSessions() {
10
19
  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;
20
+ const userId = useUser()?.id ?? null;
21
+ const { data, error, loading, reload } = useLoaded(userId, useCallback((signal) => userId ? viper.auth.sessions({ signal }) : Promise.resolve(null), [viper, userId]));
57
22
  return {
58
- sessions: current?.sessions ?? null,
59
- error: current?.error ?? null,
60
- refresh,
61
- revoke,
62
- logoutEverywhere,
23
+ sessions: data,
24
+ error: error ? asError(error) : null,
25
+ loading,
26
+ refresh: reload,
27
+ revoke: async (sessionId) => {
28
+ await viper.auth.revokeSession(sessionId);
29
+ await reload();
30
+ },
31
+ logoutEverywhere: () => viper.auth.logoutEverywhere(),
63
32
  };
64
33
  }
65
34
  /** The invites the signed-in user sent, with send and take-back actions. */
66
35
  export function useInvites() {
67
36
  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;
37
+ const userId = useUser()?.id ?? null;
38
+ const { data, error, loading, reload } = useLoaded(userId, useCallback((signal) => userId ? viper.auth.invites({ signal }) : Promise.resolve(null), [viper, userId]));
119
39
  return {
120
- invites: current?.invites ?? null,
121
- error: current?.error ?? null,
122
- refresh,
123
- invite,
124
- cancel,
40
+ invites: data,
41
+ error: error ? asError(error) : null,
42
+ loading,
43
+ refresh: reload,
44
+ invite: async (input) => {
45
+ const made = await viper.auth.invite(input);
46
+ await reload();
47
+ return made;
48
+ },
49
+ cancel: async (id) => {
50
+ await viper.auth.cancelInvite(id);
51
+ await reload();
52
+ },
125
53
  };
126
54
  }
127
55
  /** The logged-in user, or null when signed out. */
@@ -136,6 +64,12 @@ export function useUser() {
136
64
  * ```tsx
137
65
  * const { user, auth } = useAuth()
138
66
  * await auth.loginWithPassword(email, password)
67
+ * await auth.requestEmailChange(newEmail)
68
+ * await auth.confirmEmailChange(token)
69
+ * await auth.me() // refresh the user after confirmation
70
+ * const proof = await auth.beginOAuth2("google", location.href)
71
+ * // Keep proof.codeVerifier across navigation to proof.url.
72
+ * const links = await auth.oauth2Links()
139
73
  * ```
140
74
  */
141
75
  export function useAuth(collection) {
@@ -147,34 +81,32 @@ export function useAuth(collection) {
147
81
  return { user, auth };
148
82
  }
149
83
  /**
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.
84
+ * How to sign in here: the ways on offer, sign-up, and the project's
85
+ * branding. Null while
86
+ * it loads, and when the load fails: the sign-in calls report their own
87
+ * errors. Name an auth `collection` when the project has several, and pass
88
+ * `enabled: false` to hold the request back. For a sign-in page.
153
89
  *
154
90
  * ```tsx
155
91
  * const methods = useAuthMethods()
156
- * return <h1>{methods?.app?.branding.name ?? methods?.app?.name}</h1>
92
+ * return <h1>{methods?.branding?.name}</h1>
157
93
  * ```
158
94
  */
159
- export function useAuthMethods() {
95
+ export function useAuthMethods(options = {}) {
160
96
  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;
97
+ const { collection, enabled = true } = options;
98
+ const { data } = useLoaded(`${collection ?? ""}/${enabled}`, useCallback((signal) => enabled
99
+ ? (collection
100
+ ? viper.collection(collection).auth
101
+ : viper.auth).methods({ signal })
102
+ : Promise.resolve(null), [viper, collection, enabled]));
103
+ return data;
172
104
  }
173
105
  /**
174
106
  * The signed-in user's memberships of tenant collection `name` (like
175
107
  * "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.
108
+ * while signed out, null while it loads (and when the load fails). It
109
+ * loads again when the user changes, and never shows the last user's list.
178
110
  *
179
111
  * ```tsx
180
112
  * const orgs = useTenants("orgs")
@@ -183,16 +115,7 @@ export function useAuthMethods() {
183
115
  */
184
116
  export function useTenants(name) {
185
117
  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;
118
+ const userId = useUser()?.id ?? "";
119
+ const { data } = useLoaded(`${name}/${userId}`, useCallback((signal) => viper.tenants(name, { signal }), [viper, name]));
120
+ return data;
198
121
  }
package/dist/db-fetch.js CHANGED
@@ -9,6 +9,7 @@ import { ViperError } from "@polyphron/viper-client";
9
9
  import { createCollection } from "@tanstack/db";
10
10
  import { useLiveQuery } from "@tanstack/react-db";
11
11
  import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
12
+ import { cacheOf, same, singleFlight } from "./db-shared.js";
12
13
  // The key a one-object fetch keeps its row under.
13
14
  const ONE = "one";
14
15
  // Loads that failed on the network (not "no" or "not found") try again.
@@ -18,10 +19,6 @@ const watched = new Set();
18
19
  if (typeof window !== "undefined") {
19
20
  window.addEventListener("focus", () => watched.forEach((entry) => entry.onFocus()));
20
21
  }
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
22
  // Waits `ms`, or less if `signal` aborts.
26
23
  function wait(ms, signal) {
27
24
  return new Promise((done) => {
@@ -66,8 +63,6 @@ export function fetchCollectionOptions(options) {
66
63
  const { begin, write, commit, markReady } = params;
67
64
  const rows = new Map();
68
65
  let closed = false;
69
- let loading = null;
70
- let again = false;
71
66
  let timer;
72
67
  const controller = new AbortController();
73
68
  // Writes only what changed, so rows that stayed the same keep their
@@ -118,59 +113,48 @@ export function fetchCollectionOptions(options) {
118
113
  : void load(), every);
119
114
  }
120
115
  };
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();
116
+ const load = singleFlight({
117
+ start: () => publish({ fetching: true }),
118
+ pass: async () => {
119
+ let data;
120
+ const at = generation;
121
+ for (let tries = 0;; tries++) {
122
+ try {
123
+ data = await fetch(controller.signal);
124
+ break;
125
+ }
126
+ catch (error) {
127
+ // The server said no. No answer at all (status 0) and
128
+ // server trouble are worth another go.
129
+ const refused = error instanceof ViperError &&
130
+ error.status > 0 &&
131
+ error.status < 500;
132
+ if (closed || refused || tries >= RETRIES)
133
+ throw error;
134
+ await wait(1000 * 2 ** tries, controller.signal);
135
+ }
170
136
  }
171
- })();
172
- return loading;
173
- };
137
+ if (closed)
138
+ return;
139
+ // set() put newer data in while this was on the way.
140
+ if (at === generation)
141
+ apply(data);
142
+ },
143
+ closed: () => closed,
144
+ fail: (error) => {
145
+ if (closed)
146
+ return;
147
+ // Ready with no rows, so preload() settles; the error is in state.
148
+ markReady();
149
+ publish({ error });
150
+ waiting.forEach((w) => w.fail(error));
151
+ waiting.clear();
152
+ },
153
+ settle: () => {
154
+ publish({ fetching: false });
155
+ schedule();
156
+ },
157
+ });
174
158
  live = { load, apply, schedule };
175
159
  void load();
176
160
  return () => {
@@ -242,41 +226,29 @@ export function fetchCollectionOptions(options) {
242
226
  };
243
227
  }
244
228
  // 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();
229
+ // collection (and one request). One leaves the cache when TanStack DB cleans
230
+ // it up, after nothing has read it for a while.
231
+ const made = cacheOf();
248
232
  /**
249
233
  * The fetched collection for `options.key` under `owner` (a client, say),
250
234
  * made once and shared. For a list pass `getKey`; leave it out for one object.
251
235
  */
252
236
  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);
237
+ return made(owner, JSON.stringify(options.key), () => {
238
+ const collection = Object.assign(createCollection(fetchCollectionOptions(options)), { kind: options.getKey ? "list" : "one", key: options.key });
239
+ // Asking for the data (or putting some in) starts the sync if nothing
240
+ // has yet, so a route loader can await ready() on its own.
241
+ const { ready, set } = collection.utils;
242
+ collection.utils.ready = () => {
243
+ collection.startSyncImmediate();
244
+ return ready();
245
+ };
246
+ collection.utils.set = (data) => {
247
+ collection.startSyncImmediate();
248
+ set(data);
249
+ };
250
+ return collection;
277
251
  });
278
- byKey.set(hash, collection);
279
- return collection;
280
252
  }
281
253
  // True when `key` starts with every part of `prefix`.
282
254
  function startsWith(key, prefix) {
@@ -289,7 +261,7 @@ function startsWith(key, prefix) {
289
261
  */
290
262
  export function refetchFetched(owner, prefix) {
291
263
  const loads = [];
292
- for (const collection of made.get(owner)?.values() ?? []) {
264
+ for (const collection of made.of(owner)?.values() ?? []) {
293
265
  if (!startsWith(collection.key, prefix))
294
266
  continue;
295
267
  loads.push(collection.utils.stale());
@@ -301,7 +273,7 @@ export function refetchFetched(owner, prefix) {
301
273
  * `prefix`, so nothing shows its data again (after a project is deleted).
302
274
  */
303
275
  export function forgetFetched(owner, prefix) {
304
- const byKey = made.get(owner);
276
+ const byKey = made.of(owner);
305
277
  for (const [hash, collection] of byKey ?? []) {
306
278
  if (!startsWith(collection.key, prefix))
307
279
  continue;