@lotics/app-sdk 0.26.0 → 0.28.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.
@@ -24,6 +24,7 @@ import { useCallback } from "react";
24
24
  import useSWR from "swr";
25
25
  import { rpc } from "./rpc.js";
26
26
  import { captureAppEvent } from "./analytics.js";
27
+ import { useAppContext } from "./viewer.js";
27
28
  /** The reduced storage shape the update endpoint accepts for `files`. */
28
29
  function toStorageFiles(files) {
29
30
  return (files ?? []).map((f) => ({
@@ -33,24 +34,6 @@ function toStorageFiles(files) {
33
34
  mime_type: f.mime_type,
34
35
  }));
35
36
  }
36
- /**
37
- * Read the app's context once, shared across every hook via a stable SWR key.
38
- * Comments need a signed-in member (members-only) AND the app's declared
39
- * `comments` capability — both come from here.
40
- */
41
- function useAppContext() {
42
- const { data } = useSWR("app-context", () => rpc("context", {}), {
43
- revalidateOnFocus: false,
44
- revalidateIfStale: false,
45
- revalidateOnReconnect: false,
46
- shouldRetryOnError: false,
47
- });
48
- return {
49
- memberId: data?.member_id ?? null,
50
- commentsEnabled: data?.comments_enabled ?? false,
51
- resolved: data !== undefined,
52
- };
53
- }
54
37
  let optimisticCounter = 0;
55
38
  /**
56
39
  * ```tsx
@@ -1,7 +1,7 @@
1
1
  import type { AppWorkflows, AppQueries } from "./types.js";
2
2
  import type { ResolvedMember } from "./members.js";
3
- interface QueryState<R> {
4
- rows: R[];
3
+ /** Fields shared by every query hook's return value. */
4
+ interface QueryStateBase {
5
5
  /**
6
6
  * True only on the initial load — a request is in flight and there are no
7
7
  * rows yet. Stays false during background revalidation and while typing a new
@@ -13,14 +13,22 @@ interface QueryState<R> {
13
13
  isValidating: boolean;
14
14
  error: string | null;
15
15
  /**
16
- * Re-run the query from the first page. Use after a known mutation point —
17
- * a successful `useWorkflow(alias)()` call — to pull the latest state.
16
+ * Re-run the query. Use after a known mutation point — a successful
17
+ * `useWorkflow(alias)()` call — to pull the latest state.
18
18
  */
19
19
  refetch: () => void;
20
+ }
21
+ /** Return value of `useQuery` — a single fetch, no pagination. */
22
+ interface QueryState<R> extends QueryStateBase {
23
+ rows: R[];
24
+ }
25
+ /** Return value of `useInfiniteQuery` — append/load-more. */
26
+ interface InfiniteQueryState<R> extends QueryStateBase {
27
+ /** All loaded pages, flattened and accumulated. */
28
+ rows: R[];
20
29
  /**
21
- * Fetch the next page and append it to `rows`. No-op when `pageSize` was not
22
- * set or there are no more rows (`hasMore` is false). `loadingMore` is true
23
- * while it runs.
30
+ * Fetch the next page and append it to `rows`. No-op when there are no more
31
+ * rows (`hasMore` is false). `loadingMore` is true while it runs.
24
32
  */
25
33
  loadMore: () => void;
26
34
  /** True when the last page came back full, so more rows may exist. */
@@ -28,6 +36,23 @@ interface QueryState<R> {
28
36
  /** True while a `loadMore` request is in flight. */
29
37
  loadingMore: boolean;
30
38
  }
39
+ /** Return value of `usePaginatedQuery` — page-model with a total. */
40
+ interface PaginatedQueryState<R> extends QueryStateBase {
41
+ /** Rows of the current page only (≤ `pageSize`). */
42
+ rows: R[];
43
+ /** Total rows in the filtered set (across all pages). `undefined` until the
44
+ * count resolves. */
45
+ total: number | undefined;
46
+ /** `ceil(total / pageSize)`, or `undefined` until the count resolves. */
47
+ totalPages: number | undefined;
48
+ /** Current 0-indexed page. */
49
+ page: number;
50
+ pageSize: number;
51
+ /** True when a next page exists. */
52
+ hasMore: boolean;
53
+ /** Jump to a page (0-indexed). Clamped at 0. */
54
+ setPage: (page: number) => void;
55
+ }
31
56
  /**
32
57
  * One sort key — the wire shape of the query RPC's `sort`. The server applies
33
58
  * these AFTER the named query, bounded to the query's output columns (an
@@ -55,17 +80,11 @@ export interface QueryFilterGroup {
55
80
  /**
56
81
  * Runtime filter applied AFTER the named query, bounded to its output columns
57
82
  * (same exposure invariant as `sort`). Build a group from per-column filters
58
- * with `columnFilterToConditions` (`@lotics/ui/grid/column_filter`).
83
+ * with `columnFilterToConditions` (`@lotics/ui/column_filter`).
59
84
  */
60
85
  export type QueryFilter = QueryFilterCondition | QueryFilterGroup;
61
- /** Options for `useQuery`. */
62
- export interface QueryOptions {
63
- /**
64
- * Page size. Omit to fetch all rows in one request (the server still caps
65
- * the maximum). Set it to paginate: the first render loads one page, and
66
- * `loadMore()` appends the next.
67
- */
68
- pageSize?: number;
86
+ /** Options shared by every query hook. */
87
+ export interface BaseQueryOptions {
69
88
  /**
70
89
  * When `false`, the query does not run: `rows` stays empty, `loading` is
71
90
  * false, and no request is sent. Flip it back to `true` to fetch. This is the
@@ -94,6 +113,22 @@ export interface QueryOptions {
94
113
  */
95
114
  filter?: QueryFilter;
96
115
  }
116
+ /** Options for `useQuery` — a single fetch. */
117
+ export interface QueryOptions extends BaseQueryOptions {
118
+ /** Max rows to fetch in the one request (a cap, not pagination). The server
119
+ * still clamps to its own maximum. Omit to fetch up to the server cap. */
120
+ pageSize?: number;
121
+ }
122
+ /** Options for `useInfiniteQuery` — append/load-more. */
123
+ export interface InfiniteQueryOptions extends BaseQueryOptions {
124
+ /** Rows per page. `loadMore()` appends the next page. */
125
+ pageSize: number;
126
+ }
127
+ /** Options for `usePaginatedQuery` — page-model with a total. */
128
+ export interface PaginatedQueryOptions extends BaseQueryOptions {
129
+ /** Rows per page. Default 25. */
130
+ pageSize?: number;
131
+ }
97
132
  /**
98
133
  * Trigger a workflow by alias from the app's manifest.
99
134
  *
@@ -128,9 +163,11 @@ export interface WorkflowResult {
128
163
  message?: string;
129
164
  files?: UploadedFile[];
130
165
  }
131
- type UseQueryArgs<K extends keyof AppQueries & string> = AppQueries[K] extends Record<string, never> ? [params?: Record<string, never>, opts?: QueryOptions] : [params: AppQueries[K], opts?: QueryOptions];
166
+ type QueryArgs<K extends keyof AppQueries & string, O> = AppQueries[K] extends Record<string, never> ? [params?: Record<string, never>, opts?: O] : [params: AppQueries[K], opts?: O];
132
167
  /**
133
- * Read rows from a query the app's author declared in `lotics.queries`.
168
+ * Read rows from a query the app's author declared in `lotics.queries` — a
169
+ * single fetch, no pagination. For long lists use `usePaginatedQuery`
170
+ * (numbered pages + total) or `useInfiniteQuery` (load-more).
134
171
  *
135
172
  * The app never sends a raw query AST — it invokes a named query by alias and
136
173
  * fills the template's declared `{{params.x}}` value holes. The server holds
@@ -145,8 +182,35 @@ type UseQueryArgs<K extends keyof AppQueries & string> = AppQueries[K] extends R
145
182
  * with the declared alias → param-type map, so an undeclared alias is a
146
183
  * compile-time error and params are typed per the manifest.
147
184
  */
148
- export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: UseQueryArgs<K>): QueryState<Record<string, unknown>>;
185
+ export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, QueryOptions>): QueryState<Record<string, unknown>>;
149
186
  export declare function useQuery(alias: string, params?: Record<string, unknown>, opts?: QueryOptions): QueryState<Record<string, unknown>>;
187
+ /**
188
+ * Like `useQuery` but append/load-more: the first render loads one page and
189
+ * `loadMore()` appends the next, accumulating into `rows` (infinite scroll).
190
+ * For numbered pages + a total, use `usePaginatedQuery`.
191
+ *
192
+ * ```tsx
193
+ * const { rows, loadMore, hasMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
194
+ * ```
195
+ */
196
+ export declare function useInfiniteQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions>): InfiniteQueryState<Record<string, unknown>>;
197
+ export declare function useInfiniteQuery(alias: string, params?: Record<string, unknown>, opts?: InfiniteQueryOptions): InfiniteQueryState<Record<string, unknown>>;
198
+ /**
199
+ * Page-model query with a total — the data hook behind a numbered, jumpable
200
+ * table (`@lotics/ui/table_picker`, `@lotics/ui/pagination`). It owns the page
201
+ * cursor and fetches two things: the current page of rows, and a `count` over
202
+ * the filtered set (keyed independently of page + sort, so paging and
203
+ * re-sorting never recount). The `(params, filter)` tuple is the result-set
204
+ * identity: changing it resets to page 0 AND recounts; changing only `sort`
205
+ * does neither.
206
+ *
207
+ * ```tsx
208
+ * const { rows, total, page, setPage, hasMore } =
209
+ * usePaginatedQuery("orders", { q }, { pageSize: 25, sort, filter });
210
+ * ```
211
+ */
212
+ export declare function usePaginatedQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions>): PaginatedQueryState<Record<string, unknown>>;
213
+ export declare function usePaginatedQuery(alias: string, params?: Record<string, unknown>, opts?: PaginatedQueryOptions): PaginatedQueryState<Record<string, unknown>>;
150
214
  /** A file the host has stored and resolved serving URLs for. */
151
215
  export interface UploadedFile {
152
216
  id: string;
package/dist/src/hooks.js CHANGED
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * Typed React hooks for Lotics app data access.
3
3
  *
4
- * The SDK surface is three hooks: `useQuery` for reads, `useWorkflow` for
5
- * every mutation, and `useFileUpload` for attaching files. App code never
6
- * writes records directly all writes flow through declared workflows, which
7
- * gives the app owner a typed, audited chokepoint and means a publicly-shared
8
- * app exposes no anonymous direct-write path. An uploaded file is inert until
9
- * a workflow attaches it, so file upload keeps that same property.
4
+ * Reads come in three shapes `useQuery` (single fetch), `useInfiniteQuery`
5
+ * (append / load-more), and `usePaginatedQuery` (numbered pages + total) and
6
+ * every mutation is a `useWorkflow`; `useFileUpload` attaches files. App code
7
+ * never writes records directly all writes flow through declared workflows,
8
+ * which gives the app owner a typed, audited chokepoint and means a
9
+ * publicly-shared app exposes no anonymous direct-write path. An uploaded file
10
+ * is inert until a workflow attaches it, so file upload keeps that same property.
10
11
  *
11
12
  * Every hook is a thin wrapper over the postMessage RPC bridge — the parent
12
- * does the actual API calls, results stream back through `rpc()`. `useQuery`
13
- * caches through SWR keyed by (alias, params, pageSize): reads dedupe and the
13
+ * does the actual API calls, results stream back through `rpc()`. The read
14
+ * hooks cache through SWR keyed by (alias, params, ): reads dedupe and the
14
15
  * cache survives unmount/remount. Mutation / member hooks keep their own local
15
16
  * `useState` — they have nothing to share.
16
17
  */
17
18
  import { useCallback, useEffect, useState } from "react";
19
+ import useSWR from "swr";
18
20
  import useSWRInfinite from "swr/infinite";
19
21
  import { rpc } from "./rpc.js";
20
22
  import { getMockRows } from "./mock.js";
@@ -32,31 +34,62 @@ export function useWorkflow(alias) {
32
34
  }
33
35
  }, [alias]);
34
36
  }
37
+ // Shared SWR config: surface a failed query immediately, keep the last good
38
+ // rows (no retry loop that masks the error), and honor the focus/reconnect
39
+ // opt-out.
40
+ function swrConfig(revalidateOnFocus) {
41
+ return {
42
+ revalidateOnFocus,
43
+ revalidateOnReconnect: revalidateOnFocus,
44
+ shouldRetryOnError: false,
45
+ };
46
+ }
35
47
  export function useQuery(alias, params, opts) {
36
48
  const pageSize = opts?.pageSize;
37
49
  const enabled = opts?.enabled ?? true;
38
50
  const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
39
51
  const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
40
52
  const filter = opts?.filter;
41
- // A registered fixture short-circuits the network in mock / dev mode.
42
53
  const mockRows = getMockRows(alias);
43
- // The cache key is (alias, params, pageSize, pageIndex), built HERE so an app
44
- // never constructs a key it just calls `useQuery("hoSo")`. SWR canonicalizes
45
- // the params object via its stable hash, so every `useQuery` with the same
46
- // (alias, params, pageSize) dedupes to one request and one shared cache entry,
47
- // and that entry survives unmount/remount — navigating back to a screen shows
48
- // cached rows instantly and revalidates in the background (no flash). A `null`
49
- // key disables the fetch (mock / disabled / past the last page).
54
+ // A `null` key disables the fetch (mock / disabled). SWR canonicalizes the
55
+ // params/sort/filter objects via its stable hash, so identical reads dedupe to
56
+ // one request + one cache entry that survives unmount/remount.
57
+ const key = mockRows || !enabled
58
+ ? null
59
+ : ["app-query", alias, params ?? {}, pageSize ?? null, sort ?? null, filter ?? null];
60
+ const swr = useSWR(key, () => rpc("query", {
61
+ alias,
62
+ params: params ?? {},
63
+ limit: pageSize,
64
+ offset: 0,
65
+ sort,
66
+ filter,
67
+ }), swrConfig(revalidateOnFocus));
68
+ const refetch = useCallback(() => {
69
+ void swr.mutate();
70
+ }, [swr]);
71
+ return {
72
+ rows: mockRows ?? swr.data?.rows ?? [],
73
+ loading: mockRows ? false : swr.isLoading,
74
+ isValidating: mockRows ? false : swr.isValidating,
75
+ error: swr.error ? swr.error.message : null,
76
+ refetch,
77
+ };
78
+ }
79
+ export function useInfiniteQuery(alias, params, opts) {
80
+ const pageSize = opts?.pageSize ?? 30;
81
+ const enabled = opts?.enabled ?? true;
82
+ const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
83
+ const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
84
+ const filter = opts?.filter;
85
+ const mockRows = getMockRows(alias);
50
86
  const getKey = (index, prev) => {
51
87
  if (mockRows || !enabled)
52
88
  return null;
53
- // Non-paginated has only page 0; paginated stops once a short page returns.
54
- if (index > 0 && (pageSize == null || prev == null || prev.rows.length < pageSize)) {
89
+ // Stop once a short page returns.
90
+ if (index > 0 && (prev == null || prev.rows.length < pageSize))
55
91
  return null;
56
- }
57
- // sort/filter join the key so a re-sort or re-filter is a distinct cache
58
- // entry — SWR refetches instead of serving the previous order/subset.
59
- return ["app-query", alias, params ?? {}, pageSize ?? null, sort ?? null, filter ?? null, index];
92
+ return ["app-query-infinite", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, index];
60
93
  };
61
94
  const swr = useSWRInfinite(getKey, (key) => {
62
95
  const index = Number(key[6]);
@@ -64,42 +97,29 @@ export function useQuery(alias, params, opts) {
64
97
  alias,
65
98
  params: params ?? {},
66
99
  limit: pageSize,
67
- offset: pageSize != null ? index * pageSize : 0,
100
+ offset: index * pageSize,
68
101
  sort,
69
102
  filter,
70
103
  });
71
- }, {
72
- // loadMore appends pages — don't re-fetch earlier pages when `size` grows.
73
- revalidateFirstPage: false,
74
- // The opt-out covers focus AND reconnect, per the documented contract.
75
- revalidateOnFocus,
76
- revalidateOnReconnect: revalidateOnFocus,
77
- // Surface a failed query immediately and keep the last good rows — no
78
- // silent retry loop that would mask the error from the app.
79
- shouldRetryOnError: false,
80
- });
81
- // SWRInfinite leaves an in-flight page slot `undefined` until it resolves
82
- // (the typed `QueryPage[]` understates this) — operate on resolved pages only,
83
- // so a page being appended never crashes the flatten or skews the counts.
104
+ }, { revalidateFirstPage: false, ...swrConfig(revalidateOnFocus) });
105
+ // SWRInfinite leaves an in-flight page slot `undefined` until it resolves —
106
+ // operate on resolved pages only so a page being appended never crashes the
107
+ // flatten or skews the counts.
84
108
  const pages = (swr.data ?? []).filter((p) => p != null);
85
109
  const rows = mockRows ?? pages.flatMap((p) => p.rows ?? []);
86
110
  const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
87
- const hasMore = pageSize != null && lastPage != null && (lastPage.rows?.length ?? 0) === pageSize;
88
- // Appending a page: more pages are requested than have resolved.
111
+ const hasMore = lastPage != null && (lastPage.rows?.length ?? 0) === pageSize;
89
112
  const loadingMore = swr.isValidating && swr.size > pages.length;
90
113
  const refetch = useCallback(() => {
91
114
  void swr.mutate();
92
115
  }, [swr]);
93
116
  const loadMore = useCallback(() => {
94
- if (pageSize == null || !hasMore || loadingMore)
117
+ if (!hasMore || loadingMore)
95
118
  return;
96
119
  void swr.setSize((n) => n + 1);
97
- }, [pageSize, hasMore, loadingMore, swr]);
120
+ }, [hasMore, loadingMore, swr]);
98
121
  return {
99
122
  rows,
100
- // `loading` = initial load (validating with nothing to show yet);
101
- // `isValidating` = any request in flight. Gating a skeleton on `loading`
102
- // never flashes on revalidation; use `isValidating` for a subtle indicator.
103
123
  loading: mockRows ? false : swr.isLoading,
104
124
  isValidating: mockRows ? false : swr.isValidating,
105
125
  error: swr.error ? swr.error.message : null,
@@ -109,6 +129,61 @@ export function useQuery(alias, params, opts) {
109
129
  loadingMore,
110
130
  };
111
131
  }
