@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 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 logins of one auth collection, plus the user they belong to.
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("users")
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: string): {
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 logins of one auth collection, plus the user they belong to.
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("users")
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
- return { user, auth: viper.collection(collection).auth };
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 {};
@@ -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 { Viper, ViperError, fieldError, memoryStorage, type ListOptions, type ListResult, type RealtimeEvent, type RealtimeStatus, type RecordInput, type RecordModel, type Session, type ViperOptions, } from "@polyphron/viper-client";
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.2.1",
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.2.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",