@lotics/app-sdk 0.18.0 → 0.20.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.
@@ -1,18 +1,42 @@
1
1
  import type { AppWorkflows, AppQueries } from "./types.js";
2
+ import type { ResolvedMember } from "./members.js";
2
3
  interface QueryState<R> {
3
4
  rows: R[];
4
5
  loading: boolean;
5
6
  error: string | null;
6
7
  /**
7
- * Re-run the underlying query against the current AST. Use after a known
8
- * mutation point — a successful `useWorkflow(alias)()` call — to pull the
9
- * latest state.
10
- *
11
- * The iframe SDK does not subscribe to realtime invalidation channels in
12
- * v1; refetch is the explicit refresh path. Future SDK versions may add a
13
- * `subscribe` RPC op for push-based invalidation.
8
+ * Re-run the query from the first page. Use after a known mutation point —
9
+ * a successful `useWorkflow(alias)()` call — to pull the latest state.
14
10
  */
15
11
  refetch: () => void;
12
+ /**
13
+ * Fetch the next page and append it to `rows`. No-op when `pageSize` was not
14
+ * set or there are no more rows (`hasMore` is false). `loadingMore` is true
15
+ * while it runs.
16
+ */
17
+ loadMore: () => void;
18
+ /** True when the last page came back full, so more rows may exist. */
19
+ hasMore: boolean;
20
+ /** True while a `loadMore` request is in flight. */
21
+ loadingMore: boolean;
22
+ }
23
+ /** Options for `useQuery`. */
24
+ export interface QueryOptions {
25
+ /**
26
+ * Page size. Omit to fetch all rows in one request (the server still caps
27
+ * the maximum). Set it to paginate: the first render loads one page, and
28
+ * `loadMore()` appends the next.
29
+ */
30
+ pageSize?: number;
31
+ /**
32
+ * When `false`, the query does not run: `rows` stays empty, `loading` is
33
+ * false, and no request is sent. Flip it back to `true` to fetch. This is the
34
+ * primitive for search-as-you-type (skip until the user types) and for detail
35
+ * queries (skip until a row is selected) — a parameterized search filter
36
+ * matches everything on an empty term, so an always-on query would dump the
37
+ * whole table on first paint. Default `true`.
38
+ */
39
+ enabled?: boolean;
16
40
  }
17
41
  /**
18
42
  * Trigger a workflow by alias from the app's manifest.
@@ -48,7 +72,7 @@ export interface WorkflowResult {
48
72
  message?: string;
49
73
  files?: UploadedFile[];
50
74
  }
51
- type UseQueryParams<K extends keyof AppQueries & string> = AppQueries[K] extends Record<string, never> ? [params?: Record<string, never>] : [params: AppQueries[K]];
75
+ type UseQueryArgs<K extends keyof AppQueries & string> = AppQueries[K] extends Record<string, never> ? [params?: Record<string, never>, opts?: QueryOptions] : [params: AppQueries[K], opts?: QueryOptions];
52
76
  /**
53
77
  * Read rows from a query the app's author declared in `lotics.queries`.
54
78
  *
@@ -65,8 +89,8 @@ type UseQueryParams<K extends keyof AppQueries & string> = AppQueries[K] extends
65
89
  * with the declared alias → param-type map, so an undeclared alias is a
66
90
  * compile-time error and params are typed per the manifest.
67
91
  */
68
- export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...rest: UseQueryParams<K>): QueryState<Record<string, unknown>>;
69
- export declare function useQuery(alias: string, params?: Record<string, unknown>): QueryState<Record<string, unknown>>;
92
+ export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: UseQueryArgs<K>): QueryState<Record<string, unknown>>;
93
+ export declare function useQuery(alias: string, params?: Record<string, unknown>, opts?: QueryOptions): QueryState<Record<string, unknown>>;
70
94
  /** A file the host has stored and resolved serving URLs for. */