132
+ export function usePaginatedQuery(alias, params, opts) {
133
+ const pageSize = opts?.pageSize ?? 25;
134
+ const enabled = opts?.enabled ?? true;
135
+ const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
136
+ const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
137
+ const filter = opts?.filter;
138
+ const mockRows = getMockRows(alias);
139
+ // The result-set identity. When it changes, `page` derives back to 0 (not via
140
+ // an effect, so the stale page never fires a wasted fetch) and the count key
141
+ // changes (recount). `setPage` re-stamps the current identity.
142
+ const resetKey = JSON.stringify([params ?? {}, filter ?? null]);
143
+ const [pageState, setPageState] = useState({ key: resetKey, page: 0 });
144
+ const page = pageState.key === resetKey ? pageState.page : 0;
145
+ const setPage = useCallback((p) => setPageState({ key: resetKey, page: Math.max(0, p) }), [resetKey]);
146
+ const rowsKey = mockRows || !enabled
147
+ ? null
148
+ : ["app-query-page", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, page];
149
+ const rowsSwr = useSWR(rowsKey, () => rpc("query", {
150
+ alias,
151
+ params: params ?? {},
152
+ limit: pageSize,
153
+ offset: page * pageSize,
154
+ sort,
155
+ filter,
156
+ }),
157
+ // Keep the previous page's rows on screen while the next page loads.
158
+ { keepPreviousData: true, ...swrConfig(revalidateOnFocus) });
159
+ // Count key omits page AND sort — one count per result-set identity, reused
160
+ // across page clicks and re-sorts.
161
+ const countKey = mockRows || !enabled
162
+ ? null
163
+ : ["app-query-count", alias, params ?? {}, filter ?? null];
164
+ const countSwr = useSWR(countKey, () => rpc("query", { alias, params: params ?? {}, filter, count: true }), swrConfig(revalidateOnFocus));
165
+ const rows = mockRows ?? rowsSwr.data?.rows ?? [];
166
+ const total = mockRows ? mockRows.length : countSwr.data?.total;
167
+ const totalPages = total != null ? Math.max(1, Math.ceil(total / pageSize)) : undefined;
168
+ const hasMore = total != null ? (page + 1) * pageSize < total : rows.length === pageSize;
169
+ const refetch = useCallback(() => {
170
+ void rowsSwr.mutate();
171
+ void countSwr.mutate();
172
+ }, [rowsSwr, countSwr]);
173
+ return {
174
+ rows,
175
+ total,
176
+ totalPages,
177
+ page,
178
+ pageSize,
179
+ hasMore,
180
+ setPage,
181
+ loading: mockRows ? false : rowsSwr.isLoading,
182
+ isValidating: mockRows ? false : rowsSwr.isValidating || countSwr.isValidating,
183
+ error: rowsSwr.error?.message ?? countSwr.error?.message ?? null,
184
+ refetch,
185
+ };
186
+ }
112
187
  /**
113
188
  * Upload files from an app. The bytes are stored via a presigned
114
189
  * direct-to-storage upload the host mediates; the API server never proxies
@@ -16,10 +16,11 @@
16
16
  */
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
- export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
20
- export type { UploadedFile, QueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, } from "./hooks.js";
19
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUpload, useMembers, } from "./hooks.js";
20
+ export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, } from "./hooks.js";
21
21
  export { useComments } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs } from "./comments.js";
