@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/dist/db.js ADDED
@@ -0,0 +1,329 @@
1
+ import { createCollection } from "@tanstack/db";
2
+ import { useLiveQuery } from "@tanstack/react-db";
3
+ import { toViperFilter, toViperSort, toWindow } from "./db-query.js";
4
+ import { useViper } from "./provider.js";
5
+ export { toViperFilter, toViperSort } from "./db-query.js";
6
+ export { useLivePage, viperPage, viperPageOptions, } from "./db-page.js";
7
+ export { pageComparator, planPage, } from "./page-plan.js";
8
+ export { fetchCollectionOptions, fetched, forgetFetched, refetchFetched, useFetched, useFetchedAll, } from "./db-fetch.js";
9
+ export { useLiveQuery };
10
+ /**
11
+ * Options for `createCollection()`: a TanStack DB collection that holds every
12
+ * record the signed-in user's list rule allows (or, in `on-demand` mode, the
13
+ * ones live queries ask for), and keeps it fresh from
14
+ * realtime events without asking the server again. Inserts, updates and
15
+ * deletes show at once and are rolled back if the server says no.
16
+ *
17
+ * Insert with your own `id` (15 characters, like Viper's), since the row
18
+ * needs a key before the server has answered.
19
+ */
20
+ export function viperCollectionOptions(options) {
21
+ const { viper, collection } = options;
22
+ const onDemand = options.syncMode === "on-demand";
23
+ const records = viper.collection(collection).records;
24
+ // Set while the sync is running, so a mutation can write the server's
25
+ // answer into the collection.
26
+ let sink = null;
27
+ const sync = (params) => {
28
+ const { begin, write, commit, truncate, markReady, markError } = params;
29
+ // The `updated` we hold for each synced row: what makes an old realtime
30
+ // event (our own write coming back, say) unable to undo a newer one.
31
+ const versions = new Map();
32
+ let closed = false;
33
+ let loading = false;
34
+ let again = false;
35
+ // Events that arrive while a load is in flight, replayed after it.
36
+ let buffered = [];
37
+ // On-demand: subset loads in flight (they buffer events too), and the
38
+ // subsets live queries hold now, so a reload can fetch them again.
39
+ let pending = 0;
40
+ const subsets = new Map();
41
+ const stamp = (record) => Date.parse(record.updated) || 0;
42
+ const put = (record) => {
43
+ const seen = versions.get(record.id);
44
+ if (seen !== undefined && stamp(record) < seen)
45
+ return;
46
+ write({ type: seen === undefined ? "insert" : "update", value: record });
47
+ versions.set(record.id, stamp(record));
48
+ };
49
+ const remove = (id) => {
50
+ const seen = versions.get(id);
51
+ if (seen === undefined)
52
+ return;
53
+ write({ type: "delete", key: id });
54
+ versions.delete(id);
55
+ };
56
+ const apply = (event) => {
57
+ if (event.action === "delete")
58
+ remove(event.record.id);
59
+ else if (event.action !== "refresh")
60
+ put(event.record);
61
+ };
62
+ // A subset is held once per load; it is let go when each is unloaded.
63
+ const release = (subset) => {
64
+ const count = subsets.get(subset) ?? 0;
65
+ if (count > 1)
66
+ subsets.set(subset, count - 1);
67
+ else
68
+ subsets.delete(subset);
69
+ };
70
+ // Every row the held subsets want, or the whole collection when eager.
71
+ const fetchAll = async () => {
72
+ if (!onDemand)
73
+ return records.listAll();
74
+ const found = await Promise.all([...subsets.keys()].map(fetchSubset));
75
+ const byId = new Map();
76
+ for (const item of found.flat())
77
+ byId.set(item.id, item);
78
+ return [...byId.values()];
79
+ };
80
+ // One subset: the rows its `where` names, sorted, and its window when it
81
+ // has a limit. A `where` we could only half say loads wider and never
82
+ // takes a window, since the window would be cut from the wrong rows.
83
+ const fetchSubset = async (subset) => {
84
+ const { filter, exact } = toViperFilter(subset.where);
85
+ const sort = toViperSort(subset.orderBy);
86
+ const window = exact && sort && subset.limit !== undefined
87
+ ? toWindow(subset.offset ?? 0, subset.limit)
88
+ : null;
89
+ // Core always sends `offset` along with `cursor`, so the cursor
90
+ // expressions are left alone and the offset does the paging. Only the
91
+ // rows are wanted, so `skipTotal` spares the server its COUNT (see
92
+ // toWindow for why this is not a keyset cursor).
93
+ if (!window) {
94
+ return records.listAll({
95
+ filter: filter || undefined,
96
+ sort: sort || undefined,
97
+ });
98
+ }
99
+ const result = await records.list({
100
+ filter: filter || undefined,
101
+ sort: sort || undefined,
102
+ skipTotal: true,
103
+ ...window,
104
+ });
105
+ return result.items;
106
+ };
107
+ const load = async () => {
108
+ // One load at a time; a request during a load runs another after it.
109
+ if (loading) {
110
+ again = true;
111
+ return;
112
+ }
113
+ loading = true;
114
+ try {
115
+ do {
116
+ again = false;
117
+ const items = await fetchAll();
118
+ if (closed)
119
+ return;
120
+ // Truncate and rewrite in one transaction, so a reload never
121
+ // shows an empty list in between.
122
+ begin();
123
+ truncate();
124
+ versions.clear();
125
+ items.forEach(put);
126
+ const missed = buffered;
127
+ if (pending === 0)
128
+ buffered = [];
129
+ missed.forEach(apply);
130
+ commit();
131
+ markReady();
132
+ } while (again);
133
+ }
134
+ catch (error) {
135
+ markError(error);
136
+ }
137
+ finally {
138
+ loading = false;
139
+ }
140
+ };
141
+ sink = {
142
+ put,
143
+ remove,
144
+ write: (fn) => {
145
+ begin();
146
+ fn();
147
+ commit();
148
+ },
149
+ };
150
+ // Subscribe before loading, so nothing that happens in between is lost.
151
+ const off = viper.realtime.subscribe(collection, (event) => {
152
+ if (event.action === "refresh") {
153
+ void load();
154
+ }
155
+ else if (loading || pending > 0) {
156
+ buffered.push(event);
157
+ }
158
+ else {
159
+ begin();
160
+ apply(event);
161
+ commit();
162
+ }
163
+ });
164
+ const offResync = viper.realtime.onResync(() => void load());
165
+ const cleanup = () => {
166
+ closed = true;
167
+ sink = null;
168
+ off();
169
+ offResync();
170
+ };
171
+ if (!onDemand) {
172
+ void load();
173
+ return cleanup;
174
+ }
175
+ // Nothing to load until a query asks. Every realtime event is applied,
176
+ // not only those matching a held subset: the list rule already decided
177
+ // this user may see the row, and a live query drops what does not match
178
+ // it. Skipping non-matching events would need the where evaluated here,
179
+ // and would miss a new row that a query should show.
180
+ markReady();
181
+ return {
182
+ cleanup,
183
+ loadSubset: async (subset) => {
184
+ pending++;
185
+ subsets.set(subset, (subsets.get(subset) ?? 0) + 1);
186
+ try {
187
+ const items = await fetchSubset(subset);
188
+ if (closed)
189
+ return;
190
+ begin();
191
+ items.forEach(put);
192
+ buffered.forEach(apply);
193
+ await commit();
194
+ }
195
+ catch (error) {
196
+ release(subset);
197
+ throw error;
198
+ }
199
+ finally {
200
+ pending--;
201
+ if (pending === 0 && !loading)
202
+ buffered = [];
203
+ }
204
+ },
205
+ unloadSubset: release,
206
+ };
207
+ };
208
+ // Puts what the server answered into the collection, in place of our guess.
209
+ // A write the user may make on a row they may not see afterwards (the view
210
+ // rule now hides it) answers with only ids, so that row is dropped rather
211
+ // than kept as the partial guess.
212
+ const store = (saved) => {
213
+ sink?.write(() => {
214
+ for (const record of saved) {
215
+ if (!record)
216
+ continue;
217
+ if (hiddenAnswer(record))
218
+ sink?.remove(record.id);
219
+ else
220
+ sink?.put(record);
221
+ }
222
+ });
223
+ };
224
+ return {
225
+ getKey: (record) => record.id,
226
+ // Records are whole rows, so an update replaces rather than merges.
227
+ sync: { sync, rowUpdateMode: "full" },
228
+ syncMode: onDemand ? "on-demand" : "eager",
229
+ onInsert: async ({ transaction }) => {
230
+ const saved = await save(viper, records, collection, transaction.mutations);
231
+ store(saved);
232
+ },
233
+ onUpdate: async ({ transaction }) => {
234
+ const saved = await save(viper, records, collection, transaction.mutations);
235
+ store(saved);
236
+ },
237
+ onDelete: async ({ transaction }) => {
238
+ await save(viper, records, collection, transaction.mutations);
239
+ sink?.write(() => {
240
+ for (const m of transaction.mutations)
241
+ sink?.remove(m.key);
242
+ });
243
+ },
244
+ };
245
+ }
246
+ // True when a write's answer holds only the ids, which is what the server
247
+ // sends when the view rule no longer lets the caller see the saved row.
248
+ function hiddenAnswer(record) {
249
+ return Object.keys(record).every((key) => key === "id" || key === "collectionId" || key === "collectionName");
250
+ }
251
+ // Sends the mutations: one call for one, a single all-or-nothing batch for
252
+ // several. Resolves to the saved records, in order (null for a delete).
253
+ async function save(viper, records, collection, mutations) {
254
+ if (mutations.length > 1) {
255
+ return viper.batch(mutations.map((m) => toOp(collection, m)));
256
+ }
257
+ const m = mutations[0];
258
+ if (m.type === "insert")
259
+ return [await records.create(m.modified)];
260
+ if (m.type === "update")
261
+ return [await records.update(m.key, m.changes)];
262
+ await records.delete(m.key);
263
+ return [null];
264
+ }
265
+ // One mutation as a batch write.
266
+ function toOp(collection, m) {
267
+ if (m.type === "insert") {
268
+ return { method: "create", collection, body: m.modified };
269
+ }
270
+ if (m.type === "update") {
271
+ return { method: "update", collection, id: m.key, body: m.changes };
272
+ }
273
+ return { method: "delete", collection, id: m.key };
274
+ }
275
+ // Collections made by `useViperCollection`, one per client, name and mode, so
276
+ // two components asking for `posts` share one copy of it (and one fetch).
277
+ const made = new WeakMap();
278
+ /**
279
+ * The Viper collection `name` for the client in `<ViperProvider>`, made once
280
+ * and shared: every component asking for the same name and mode gets the same
281
+ * collection, so its records are fetched once. Use it to insert, update and
282
+ * delete, and as a `from` in live queries. Nothing about `useRecords` changes.
283
+ *
284
+ * ```tsx
285
+ * const posts = useViperCollection<Post>("posts")
286
+ * posts.insert({ id: newId(), title: "Hi" })
287
+ * ```
288
+ */
289
+ export function useViperCollection(name, options = {}) {
290
+ const viper = useViper();
291
+ const mode = options.syncMode ?? "eager";
292
+ let byName = made.get(viper);
293
+ if (!byName) {
294
+ byName = new Map();
295
+ made.set(viper, byName);
296
+ }
297
+ const key = `${mode}:${name}`;
298
+ let found = byName.get(key);
299
+ if (!found) {
300
+ found = createCollection(viperCollectionOptions({ viper, collection: name, syncMode: mode }));
301
+ byName.set(key, found);
302
+ }
303
+ return found;
304
+ }
305
+ /**
306
+ * The rows of a Viper collection as a live list, like `useRecords` but read
307
+ * from the shared collection, so it updates row by row with no refetch. Give
308
+ * `query` to filter, sort or join; without it you get every row. In
309
+ * `on-demand` mode `query` is what decides what is loaded.
310
+ *
311
+ * ```tsx
312
+ * const { data, loading } = useLiveRecords<Post>("posts", {
313
+ * query: (q, posts) =>
314
+ * q.from({ p: posts }).where(({ p }) => eq(p.status, "live")),
315
+ * })
316
+ * ```
317
+ */
318
+ export function useLiveRecords(name, options = {}) {
319
+ const rows = useViperCollection(name, options);
320
+ const { query } = options;
321
+ const live = useLiveQuery({
322
+ query: (q) => (query ? query(q, rows) : q.from({ row: rows })),
323
+ });
324
+ return {
325
+ data: live.isReady ? live.data : null,
326
+ error: live.isError ? new Error(`${name} could not be loaded`) : null,
327
+ loading: live.isLoading || live.isIdle,
328
+ };
329
+ }
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";
@@ -0,0 +1,29 @@
1
+ import type { RealtimeEvent, RecordModel } from "@polyphron/viper-client";
2
+ /** One page as the server sent it, and how it was asked for. */
3
+ export type PageState<T extends RecordModel = RecordModel> = {
4
+ /** The rows, in the server's order. */
5
+ rows: T[];
6
+ page: number;
7
+ /** -1 when unknown. */
8
+ totalPages: number;
9
+ /** The sort the page was asked for, like `-updated,title`. */
10
+ sort?: string;
11
+ filter?: string;
12
+ deleted?: boolean;
13
+ /** The `expand` it was asked for, like `author,tags.owner`. */
14
+ expand?: string;
15
+ };
16
+ /** What to do: write `patch` in, take `drop` out, and maybe ask again. */
17
+ export type PagePlan<T extends RecordModel = RecordModel> = {
18
+ patch?: T;
19
+ drop?: string;
20
+ refetch: boolean;
21
+ };
22
+ /**
23
+ * Orders rows as the server does for `sort`, or null when the sort has a
24
+ * field the browser can't order the same way (text in the database's
25
+ * collation, numbers, lists).
26
+ */
27
+ export declare function pageComparator<T extends RecordModel>(sort: string | undefined): ((a: T, b: T) => number) | null;
28
+ /** What `event` does to `page`. */
29
+ export declare function planPage<T extends RecordModel>(event: RealtimeEvent<T>, page: PageState<T>): PagePlan<T>;
@@ -0,0 +1,102 @@
1
+ // Sort fields the browser orders just as Postgres does: times compared as
2
+ // times, and ids (a-z and 0-9) compared as text.
3
+ const TIMES = new Set(["created", "updated"]);
4
+ /**
5
+ * The sort as the server applies it, with its tie-breaker. The server
6
+ * breaks ties on `id` ascending, or sorts `-created,-id` when none is given.
7
+ */
8
+ function sortKeys(sort) {
9
+ const given = (sort ?? "")
10
+ .split(",")
11
+ .map((s) => s.trim())
12
+ .filter(Boolean);
13
+ if (!given.length) {
14
+ return [
15
+ { field: "created", desc: true },
16
+ { field: "id", desc: true },
17
+ ];
18
+ }
19
+ return [
20
+ ...given.map((s) => ({
21
+ field: s.replace(/^[-+]/, ""),
22
+ desc: s.startsWith("-"),
23
+ })),
24
+ { field: "id", desc: false },
25
+ ];
26
+ }
27
+ /**
28
+ * Orders rows as the server does for `sort`, or null when the sort has a
29
+ * field the browser can't order the same way (text in the database's
30
+ * collation, numbers, lists).
31
+ */
32
+ export function pageComparator(sort) {
33
+ const keys = sortKeys(sort);
34
+ if (!keys.every((k) => k.field === "id" || TIMES.has(k.field)))
35
+ return null;
36
+ return (a, b) => {
37
+ for (const { field, desc } of keys) {
38
+ const x = String(a[field]);
39
+ const y = String(b[field]);
40
+ const order = field === "id"
41
+ ? x < y
42
+ ? -1
43
+ : x > y
44
+ ? 1
45
+ : 0
46
+ : Date.parse(x) - Date.parse(y);
47
+ if (order)
48
+ return desc ? -order : order;
49
+ }
50
+ return 0;
51
+ };
52
+ }
53
+ // True when two field values are the same, lists and objects included.
54
+ function same(a, b) {
55
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
56
+ }
57
+ /** What `event` does to `page`. */
58
+ export function planPage(event, page) {
59
+ // A new row, or a view that changed: only the server knows where it goes.
60
+ if (event.action === "create" || event.action === "refresh") {
61
+ return { refetch: true };
62
+ }
63
+ const { record } = event;
64
+ const old = page.rows.find((r) => r.id === record.id);
65
+ // The rows after it move up, and the count changes.
66
+ if (event.action === "delete") {
67
+ return { drop: old ? record.id : undefined, refetch: true };
68
+ }
69
+ // A row that isn't on the page can only move onto it if its sort values
70
+ // changed. Its old values aren't known, so only a sort on fields an
71
+ // update never changes (created, id) is safe to skip.
72
+ if (!old) {
73
+ const fixed = sortKeys(page.sort).every((k) => k.field === "created" || k.field === "id");
74
+ return { refetch: !fixed || !!page.filter || !!page.deleted };
75
+ }
76
+ // A change carries no `expand`, so the old one stays while the links do.
77
+ const relations = (page.expand ?? "")
78
+ .split(",")
79
+ .map((path) => path.trim().split(".")[0])
80
+ .filter(Boolean);
81
+ const relinked = relations.some((f) => !same(old[f], record[f]));
82
+ const patch = relinked ? record : { ...record, expand: old.expand };
83
+ // A filter or the Deleted view may now leave it out, and the filter can't
84
+ // be run here.
85
+ if (page.filter || page.deleted || relinked)
86
+ return { patch, refetch: true };
87
+ const moved = sortKeys(page.sort).some((k) => !same(old[k.field], record[k.field]));
88
+ if (!moved)
89
+ return { patch, refetch: false };
90
+ // It moved. Order the page here, and ask again only if it may now belong
91
+ // on the page before or after this one.
92
+ const compare = pageComparator(page.sort);
93
+ if (!compare)
94
+ return { patch, refetch: true };
95
+ const rows = page.rows
96
+ .map((r) => (r.id === record.id ? patch : r))
97
+ .sort(compare);
98
+ const at = rows.findIndex((r) => r.id === record.id);
99
+ const more = page.totalPages < 0 || page.page < page.totalPages;
100
+ const leaves = (at === 0 && page.page > 1) || (at === rows.length - 1 && more);
101
+ return { patch, refetch: leaves };
102
+ }
package/dist/records.d.ts CHANGED
@@ -2,8 +2,14 @@ import type { ListOptions, ListResult, RecordModel } from "@polyphron/viper-clie
2
2
  export type Query<T> = {
3
3
  data: T | null;
4
4
  error: unknown;
5
- /** True until the first answer arrives, and again during a `reload`. */
5
+ /**
6
+ * True until the first answer arrives for what is being shown (a new
7
+ * collection, or a new record id). A `reload` or a live update does not set
8
+ * it again, so the last data stays on screen; see `refreshing`.
9
+ */
6
10
  loading: boolean;
11
+ /** True whenever a request is in flight, first load and reloads included. */
12
+ refreshing: boolean;
7
13
  /** Fetches again now. */
8
14
  reload: () => void;
9
15
  };