71
95
  export interface UploadedFile {
72
96
  id: string;
@@ -100,4 +124,27 @@ interface FileUploadState {
100
124
  * ```
101
125
  */
102
126
  export declare function useFileUpload(): FileUploadState;
127
+ interface MembersState {
128
+ /** Members of the app's organization, for assign / member-picker UIs. */
129
+ members: ResolvedMember[];
130
+ loading: boolean;
131
+ error: string | null;
132
+ }
133
+ /**
134
+ * List the members of the app's organization — the candidate set for an
135
+ * "assign to a member" picker. Resolves through the host (member-only; an
136
+ * anonymous public visitor gets an error). Names may be empty for members
137
+ * without a display name set — fall back to `email`.
138
+ *
139
+ * Gated: the app must DECLARE that it works with members — it needs a workflow
140
+ * whose manifest declares a `member`-typed input (i.e. it assigns members).
141
+ * Without one the host returns an error, so member access stays a declared,
142
+ * auditable surface rather than something any app can read ambiently.
143
+ *
144
+ * ```tsx
145
+ * const { members } = useMembers();
146
+ * // <Picker options={members.map((m) => ({ value: m.id, label: m.name || m.email || m.id }))} />
147
+ * ```
148
+ */
149
+ export declare function useMembers(): MembersState;
103
150
  export {};
package/dist/src/hooks.js CHANGED
@@ -17,6 +17,9 @@ import { useCallback, useEffect, useState } from "react";
17
17
  import { rpc } from "./rpc.js";
18
18
  import { getMockRows } from "./mock.js";
19
19
  import { captureAppEvent } from "./analytics.js";
20
+ // A tab return can fire both `focus` and `visibilitychange` back-to-back;
21
+ // coalesce them into a single revalidation.
22
+ const FOCUS_REVALIDATE_THROTTLE_MS = 2000;
20
23
  export function useWorkflow(alias) {
21
24
  return useCallback(async (inputs) => {
22
25
  try {
@@ -30,55 +33,123 @@ export function useWorkflow(alias) {
30
33
  }
31
34
  }, [alias]);
32
35
  }
33
- export function useQuery(alias, params) {
36
+ export function useQuery(alias, params, opts) {
37
+ const pageSize = opts?.pageSize;
38
+ const enabled = opts?.enabled ?? true;
34
39
  // Stringified params key — structurally-equal-but-new param objects don't
35
40
  // re-fire the effect every render.
36
41
  const paramsKey = JSON.stringify(params ?? {});
37
- // Bumping this token re-fires the effect without changing alias/params. The
38
- // refetch callback mutates only this counter; state transitions still
39
- // happen inside the effect so loading / error / rows flow consistently.
42
+ // Bumping this token re-fires the effect (first page) without changing
43
+ // alias/params. State transitions happen inside the effect so loading /
44
+ // error / rows flow consistently.
40
45
  const [refetchToken, setRefetchToken] = useState(0);
41
46
  // Initialize from the fixture when mock mode is on and the app registered
42
47
  // rows for this alias — avoids a flash of `loading: true` on first paint.
43
- // Computed lazily so subsequent renders don't re-read `window.location`.
44
48
  const [state, setState] = useState(() => {
45
49
  const mockRows = getMockRows(alias);
46
50
  if (mockRows)
47
- return { rows: mockRows, loading: false, error: null };
48
- return { rows: [], loading: true, error: null };
51
+ return { rows: mockRows, loading: false, error: null, hasMore: false };
52
+ // A disabled query starts idle (no loading flash), not pending.
53
+ return { rows: [], loading: enabled, error: null, hasMore: false };
49
54
  });
55
+ const [loadingMore, setLoadingMore] = useState(false);
50
56
  useEffect(() => {
51
- // Re-check on every effect run so HMR updates to the fixture (mount-time
52
- // re-registration) propagate without a hard reload. When the fixture has
53
- // an entry we short-circuit the RPC — partial mocking still works because
54
- // aliases without fixture entries fall through to the live path below.
57
+ // Re-check on every effect run so HMR updates to the fixture propagate
58
+ // without a hard reload. Fixture short-circuits the RPC.
55
59
  const mockRows = getMockRows(alias);
56
60
  if (mockRows) {
57
- setState({ rows: mockRows, loading: false, error: null });
61
+ setState({ rows: mockRows, loading: false, error: null, hasMore: false });
62
+ return;
63
+ }
64
+ if (!enabled) {
65
+ setState({ rows: [], loading: false, error: null, hasMore: false });
58
66
  return;
59
67
  }
60
68
  let cancelled = false;
61
- setState((s) => ({ rows: s.rows, loading: true, error: null }));
62
- rpc("query", { alias, params: params ?? {} })
69
+ setState((s) => ({ ...s, loading: true, error: null }));
70
+ rpc("query", {
71
+ alias,
72
+ params: params ?? {},
73
+ limit: pageSize,
74
+ offset: 0,
75
+ })
63
76
  .then((result) => {
64
77
  if (cancelled)
65
78
  return;
66
- setState({ rows: result.rows ?? [], loading: false, error: null });
79
+ const rows = result.rows ?? [];
80
+ setState({
81
+ rows,
82
+ loading: false,
83
+ error: null,
84
+ hasMore: pageSize != null && rows.length === pageSize,
85
+ });
67
86
  })
68
87
  .catch((err) => {
69
88
  if (cancelled)
70
89
  return;
71
- setState({ rows: [], loading: false, error: err.message });
90
+ setState({ rows: [], loading: false, error: err.message, hasMore: false });
72
91
  });
73
92
  return () => {
74
93
  cancelled = true;
75
94
  };
76
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
77
- }, [alias, paramsKey, refetchToken]);
96
+ }, [alias, paramsKey, refetchToken, pageSize, enabled]);
78
97
  const refetch = useCallback(() => {
79
98
  setRefetchToken((n) => n + 1);
80
99
  }, []);
81
- return { ...state, refetch };
100
+ // Revalidate on focus / reconnect. An app can't hold a realtime socket, so
101
+ // freshness comes from re-running the query (first page) when the user
102
+ // returns to it or the network comes back — plus the explicit `refetch()`
103
+ // an app calls after a `useWorkflow()` mutation. Throttled so a tab return
104
+ // that fires both `focus` and `visibilitychange` only refetches once.
105
+ useEffect(() => {
106
+ let last = 0;
107
+ const revalidate = () => {
108
+ if (document.visibilityState === "hidden")
109
+ return;
110
+ const now = performance.now();
111
+ if (now - last < FOCUS_REVALIDATE_THROTTLE_MS)
112
+ return;
113
+ last = now;
114
+ setRefetchToken((n) => n + 1);
115
+ };
116
+ window.addEventListener("focus", revalidate);
117
+ window.addEventListener("online", revalidate);
118
+ document.addEventListener("visibilitychange", revalidate);
119
+ return () => {
120
+ window.removeEventListener("focus", revalidate);
121
+ window.removeEventListener("online", revalidate);
122
+ document.removeEventListener("visibilitychange", revalidate);
123
+ };
124
+ }, []);
125
+ const loadMore = useCallback(() => {
126
+ if (pageSize == null || loadingMore || state.loading || !state.hasMore)
127
+ return;
128
+ setLoadingMore(true);
129
+ rpc("query", {
130
+ alias,
131
+ params: params ?? {},
132
+ limit: pageSize,
133
+ offset: state.rows.length,
134
+ })
135
+ .then((result) => {
136
+ const page = result.rows ?? [];
137
+ setState((cur) => ({
138
+ ...cur,
139
+ rows: [...cur.rows, ...page],
140
+ hasMore: page.length === pageSize,
141
+ }));
142
+ })
143
+ .catch((err) => {
144
+ // Keep the rows we have — a failed page-append shouldn't blank the
145
+ // list — but surface the error instead of swallowing it. `hasMore`
146
+ // stays true, so the app can retry `loadMore()`.
147
+ setState((cur) => ({ ...cur, error: err.message }));
148
+ })
149
+ .finally(() => setLoadingMore(false));
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [alias, paramsKey, pageSize, loadingMore, state.loading, state.hasMore, state.rows.length]);
152
+ return { ...state, refetch, loadMore, loadingMore };
82
153
  }
83
154
  /**
84
155
  * Upload files from an app. The bytes are stored via a presigned
@@ -114,3 +185,43 @@ export function useFileUpload() {
114
185
  }, []);
115
186
  return { upload, uploading: inFlight > 0, error };
116
187
  }
188
+ /**
189
+ * List the members of the app's organization — the candidate set for an
190
+ * "assign to a member" picker. Resolves through the host (member-only; an
191
+ * anonymous public visitor gets an error). Names may be empty for members
192
+ * without a display name set — fall back to `email`.
193
+ *
194
+ * Gated: the app must DECLARE that it works with members — it needs a workflow
195
+ * whose manifest declares a `member`-typed input (i.e. it assigns members).
196
+ * Without one the host returns an error, so member access stays a declared,
197
+ * auditable surface rather than something any app can read ambiently.
198
+ *
199
+ * ```tsx
200
+ * const { members } = useMembers();
201
+ * // <Picker options={members.map((m) => ({ value: m.id, label: m.name || m.email || m.id }))} />
202
+ * ```
203
+ */
204
+ export function useMembers() {
205
+ const [state, setState] = useState({
206
+ members: [],
207
+ loading: true,
208
+ error: null,
209
+ });
210
+ useEffect(() => {
211
+ let cancelled = false;
212
+ setState((s) => ({ ...s, loading: true, error: null }));
213
+ rpc("members", {})
214
+ .then((r) => {
215
+ if (!cancelled)
216
+ setState({ members: r.members ?? [], loading: false, error: null });
217
+ })
218
+ .catch((err) => {
219
+ if (!cancelled)
220
+ setState({ members: [], loading: false, error: err.message });
221
+ });
222
+ return () => {
223
+ cancelled = true;
224
+ };
225
+ }, []);
226
+ return state;
227
+ }
@@ -16,8 +16,8 @@
16
16
  */
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
- export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
20
- export type { UploadedFile, WorkflowResult } from "./hooks.js";
19
+ export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
20
+ export type { UploadedFile, QueryOptions, WorkflowResult } from "./hooks.js";
21
21
  export { rpc } from "./rpc.js";
22
22
  export type { RpcOp } from "./rpc.js";
23
23
  export { openExternal } from "./open_external.js";
@@ -31,3 +31,5 @@ export { row, readLinks } from "./row.js";
31
31
  export type { ResolvedLink } from "./row.js";
32
32
  export { useOptimistic } from "./use_optimistic.js";
33
33
  export type { OptimisticApi } from "./use_optimistic.js";
34
+ export { useRecents } from "./use_recents.js";
35
+ export type { RecentsApi, RecentsOptions } from "./use_recents.js";
package/dist/src/index.js CHANGED
@@ -15,10 +15,11 @@
15
15
  * not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
16
16
  */
17
17
  export { mount } from "./mount.js";
18
- export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
18
+ export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
19
19
  export { rpc } from "./rpc.js";
20
20
  export { openExternal } from "./open_external.js";
21
21
  export { readMembers } from "./members.js";
22
22
  export { readSelect } from "./select.js";
23
23
  export { row, readLinks } from "./row.js";
24
24
  export { useOptimistic } from "./use_optimistic.js";
25
+ export { useRecents } from "./use_recents.js";
package/dist/src/rpc.d.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * app → host: { id, op, payload }
19
19
  * host → app: { id, type: "result", data } | { id, type: "error", message }
20
20
  */
21
- export type RpcOp = "query" | "workflow" | "upload" | "context" | "openExternal";
21
+ export type RpcOp = "query" | "workflow" | "upload" | "members" | "context" | "openExternal";
22
22
  /**
23
23
  * The app's identity, resolved once at startup to tag PostHog events.
24
24
  * Assembled by whichever transport is active:
package/dist/src/rpc.js CHANGED
@@ -217,12 +217,21 @@ function rpcStandalone(op, payload) {
217
217
  return standaloneWorkflow(payload);
218
218
  case "upload":
219
219
  return standaloneUpload(payload.file);
220
+ case "members":
221
+ return standaloneMembers();
220
222
  case "context":
221
223
  return standaloneContext();
222
224
  case "openExternal":
223
225
  return standaloneOpenExternal(payload);
224
226
  }
225
227
  }
228
+ async function standaloneMembers() {
229
+ const { app_id } = await boot();
230
+ const r = (await apiCall("GET", `/v1/apps/${app_id}/members`, undefined, {
231
+ appId: app_id,
232
+ }));
233
+ return { members: r.members ?? [] };
234
+ }
226
235
  /**
227
236
  * Open an external URL in a new tab, scheme-validated. In standalone mode the
228
237
  * app is a normal top-level page (`<slug>.lotics.app`), so `window.open` is not
@@ -257,7 +266,7 @@ async function standaloneContext() {
257
266
  }
258
267
  async function standaloneQuery(p) {
259
268
  const { app_id } = await boot();
260
- const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
269
+ const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params, limit: p.limit, offset: p.offset }, { appId: app_id }));
261
270
  return { rows: r.rows ?? [] };
262
271
  }
263
272
  async function standaloneWorkflow(p) {
@@ -0,0 +1,19 @@
1
+ export interface RecentsApi<T> {
2
+ /** Remembered items, most-recent first (deduped by `keyOf`, capped at `max`). */
3
+ recents: T[];
4
+ /** Record `item` as most-recent: moves an existing match to the front, caps,
5
+ * and persists. Call this on select. */
6
+ remember: (item: T) => void;
7
+ /** Drop one remembered item (matched by `keyOf`). */
8
+ forget: (item: T) => void;
9
+ /** Clear the whole list. */
10
+ clear: () => void;
11
+ }
12
+ export interface RecentsOptions<T> {
13
+ /** Stable identity per item — used to dedup and to match `forget`. Default:
14
+ * `JSON.stringify`. Pass the item's id for objects you'll re-create. */
15
+ keyOf?: (item: T) => string;
16
+ /** Maximum items kept. Default 5. */
17
+ max?: number;
18
+ }
19
+ export declare function useRecents<T>(key: string, options?: RecentsOptions<T>): RecentsApi<T>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Remember a small, most-recent-first list of items across sessions — the
3
+ * "recently used" affordance a search box shows when focused but empty.
4
+ *
5
+ * Persistence is `localStorage`, namespaced per `key`, so two comboboxes on one
6
+ * page keep separate lists and a deployed app's recents survive reloads. The
7
+ * SDK is the right home (not `@lotics/ui`): `localStorage` is web-only, and
8
+ * `@lotics/ui` also builds for native, where it doesn't exist. The list is
9
+ * always state-backed, so the hook keeps working in-memory if storage is
10
+ * unavailable (private mode / quota) — recents is an enhancement, never a
11
+ * reason to crash the app.
12
+ */
13
+ import { useCallback, useEffect, useRef, useState } from "react";
14
+ const PREFIX = "lotics.recents.";
15
+ function read(storageKey) {
16
+ try {
17
+ const raw = localStorage.getItem(storageKey);
18
+ if (raw == null)
19
+ return [];
20
+ const parsed = JSON.parse(raw);
21
+ return Array.isArray(parsed) ? parsed : [];
22
+ }
23
+ catch {
24
+ // Storage unavailable or corrupt JSON: start empty, keep working in-memory.
25
+ return [];
26
+ }
27
+ }
28
+ function write(storageKey, items) {
29
+ try {
30
+ localStorage.setItem(storageKey, JSON.stringify(items));
31
+ }
32
+ catch {
33
+ // Storage unavailable (private mode / quota): the in-memory list still
34
+ // updates; only cross-reload persistence is lost.
35
+ }
36
+ }
37
+ export function useRecents(key, options = {}) {
38
+ const storageKey = PREFIX + key;
39
+ // Config is captured in refs so the returned callbacks stay referentially
40
+ // stable across renders even when the caller passes an inline `keyOf`.
41
+ const keyOfRef = useRef(options.keyOf ?? ((item) => JSON.stringify(item)));
42
+ keyOfRef.current = options.keyOf ?? ((item) => JSON.stringify(item));
43
+ const maxRef = useRef(options.max ?? 5);
44
+ maxRef.current = options.max ?? 5;
45
+ const [recents, setRecents] = useState(() => read(storageKey).slice(0, maxRef.current));
46
+ // Re-hydrate when the namespace changes (a different list on the same screen).
47
+ useEffect(() => {
48
+ setRecents(read(storageKey).slice(0, maxRef.current));
49
+ }, [storageKey]);
50
+ const remember = useCallback((item) => {
51
+ setRecents((prev) => {
52
+ const k = keyOfRef.current(item);
53
+ const next = [item, ...prev.filter((p) => keyOfRef.current(p) !== k)].slice(0, maxRef.current);
54
+ write(storageKey, next);
55
+ return next;
56
+ });
57
+ }, [storageKey]);
58
+ const forget = useCallback((item) => {
59
+ setRecents((prev) => {
60
+ const k = keyOfRef.current(item);
61
+ const next = prev.filter((p) => keyOfRef.current(p) !== k);
62
+ write(storageKey, next);
63
+ return next;
64
+ });
65
+ }, [storageKey]);
66
+ const clear = useCallback(() => {
67
+ write(storageKey, []);
68
+ setRecents([]);
69
+ }, [storageKey]);
70
+ return { recents, remember, forget, clear };
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {