@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/dist/loaded.js ADDED
@@ -0,0 +1,64 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ /** Makes an `Error` out of whatever a failed load threw. */
3
+ export function asError(cause) {
4
+ return cause instanceof Error ? cause : new Error(String(cause));
5
+ }
6
+ /**
7
+ * The one fetch hook: runs `run` whenever it or `subject` changes, and gives
8
+ * back its state. Answers from a call that has already been replaced are
9
+ * dropped, so a slow first request can't overwrite a fast second one.
10
+ *
11
+ * `subject` names what is being shown (a collection, a user, a session);
12
+ * data from another subject is never handed back, only data from a reload of
13
+ * the same one. Subjects are compared with `Object.is`. `reload` fetches in
14
+ * its own call, not through a render, so awaiting it inside React's `act`
15
+ * (or any batched update) can't deadlock.
16
+ */
17
+ export function useLoaded(subject, run) {
18
+ const [inFlight, setInFlight] = useState(0);
19
+ const [answer, setAnswer] = useState({ subject: null, run: null, data: null, error: null });
20
+ // The newest request's controller and promise: starting one replaces the
21
+ // last, and a replaced request waits for the one that replaced it.
22
+ const controller = useRef(null);
23
+ const newest = useRef(Promise.resolve());
24
+ const fetchNow = useCallback((forSubject, forRun) => {
25
+ controller.current?.abort();
26
+ const mine = new AbortController();
27
+ controller.current = mine;
28
+ const request = (async () => {
29
+ // After a tick, so starting from an effect sets no state in it.
30
+ await Promise.resolve();
31
+ setInFlight((n) => n + 1);
32
+ try {
33
+ const data = await forRun(mine.signal);
34
+ if (!mine.signal.aborted) {
35
+ setAnswer({ subject: forSubject, run: forRun, data, error: null });
36
+ }
37
+ }
38
+ catch (error) {
39
+ if (!mine.signal.aborted) {
40
+ setAnswer({ subject: forSubject, run: forRun, data: null, error });
41
+ }
42
+ }
43
+ finally {
44
+ setInFlight((n) => n - 1);
45
+ }
46
+ })();
47
+ newest.current = request;
48
+ return request.then(() => mine.signal.aborted ? newest.current : undefined);
49
+ }, []);
50
+ useEffect(() => {
51
+ void fetchNow(subject, run);
52
+ return () => controller.current?.abort();
53
+ }, [subject, run, fetchNow]);
54
+ const sameSubject = answer.run !== null && Object.is(answer.subject, subject);
55
+ const current = answer.run === run && inFlight === 0;
56
+ return {
57
+ // The last good data stays on screen while a reload is in flight.
58
+ data: sameSubject ? answer.data : null,
59
+ error: current ? answer.error : null,
60
+ loading: !sameSubject,
61
+ refreshing: !current,
62
+ reload: useCallback(() => fetchNow(subject, run), [fetchNow, subject, run]),
63
+ };
64
+ }
package/dist/page-plan.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { same } from "./db-shared.js";
1
2
  // Sort fields the browser orders just as Postgres does: times compared as
2
3
  // times, and ids (a-z and 0-9) compared as text.
3
4
  const TIMES = new Set(["created", "updated"]);
@@ -50,10 +51,6 @@ export function pageComparator(sort) {
50
51
  return 0;
51
52
  };
52
53
  }
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
54
  /** What `event` does to `page`. */