package/dist/records.js CHANGED
@@ -13,57 +13,65 @@ export function useRecords(collection, options = {}) {
13
13
  const { live = false, ...list } = options;
14
14
  // Options are a fresh object every render; compare them by value.
15
15
  const key = JSON.stringify(list);
16
- const query = useFetch(useCallback((signal) => viper
16
+ const query = useFetch(collection, useCallback((signal) => viper
17
17
  .collection(collection)
18
18
  .list({ ...JSON.parse(key), signal }), [viper, collection, key]));
19
- useLiveReload(live, collection, query.reload);
19
+ useLiveReload(live, collection, query.reload, viper);
20
20
  return query;
21
21
  }
22
22
  /** One record, re-fetched when the id changes. */
23
23
  export function useRecord(collection, id, options = {}) {
24
24
  const viper = useViper();
25
25
  const { live = false, expand } = options;
26
- const query = useFetch(useCallback((signal) => id
26
+ const query = useFetch(
27
+ // A different record must never show the last one's data.
28
+ `${collection}/${id ?? ""}`, useCallback((signal) => id
27
29
  ? viper.collection(collection).view(id, { expand, signal })
28
30
  : Promise.resolve(null), [viper, collection, id, expand]));
29
- useLiveReload(live && Boolean(id), `${collection}/${id}`, query.reload);
31
+ useLiveReload(live && Boolean(id), `${collection}/${id}`, query.reload, viper);
30
32
  return query;
31
33
  }
32
34
  // Runs `run` whenever it changes, and gives back its state. Answers from a
33
35
  // call that has already been replaced are dropped, so a slow first request
34
36
  // can't overwrite a fast second one.
35
37
  //
36
- // `loading` is worked out rather than stored: the answer carries the request
37
- // it belongs to, so anything older than the current request means "still
38
- // waiting" — and no state has to be set while the effect is starting.
39
- function useFetch(run) {
38
+ // `refreshing` and `loading` are worked out rather than stored: the answer
39
+ // carries the request it belongs to, so anything older than the current
40
+ // request means "still waiting" — and no state has to be set while the effect
41
+ // is starting. `subject` names what is being shown; data from another subject
42
+ // is never handed back, only data from a reload of the same one.
43
+ function useFetch(subject, run) {
40
44
  const [attempt, setAttempt] = useState(0);
41
- const [answer, setAnswer] = useState({ run: null, attempt: -1, data: null, error: null });
45
+ const [answer, setAnswer] = useState({ subject: "", run: null, attempt: -1, data: null, error: null });
42
46
  useEffect(() => {
43
47
  const controller = new AbortController();
44
48
  run(controller.signal).then((data) => {
45
49
  if (!controller.signal.aborted) {
46
- setAnswer({ run, attempt, data, error: null });
50
+ setAnswer({ subject, run, attempt, data, error: null });
47
51
  }
48
52
  }, (error) => {
49
53
  if (!controller.signal.aborted) {
50
- setAnswer({ run, attempt, data: null, error });
54
+ setAnswer({ subject, run, attempt, data: null, error });
51
55
  }
52
56
  });
53
57
  return () => controller.abort();
54
- }, [run, attempt]);
58
+ }, [subject, run, attempt]);
55
59
  const current = answer.run === run && answer.attempt === attempt;
60
+ const sameSubject = answer.run !== null && answer.subject === subject;
56
61
  return {
57
62
  // The last good data stays on screen while a reload is in flight.
58
- data: answer.data,
63
+ data: sameSubject ? answer.data : null,
59
64
  error: current ? answer.error : null,
60
- loading: !current,
65
+ loading: !sameSubject,
66
+ refreshing: !current,
61
67
  reload: useCallback(() => setAttempt((n) => n + 1), []),
62
68
  };
63
69
  }
64
- // Reloads on every realtime event for `topic`, when `live` is on. The hook
65
- // below is always called, because hooks can't be conditional; an empty topic
66
- // means no subscription.
67
- function useLiveReload(live, topic, reload) {
70
+ // Reloads on every realtime event for `topic`, when `live` is on, and once
71
+ // more after the socket comes back from a drop, since events may have been
72
+ // missed while it was down. The hooks are always called, because hooks can't
73
+ // be conditional; an empty topic means no subscription.
74
+ function useLiveReload(live, topic, reload, viper) {
68
75
  useRealtime(live ? topic : [], reload);
76
+ useEffect(() => (live ? viper.realtime.onResync(reload) : undefined), [live, viper, reload]);
69
77
  }
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
+ }