23
+ export { useViewer } from "./viewer.js";
23
24
  export { rpc } from "./rpc.js";
24
25
  export type { RpcOp } from "./rpc.js";
25
26
  export { openExternal } from "./open_external.js";
package/dist/src/index.js CHANGED
@@ -15,8 +15,9 @@
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, useMembers } from "./hooks.js";
18
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUpload, useMembers, } from "./hooks.js";
19
19
  export { useComments } from "./comments.js";
20
+ export { useViewer } from "./viewer.js";
20
21
  export { rpc } from "./rpc.js";
21
22
  export { openExternal } from "./open_external.js";
22
23
  export { readMembers } from "./members.js";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Read the app's context once, shared across every hook via a stable SWR key.
3
+ * The host (the product iframe, or `lotics app dev`) supplies the signed-in
4
+ * member and the app's declared capabilities.
5
+ */
6
+ export declare function useAppContext(): {
7
+ memberId: string | null;
8
+ commentsEnabled: boolean;
9
+ resolved: boolean;
10
+ };
11
+ /**
12
+ * The signed-in member currently viewing the app — the **view-as target** under
13
+ * an admin's "View as" (product UI or `lotics app dev --view-as`), the
14
+ * authenticated member otherwise, and `null` for a public/standalone visitor or
15
+ * until the context resolves (gate viewer-dependent UI on `loading`).
16
+ *
17
+ * Use it to personalize (greet the member, default an assignment to them) or to
18
+ * pass the viewer into a workflow that must attribute to them. **Per-row data
19
+ * scoping does NOT need this** — put an `is_current_member` filter in the query
20
+ * template and the server resolves it to the same member (honoring view-as).
21
+ */
22
+ export declare function useViewer(): {
23
+ memberId: string | null;
24
+ loading: boolean;
25
+ };
@@ -0,0 +1,35 @@
1
+ import useSWR from "swr";
2
+ import { rpc } from "./rpc.js";
3
+ /**
4
+ * Read the app's context once, shared across every hook via a stable SWR key.
5
+ * The host (the product iframe, or `lotics app dev`) supplies the signed-in
6
+ * member and the app's declared capabilities.
7
+ */
8
+ export function useAppContext() {
9
+ const { data } = useSWR("app-context", () => rpc("context", {}), {
10
+ revalidateOnFocus: false,
11
+ revalidateIfStale: false,
12
+ revalidateOnReconnect: false,
13
+ shouldRetryOnError: false,
14
+ });
15
+ return {
16
+ memberId: data?.member_id ?? null,
17
+ commentsEnabled: data?.comments_enabled ?? false,
18
+ resolved: data !== undefined,
19
+ };
20
+ }
21
+ /**
22
+ * The signed-in member currently viewing the app — the **view-as target** under
23
+ * an admin's "View as" (product UI or `lotics app dev --view-as`), the
24
+ * authenticated member otherwise, and `null` for a public/standalone visitor or
25
+ * until the context resolves (gate viewer-dependent UI on `loading`).
26
+ *
27
+ * Use it to personalize (greet the member, default an assignment to them) or to
28
+ * pass the viewer into a workflow that must attribute to them. **Per-row data
29
+ * scoping does NOT need this** — put an `is_current_member` filter in the query
30
+ * template and the server resolves it to the same member (honoring view-as).
31
+ */
32
+ export function useViewer() {
33
+ const ctx = useAppContext();
34
+ return { memberId: ctx.memberId, loading: !ctx.resolved };
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.26.0",
3
+ "version": "0.28.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": {