58
55
  export function planPage(event, page) {
59
56
  // A new row, or a view that changed: only the server knows where it goes.
@@ -1,4 +1,4 @@
1
- import type { RealtimeEvent, RealtimeStatus, RecordModel } from "@polyphron/viper-client";
1
+ import type { PresenceMember, RealtimeEvent, RealtimeStatus, RecordModel } from "@polyphron/viper-client";
2
2
  /**
3
3
  * Watches topics and calls `onEvent` for each change. A topic is a collection
4
4
  * name for every record in it (`posts`), or `collection/id` for one record.
@@ -8,3 +8,14 @@ import type { RealtimeEvent, RealtimeStatus, RecordModel } from "@polyphron/vipe
8
8
  export declare function useRealtime<T extends RecordModel = RecordModel>(topics: string | string[], onEvent: (event: RealtimeEvent<T>) => void): RealtimeStatus;
9
9
  /** The realtime connection status, on its own. */
10
10
  export declare function useRealtimeStatus(): RealtimeStatus;
11
+ /**
12
+ * Joins a room (`posts` or `posts/abc123`) while mounted and returns who is
13
+ * in it. `state` is what others see about you; a new value is sent when its
14
+ * JSON changes. `onBroadcast` hears what the others send with `broadcast`.
15
+ */
16
+ export declare function usePresence<S = unknown, D = unknown>(room: string, state?: S, onBroadcast?: (data: D, from: PresenceMember<S>) => void): {
17
+ members: PresenceMember<S>[];
18
+ me: string;
19
+ error: string;
20
+ broadcast: (data: D) => void;
21
+ };
package/dist/realtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useSyncExternalStore } from "react";
1
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { useViper } from "./provider.js";
3
3
  /**
4
4
  * Watches topics and calls `onEvent` for each change. A topic is a collection
@@ -23,3 +23,52 @@ export function useRealtimeStatus() {
23
23
  const viper = useViper();
24
24
  return useSyncExternalStore((listener) => viper.realtime.onStatus(listener), () => viper.realtime.status, () => "closed");
25
25
  }
26
+ /**
27
+ * Joins a room (`posts` or `posts/abc123`) while mounted and returns who is
28
+ * in it. `state` is what others see about you; a new value is sent when its
29
+ * JSON changes. `onBroadcast` hears what the others send with `broadcast`.
30
+ */
31
+ export function usePresence(room, state, onBroadcast) {
32
+ const viper = useViper();
33
+ const [members, setMembers] = useState([]);
34
+ const [error, setError] = useState("");
35
+ const [joined, setJoined] = useState(null);
36
+ const onBroadcastRef = useRef(onBroadcast);
37
+ useEffect(() => {
38
+ onBroadcastRef.current = onBroadcast;
39
+ });
40
+ // The state as JSON, so a new object with the same values sends nothing.
41
+ const stateKey = JSON.stringify(state ?? null);
42
+ const stateRef = useRef(state);
43
+ useEffect(() => {
44
+ stateRef.current = state;
45
+ });
46
+ useEffect(() => {
47
+ if (!room)
48
+ return;
49
+ const r = viper.realtime.join(room, {
50
+ onPresence: (m) => {
51
+ setMembers(m);
52
+ setError("");
53
+ },
54
+ onBroadcast: (data, from) => onBroadcastRef.current?.(data, from),
55
+ onError: setError,
56
+ }, stateRef.current);
57
+ setJoined(r);
58
+ return () => {
59
+ r.leave();
60
+ setJoined(null);
61
+ setMembers([]);
62
+ };
63
+ }, [viper, room]);
64
+ useEffect(() => {
65
+ if (joined && stateRef.current !== undefined)
66
+ joined.track(stateRef.current);
67
+ }, [joined, stateKey]);
68
+ return {
69
+ members,
70
+ me: joined?.me ?? "",
71
+ error,
72
+ broadcast: (data) => joined?.broadcast(data),
73
+ };
74
+ }
package/dist/records.d.ts CHANGED
@@ -1,18 +1,5 @@
1
- import type { ListOptions, ListResult, RecordModel } from "@polyphron/viper-client";
2
- export type Query<T> = {
3
- data: T | null;
4
- error: unknown;
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
- */
10
- loading: boolean;
11
- /** True whenever a request is in flight, first load and reloads included. */
12
- refreshing: boolean;
13
- /** Fetches again now. */
14
- reload: () => void;
15
- };
1
+ import type { AggregateOptions, AggregateResult, ListOptions, ListResult, RecordModel } from "@polyphron/viper-client";
2
+ import { type Query } from "./loaded.js";
16
3
  export type RecordsOptions = ListOptions & {
17
4
  /** Re-fetch when anything in the collection changes. Off by default. */
18
5
  live?: boolean;
@@ -25,8 +12,22 @@ export type RecordsOptions = ListOptions & {
25
12
  * ```
26
13
  */
27
14
  export declare function useRecords<T extends RecordModel = RecordModel>(collection: string, options?: RecordsOptions): Query<ListResult<T>>;
15
+ export type AggregateHookOptions = AggregateOptions & {
16
+ /** Re-fetch when anything in the collection changes. Off by default. */
17
+ live?: boolean;
18
+ };
19
+ /**
20
+ * Counts and sums of a collection, grouped by fields or time buckets,
21
+ * re-fetched when the options change.
22
+ *
23
+ * ```tsx
24
+ * const { data } = useAggregate("orders", { group: "created:day", agg: "count,sum:amount" })
25
+ * ```
26
+ */
27
+ export declare function useAggregate(collection: string, options?: AggregateHookOptions): Query<AggregateResult>;
28
28
  /** One record, re-fetched when the id changes. */
29
29
  export declare function useRecord<T extends RecordModel = RecordModel>(collection: string, id: string | undefined, options?: {
30
30
  expand?: string;
31
+ fields?: string;
31
32
  live?: boolean;
32
33
  }): Query<T>;
package/dist/records.js CHANGED
@@ -1,4 +1,5 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect } from "react";
2
+ import { useLoaded } from "./loaded.js";
2
3
  import { useViper } from "./provider.js";
3
4
  import { useRealtime } from "./realtime.js";
4
5
  /**
@@ -13,60 +14,43 @@ export function useRecords(collection, options = {}) {
13
14
  const { live = false, ...list } = options;
14
15
  // Options are a fresh object every render; compare them by value.
15
16
  const key = JSON.stringify(list);
16
- const query = useFetch(collection, useCallback((signal) => viper
17
+ const query = useLoaded(collection, useCallback((signal) => viper
17
18
  .collection(collection)
18
19
  .list({ ...JSON.parse(key), signal }), [viper, collection, key]));
19
20
  useLiveReload(live, collection, query.reload, viper);
20
21
  return query;
21
22
  }
23
+ /**
24
+ * Counts and sums of a collection, grouped by fields or time buckets,
25
+ * re-fetched when the options change.
26
+ *
27
+ * ```tsx
28
+ * const { data } = useAggregate("orders", { group: "created:day", agg: "count,sum:amount" })
29
+ * ```
30
+ */
31
+ export function useAggregate(collection, options = {}) {
32
+ const viper = useViper();
33
+ const { live = false, ...aggregate } = options;
34
+ // Options are a fresh object every render; compare them by value.
35
+ const key = JSON.stringify(aggregate);
36
+ const query = useLoaded(`${collection}:aggregate`, useCallback((signal) => viper
37
+ .collection(collection)
38
+ .aggregate({ ...JSON.parse(key), signal }), [viper, collection, key]));
39
+ useLiveReload(live, collection, query.reload, viper);
40
+ return query;
41
+ }
22
42
  /** One record, re-fetched when the id changes. */
23
43
  export function useRecord(collection, id, options = {}) {
24
44
  const viper = useViper();
25
- const { live = false, expand } = options;
26
- const query = useFetch(
45
+ const { live = false, expand, fields } = options;
46
+ const query = useLoaded(
27
47
  // A different record must never show the last one's data.
28
48
  `${collection}/${id ?? ""}`, useCallback((signal) => id
29
- ? viper.collection(collection).view(id, { expand, signal })
30
- : Promise.resolve(null), [viper, collection, id, expand]));
49
+ ? viper.collection(collection).view(id, { expand, fields, signal })
50
+ : Promise.resolve(null), [viper, collection, id, expand, fields]));
31
51
  useLiveReload(live && Boolean(id), `${collection}/${id}`, query.reload, viper);
32
52
  return query;
33
53
  }
34
- // Runs `run` whenever it changes, and gives back its state. Answers from a
35
- // call that has already been replaced are dropped, so a slow first request
36
- // can't overwrite a fast second one.
37
- //
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) {
44
- const [attempt, setAttempt] = useState(0);
45
- const [answer, setAnswer] = useState({ subject: "", run: null, attempt: -1, data: null, error: null });
46
- useEffect(() => {
47
- const controller = new AbortController();
48
- run(controller.signal).then((data) => {
49
- if (!controller.signal.aborted) {
50
- setAnswer({ subject, run, attempt, data, error: null });
51
- }
52
- }, (error) => {
53
- if (!controller.signal.aborted) {
54
- setAnswer({ subject, run, attempt, data: null, error });
55
- }
56
- });
57
- return () => controller.abort();
58
- }, [subject, run, attempt]);
59
- const current = answer.run === run && answer.attempt === attempt;
60
- const sameSubject = answer.run !== null && answer.subject === subject;
61
- return {
62
- // The last good data stays on screen while a reload is in flight.
63
- data: sameSubject ? answer.data : null,
64
- error: current ? answer.error : null,
65
- loading: !sameSubject,
66
- refreshing: !current,
67
- reload: useCallback(() => setAttempt((n) => n + 1), []),
68
- };
69
- }
70
54
  // Reloads on every realtime event for `topic`, when `live` is on, and once
71
55
  // more after the socket comes back from a drop, since events may have been
72
56
  // missed while it was down. The hooks are always called, because hooks can't
package/dist/runs.d.ts CHANGED
@@ -3,11 +3,11 @@ export type RunState<T> = {
3
3
  /** The run as last read, or null before the first answer. */
4
4
  data: WorkflowRun<T> | null;
5
5
  error: unknown;
6
- /** True from the start until the run is done or failed, or can't be read. */
6
+ /** True from the start until the run has ended, or can't be read. */
7
7
  pending: boolean;
8
8
  };
9
9
  /**
10
- * Watches a background workflow's run until it's done or failed, reading
10
+ * Watches a background workflow's run until it's done, failed or cancelled, reading
11
11
  * it every `every` milliseconds (a second by default). Pass the
12
12
  * `StartedRun` its call returned; nothing is read while it's empty.
13
13
  *
package/dist/runs.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { useViper } from "./provider.js";
3
+ /** A run is still going while it runs or waits; done, failed and cancelled are final. */
4
+ function unfinished(run) {
5
+ return run.status === "running" || run.status === "waiting";
6
+ }
3
7
  /**
4
- * Watches a background workflow's run until it's done or failed, reading
8
+ * Watches a background workflow's run until it's done, failed or cancelled, reading
5
9
  * it every `every` milliseconds (a second by default). Pass the
6
10
  * `StartedRun` its call returned; nothing is read while it's empty.
7
11
  *
@@ -29,7 +33,7 @@ export function useRun(name, started, options = {}) {
29
33
  try {
30
34
  const data = await viper.workflows.run(name, { run, token }, { signal: controller.signal });
31
35
  setState({ key, data, error: null });
32
- if (data.status !== "done" && data.status !== "failed") {
36
+ if (unfinished(data)) {
33
37
  timer = setTimeout(() => void read(), every);
34
38
  }
35
39
  }
@@ -50,9 +54,6 @@ export function useRun(name, started, options = {}) {
50
54
  return {
51
55
  data,
52
56
  error,
53
- pending: run !== undefined &&
54
- !error &&
55
- data?.status !== "done" &&
56
- data?.status !== "failed",
57
+ pending: run !== undefined && !error && (data === null || unfinished(data)),
57
58
  };
58
59
  }
@@ -0,0 +1,24 @@
1
+ import type { RecordModel, UploadLargeOptions, ViperError } from "@polyphron/viper-client";
2
+ /** Where a big upload is: what `useUpload` returns next to `upload`. */
3
+ export type UploadState<T extends RecordModel> = {
4
+ /** Sends the file; resolves with the record, rejects on failure. */
5
+ upload: (id: string, field: string, file: Blob, options?: Omit<UploadLargeOptions, "onProgress" | "signal">) => Promise<T>;
6
+ /** Stops the upload in flight. */
7
+ cancel: () => void;
8
+ /** 0 to 1; stays at 1 once done. */
9
+ progress: number;
10
+ uploading: boolean;
11
+ error: ViperError | Error | null;
12
+ /** The record the last finished upload returned. */
13
+ data: T | null;
14
+ };
15
+ /**
16
+ * A big file into a file field, in parts or straight to the bucket (the
17
+ * project's big-uploads switch must be on), with progress.
18
+ *
19
+ * ```tsx
20
+ * const { upload, progress } = useUpload("videos")
21
+ * await upload(record.id, "file", input.files[0])
22
+ * ```
23
+ */
24
+ export declare function useUpload<T extends RecordModel = RecordModel>(collection: string): UploadState<T>;
package/dist/upload.js ADDED
@@ -0,0 +1,44 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { useViper } from "./provider.js";
3
+ /**
4
+ * A big file into a file field, in parts or straight to the bucket (the
5
+ * project's big-uploads switch must be on), with progress.
6
+ *
7
+ * ```tsx
8
+ * const { upload, progress } = useUpload("videos")
9
+ * await upload(record.id, "file", input.files[0])
10
+ * ```
11
+ */
12
+ export function useUpload(collection) {
13
+ const viper = useViper();
14
+ const [progress, setProgress] = useState(0);
15
+ const [uploading, setUploading] = useState(false);
16
+ const [error, setError] = useState(null);
17
+ const [data, setData] = useState(null);
18
+ const abort = useRef(null);
19
+ const upload = useCallback(async (id, field, file, options = {}) => {
20
+ abort.current = new AbortController();
21
+ setUploading(true);
22
+ setProgress(0);
23
+ setError(null);
24
+ try {
25
+ const record = await viper.collection(collection).uploadLarge(id, field, file, {
26
+ ...options,
27
+ signal: abort.current.signal,
28
+ onProgress: (sent, total) => setProgress(total ? sent / total : 1),
29
+ });
30
+ setProgress(1);
31
+ setData(record);
32
+ return record;
33
+ }
34
+ catch (e) {
35
+ setError(e);
36
+ throw e;
37
+ }
38
+ finally {
39
+ setUploading(false);
40
+ }
41
+ }, [viper, collection]);
42
+ const cancel = useCallback(() => abort.current?.abort(), []);
43
+ return { upload, cancel, progress, uploading, error, data };
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polyphron/viper-react",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.3.0",
33
+ "@polyphron/viper-client": "^0.5.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"