lemma-sdk 0.2.31 → 0.2.33

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.
Files changed (45) hide show
  1. package/README.md +138 -52
  2. package/dist/browser/lemma-client.js +23 -8
  3. package/dist/namespaces/desks.d.ts +5 -2
  4. package/dist/namespaces/desks.js +5 -2
  5. package/dist/namespaces/files.d.ts +11 -0
  6. package/dist/namespaces/files.js +12 -0
  7. package/dist/openapi_client/models/DeskBundleUploadRequest.d.ts +1 -1
  8. package/dist/openapi_client/services/DesksService.d.ts +4 -4
  9. package/dist/openapi_client/services/DesksService.js +6 -6
  10. package/dist/openapi_client/services/PublicDesksService.d.ts +2 -2
  11. package/dist/openapi_client/services/PublicDesksService.js +3 -3
  12. package/dist/react/index.d.ts +13 -1
  13. package/dist/react/index.js +6 -0
  14. package/dist/react/useAssistantController.js +82 -37
  15. package/dist/react/useAssistantRuntime.js +8 -4
  16. package/dist/react/useAssistantSession.js +44 -2
  17. package/dist/react/useConversationMessages.js +19 -2
  18. package/dist/react/useCreateRecord.d.ts +33 -3
  19. package/dist/react/useCreateRecord.js +12 -2
  20. package/dist/react/useFile.d.ts +18 -0
  21. package/dist/react/useFile.js +58 -0
  22. package/dist/react/useFilePreview.d.ts +23 -0
  23. package/dist/react/useFilePreview.js +76 -0
  24. package/dist/react/useFileSearch.d.ts +26 -0
  25. package/dist/react/useFileSearch.js +64 -0
  26. package/dist/react/useFileTree.d.ts +21 -0
  27. package/dist/react/useFileTree.js +59 -0
  28. package/dist/react/useFiles.d.ts +29 -0
  29. package/dist/react/useFiles.js +90 -0
  30. package/dist/react/useForeignKeyOptions.d.ts +18 -0
  31. package/dist/react/useFunctionRun.d.ts +17 -0
  32. package/dist/react/useJoinedRecords.d.ts +57 -2
  33. package/dist/react/useJoinedRecords.js +54 -5
  34. package/dist/react/useRecord.d.ts +16 -0
  35. package/dist/react/useRecordForm.d.ts +42 -3
  36. package/dist/react/useRecordForm.js +43 -6
  37. package/dist/react/useRecords.js +8 -5
  38. package/dist/react/useReferencingRecords.d.ts +66 -0
  39. package/dist/react/useReferencingRecords.js +159 -0
  40. package/dist/react/useRelatedRecords.d.ts +17 -0
  41. package/dist/react/useReverseRelatedRecords.d.ts +21 -0
  42. package/dist/react/useUpdateRecord.d.ts +34 -3
  43. package/dist/react/useUpdateRecord.js +13 -2
  44. package/dist/types.d.ts +6 -1
  45. package/package.json +2 -1
@@ -0,0 +1,76 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useFilePreview({ client, podId, path = null, enabled = true, autoLoad = true, mode = "rendered", artifact = "document.md", }) {
4
+ const [content, setContent] = useState(null);
5
+ const [blob, setBlob] = useState(null);
6
+ const [isLoading, setIsLoading] = useState(false);
7
+ const [error, setError] = useState(null);
8
+ const trimmedPath = typeof path === "string" ? path.trim() : "";
9
+ const isEnabled = enabled && trimmedPath.length > 0;
10
+ const refresh = useCallback(async (overrides = {}, signal) => {
11
+ const nextPath = typeof overrides.path === "string" ? overrides.path.trim() : trimmedPath;
12
+ const nextMode = overrides.mode ?? mode;
13
+ const nextArtifact = overrides.artifact ?? artifact;
14
+ if (!enabled || nextPath.length === 0) {
15
+ setContent(null);
16
+ setBlob(null);
17
+ setError(null);
18
+ setIsLoading(false);
19
+ return null;
20
+ }
21
+ setIsLoading(true);
22
+ setError(null);
23
+ try {
24
+ const scopedClient = resolvePodClient(client, podId);
25
+ if (nextMode === "artifact") {
26
+ const nextBlob = await scopedClient.files.converted.download(nextPath, nextArtifact);
27
+ const text = await nextBlob.text();
28
+ if (signal?.aborted)
29
+ return null;
30
+ setBlob(nextBlob);
31
+ setContent(text);
32
+ return text;
33
+ }
34
+ const rendered = await scopedClient.files.converted.render(nextPath);
35
+ const text = typeof rendered === "string" ? rendered : String(rendered ?? "");
36
+ if (signal?.aborted)
37
+ return null;
38
+ setBlob(null);
39
+ setContent(text);
40
+ return text;
41
+ }
42
+ catch (refreshError) {
43
+ if (signal?.aborted)
44
+ return null;
45
+ setError(normalizeError(refreshError, "Failed to load file preview."));
46
+ setContent(null);
47
+ setBlob(null);
48
+ return null;
49
+ }
50
+ finally {
51
+ if (!signal?.aborted)
52
+ setIsLoading(false);
53
+ }
54
+ }, [artifact, client, enabled, mode, podId, trimmedPath]);
55
+ useEffect(() => {
56
+ if (!isEnabled) {
57
+ setContent(null);
58
+ setBlob(null);
59
+ setError(null);
60
+ setIsLoading(false);
61
+ return;
62
+ }
63
+ if (!autoLoad)
64
+ return;
65
+ const controller = new AbortController();
66
+ void refresh({}, controller.signal);
67
+ return () => controller.abort();
68
+ }, [autoLoad, isEnabled, refresh]);
69
+ return useMemo(() => ({
70
+ content,
71
+ blob,
72
+ isLoading,
73
+ error,
74
+ refresh,
75
+ }), [blob, content, error, isLoading, refresh]);
76
+ }
@@ -0,0 +1,26 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { FileSearchResponse, FileSearchResultSchema, SearchMethod } from "../types.js";
3
+ export interface UseFileSearchOptions {
4
+ client: LemmaClient;
5
+ podId?: string;
6
+ query?: string;
7
+ enabled?: boolean;
8
+ autoLoad?: boolean;
9
+ minQueryLength?: number;
10
+ limit?: number;
11
+ searchMethod?: SearchMethod;
12
+ }
13
+ export interface UseFileSearchResult {
14
+ response: FileSearchResponse | null;
15
+ results: FileSearchResultSchema[];
16
+ totalResults: number;
17
+ isLoading: boolean;
18
+ error: Error | null;
19
+ search: (overrides?: {
20
+ query?: string;
21
+ limit?: number;
22
+ searchMethod?: SearchMethod;
23
+ }) => Promise<FileSearchResponse | null>;
24
+ reset: () => void;
25
+ }
26
+ export declare function useFileSearch({ client, podId, query, enabled, autoLoad, minQueryLength, limit, searchMethod, }: UseFileSearchOptions): UseFileSearchResult;
@@ -0,0 +1,64 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useFileSearch({ client, podId, query = "", enabled = true, autoLoad = true, minQueryLength = 1, limit = 10, searchMethod, }) {
4
+ const [response, setResponse] = useState(null);
5
+ const [isLoading, setIsLoading] = useState(false);
6
+ const [error, setError] = useState(null);
7
+ const trimmedQuery = query.trim();
8
+ const reset = useCallback(() => {
9
+ setResponse(null);
10
+ setError(null);
11
+ setIsLoading(false);
12
+ }, []);
13
+ const search = useCallback(async (overrides = {}, signal) => {
14
+ const nextQuery = (overrides.query ?? trimmedQuery).trim();
15
+ if (!enabled || nextQuery.length < minQueryLength) {
16
+ reset();
17
+ return null;
18
+ }
19
+ setIsLoading(true);
20
+ setError(null);
21
+ try {
22
+ const scopedClient = resolvePodClient(client, podId);
23
+ const nextResponse = await scopedClient.files.search(nextQuery, {
24
+ limit: overrides.limit ?? limit,
25
+ searchMethod: overrides.searchMethod ?? searchMethod,
26
+ });
27
+ if (signal?.aborted)
28
+ return null;
29
+ setResponse(nextResponse);
30
+ return nextResponse;
31
+ }
32
+ catch (searchError) {
33
+ if (signal?.aborted)
34
+ return null;
35
+ setError(normalizeError(searchError, "Failed to search files."));
36
+ setResponse(null);
37
+ return null;
38
+ }
39
+ finally {
40
+ if (!signal?.aborted)
41
+ setIsLoading(false);
42
+ }
43
+ }, [client, enabled, limit, minQueryLength, podId, reset, searchMethod, trimmedQuery]);
44
+ useEffect(() => {
45
+ if (!enabled || !autoLoad)
46
+ return;
47
+ if (trimmedQuery.length < minQueryLength) {
48
+ reset();
49
+ return;
50
+ }
51
+ const controller = new AbortController();
52
+ void search({}, controller.signal);
53
+ return () => controller.abort();
54
+ }, [autoLoad, enabled, minQueryLength, reset, search, trimmedQuery]);
55
+ return useMemo(() => ({
56
+ response,
57
+ results: response?.results ?? [],
58
+ totalResults: response?.total_results ?? 0,
59
+ isLoading,
60
+ error,
61
+ search,
62
+ reset,
63
+ }), [error, isLoading, reset, response, search]);
64
+ }
@@ -0,0 +1,21 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { DirectoryTreeNode, DirectoryTreeResponse } from "../types.js";
3
+ export interface UseFileTreeOptions {
4
+ client: LemmaClient;
5
+ podId?: string;
6
+ enabled?: boolean;
7
+ autoLoad?: boolean;
8
+ rootPath?: string;
9
+ filesPerDirectory?: number;
10
+ }
11
+ export interface UseFileTreeResult {
12
+ tree: DirectoryTreeNode | null;
13
+ response: DirectoryTreeResponse | null;
14
+ isLoading: boolean;
15
+ error: Error | null;
16
+ refresh: (overrides?: {
17
+ rootPath?: string;
18
+ filesPerDirectory?: number;
19
+ }) => Promise<DirectoryTreeResponse | null>;
20
+ }
21
+ export declare function useFileTree({ client, podId, enabled, autoLoad, rootPath, filesPerDirectory, }: UseFileTreeOptions): UseFileTreeResult;
@@ -0,0 +1,59 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useFileTree({ client, podId, enabled = true, autoLoad = true, rootPath = "/", filesPerDirectory = 3, }) {
4
+ const [response, setResponse] = useState(null);
5
+ const [isLoading, setIsLoading] = useState(false);
6
+ const [error, setError] = useState(null);
7
+ const refresh = useCallback(async (overrides = {}, signal) => {
8
+ if (!enabled) {
9
+ setResponse(null);
10
+ setError(null);
11
+ setIsLoading(false);
12
+ return null;
13
+ }
14
+ setIsLoading(true);
15
+ setError(null);
16
+ try {
17
+ const scopedClient = resolvePodClient(client, podId);
18
+ const nextResponse = await scopedClient.files.tree({
19
+ rootPath: overrides.rootPath ?? rootPath,
20
+ filesPerDirectory: overrides.filesPerDirectory ?? filesPerDirectory,
21
+ });
22
+ if (signal?.aborted)
23
+ return null;
24
+ setResponse(nextResponse);
25
+ return nextResponse;
26
+ }
27
+ catch (refreshError) {
28
+ if (signal?.aborted)
29
+ return null;
30
+ setError(normalizeError(refreshError, "Failed to load file tree."));
31
+ setResponse(null);
32
+ return null;
33
+ }
34
+ finally {
35
+ if (!signal?.aborted)
36
+ setIsLoading(false);
37
+ }
38
+ }, [client, enabled, filesPerDirectory, podId, rootPath]);
39
+ useEffect(() => {
40
+ if (!enabled) {
41
+ setResponse(null);
42
+ setError(null);
43
+ setIsLoading(false);
44
+ return;
45
+ }
46
+ if (!autoLoad)
47
+ return;
48
+ const controller = new AbortController();
49
+ void refresh({}, controller.signal);
50
+ return () => controller.abort();
51
+ }, [autoLoad, enabled, refresh]);
52
+ return useMemo(() => ({
53
+ tree: response?.tree ?? null,
54
+ response,
55
+ isLoading,
56
+ error,
57
+ refresh,
58
+ }), [error, isLoading, refresh, response]);
59
+ }
@@ -0,0 +1,29 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { FileResponse } from "../types.js";
3
+ export interface UseFilesOptions {
4
+ client: LemmaClient;
5
+ podId?: string;
6
+ enabled?: boolean;
7
+ autoLoad?: boolean;
8
+ limit?: number;
9
+ pageToken?: string;
10
+ directoryPath?: string;
11
+ parentId?: string;
12
+ }
13
+ export interface UseFilesResult {
14
+ files: FileResponse[];
15
+ nextPageToken: string | null;
16
+ isLoading: boolean;
17
+ isLoadingMore: boolean;
18
+ error: Error | null;
19
+ refresh: (overrides?: {
20
+ limit?: number;
21
+ pageToken?: string;
22
+ directoryPath?: string;
23
+ parentId?: string;
24
+ }) => Promise<FileResponse[]>;
25
+ loadMore: (overrides?: {
26
+ limit?: number;
27
+ }) => Promise<FileResponse[]>;
28
+ }
29
+ export declare function useFiles({ client, podId, enabled, autoLoad, limit, pageToken, directoryPath, parentId, }: UseFilesOptions): UseFilesResult;
@@ -0,0 +1,90 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useFiles({ client, podId, enabled = true, autoLoad = true, limit = 100, pageToken, directoryPath = "/", parentId, }) {
4
+ const [files, setFiles] = useState([]);
5
+ const [nextPageToken, setNextPageToken] = useState(null);
6
+ const [isLoading, setIsLoading] = useState(false);
7
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
8
+ const [error, setError] = useState(null);
9
+ const refresh = useCallback(async (overrides = {}, signal) => {
10
+ if (!enabled)
11
+ return [];
12
+ setIsLoading(true);
13
+ setError(null);
14
+ try {
15
+ const scopedClient = resolvePodClient(client, podId);
16
+ const response = await scopedClient.files.list({
17
+ limit: overrides.limit ?? limit,
18
+ pageToken: overrides.pageToken ?? pageToken,
19
+ directoryPath: overrides.directoryPath ?? directoryPath,
20
+ parentId: overrides.parentId ?? parentId,
21
+ });
22
+ if (signal?.aborted)
23
+ return [];
24
+ const nextFiles = response.items ?? [];
25
+ setFiles(nextFiles);
26
+ setNextPageToken(response.next_page_token ?? null);
27
+ return nextFiles;
28
+ }
29
+ catch (refreshError) {
30
+ if (signal?.aborted)
31
+ return [];
32
+ setError(normalizeError(refreshError, "Failed to load files."));
33
+ return [];
34
+ }
35
+ finally {
36
+ if (!signal?.aborted)
37
+ setIsLoading(false);
38
+ }
39
+ }, [client, directoryPath, enabled, limit, pageToken, parentId, podId]);
40
+ const loadMore = useCallback(async (overrides = {}) => {
41
+ if (!enabled || !nextPageToken || isLoading || isLoadingMore)
42
+ return [];
43
+ setIsLoadingMore(true);
44
+ setError(null);
45
+ try {
46
+ const scopedClient = resolvePodClient(client, podId);
47
+ const response = await scopedClient.files.list({
48
+ limit: overrides.limit ?? limit,
49
+ pageToken: nextPageToken,
50
+ directoryPath,
51
+ parentId,
52
+ });
53
+ const moreFiles = response.items ?? [];
54
+ setFiles((previous) => [...previous, ...moreFiles]);
55
+ setNextPageToken(response.next_page_token ?? null);
56
+ return moreFiles;
57
+ }
58
+ catch (loadError) {
59
+ setError(normalizeError(loadError, "Failed to load more files."));
60
+ return [];
61
+ }
62
+ finally {
63
+ setIsLoadingMore(false);
64
+ }
65
+ }, [client, directoryPath, enabled, isLoading, isLoadingMore, limit, nextPageToken, parentId, podId]);
66
+ useEffect(() => {
67
+ if (!enabled) {
68
+ setFiles([]);
69
+ setNextPageToken(null);
70
+ setError(null);
71
+ setIsLoading(false);
72
+ setIsLoadingMore(false);
73
+ return;
74
+ }
75
+ if (!autoLoad)
76
+ return;
77
+ const controller = new AbortController();
78
+ void refresh({}, controller.signal);
79
+ return () => controller.abort();
80
+ }, [autoLoad, enabled, refresh]);
81
+ return useMemo(() => ({
82
+ files,
83
+ nextPageToken,
84
+ isLoading,
85
+ isLoadingMore,
86
+ error,
87
+ refresh,
88
+ loadMore,
89
+ }), [error, files, isLoading, isLoadingMore, loadMore, nextPageToken, refresh]);
90
+ }
@@ -1,6 +1,24 @@
1
1
  import type { LemmaClient } from "../client.js";
2
2
  import { type ForeignKeyReference } from "../datastore-query.js";
3
3
  import type { ColumnSchema, Table } from "../types.js";
4
+ /**
5
+ * React hook that resolves a foreign-key column into dropdown options.
6
+ * Reads the FK metadata from the table schema, fetches records from the
7
+ * referenced table, and returns `{ value, label, record }` options ready
8
+ * for `<Select>` components.
9
+ *
10
+ * Auto-detects the best label field (name > title > label > email > slug).
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * const { options, isLoading } = useForeignKeyOptions({
15
+ * client,
16
+ * tableName: "issues",
17
+ * columnName: "team_id",
18
+ * });
19
+ * // options = [{ value: "team_1", label: "Engineering", record: {...} }, ...]
20
+ * ```
21
+ */
4
22
  export interface ForeignKeyOption {
5
23
  value: unknown;
6
24
  label: string;
@@ -1,5 +1,22 @@
1
1
  import type { FunctionRun } from "../types.js";
2
2
  import { type UseFunctionSessionOptions, type UseFunctionSessionResult } from "./useFunctionSession.js";
3
+ /**
4
+ * React hook for running a pod function and tracking its output.
5
+ * Wraps `useFunctionSession` with a simpler API — call `start(input)`
6
+ * from an event handler, read `output` / `finalOutput`.
7
+ *
8
+ * @example Run a function on button click
9
+ * ```tsx
10
+ * const { start, output, isFinished, isPolling } = useFunctionRun({
11
+ * client,
12
+ * functionName: "update-issue-status",
13
+ * });
14
+ *
15
+ * <button onClick={() => start({ issue_id: "123", status: "closed" })}>
16
+ * Close issue
17
+ * </button>
18
+ * ```
19
+ */
3
20
  export interface UseFunctionRunOptions extends UseFunctionSessionOptions {
4
21
  }
5
22
  export interface UseFunctionRunResult extends Omit<UseFunctionSessionResult, "start" | "listHistory"> {
@@ -1,9 +1,64 @@
1
1
  import type { LemmaClient } from "../client.js";
2
2
  import { type JoinedRecordsQueryDefinition } from "../datastore-query.js";
3
+ /**
4
+ * A simplified join descriptor. The hook auto-resolves the join condition
5
+ * from the foreign-key metadata on the base table's column named by `on`.
6
+ */
7
+ export interface JoinedRecordsShorthandJoin {
8
+ /** The table to join into (e.g. "teams"). */
9
+ table: string;
10
+ /**
11
+ * The FK column on the base table that references the join table
12
+ * (e.g. "team_id"). The hook reads the FK metadata on this column
13
+ * to determine the join condition automatically.
14
+ */
15
+ on: string;
16
+ /** Optional alias for the joined table. Defaults to the table name. */
17
+ alias?: string;
18
+ /** Join type. Defaults to "left". */
19
+ type?: "inner" | "left" | "left outer" | "right" | "right outer" | "full" | "full outer";
20
+ }
21
+ /**
22
+ * React hook for cross-table join queries. Supports two API styles:
23
+ *
24
+ * 1. **Full query** via the `query` option — for complex joins with custom
25
+ * select lists, filters, and expressions.
26
+ *
27
+ * 2. **Shorthand** via `baseTable` + `joins` — the hook auto-resolves the
28
+ * join conditions from the schema's foreign-key metadata, so you don't
29
+ * need to spell out the `on` condition manually.
30
+ *
31
+ * @example Full query (existing API)
32
+ * ```tsx
33
+ * const { records, isLoading } = useJoinedRecords({
34
+ * client,
35
+ * query: {
36
+ * from: "issues",
37
+ * joins: [{ table: "teams", on: { left: "issues.team_id", right: "teams.id" } }],
38
+ * },
39
+ * });
40
+ * ```
41
+ *
42
+ * @example Shorthand with FK auto-resolution
43
+ * ```tsx
44
+ * const { records, isLoading } = useJoinedRecords({
45
+ * client,
46
+ * baseTable: "issues",
47
+ * joins: [{ table: "teams", on: "team_id" }],
48
+ * });
49
+ * // The hook reads the FK metadata on issues.team_id to find that
50
+ * // it references teams.id, and builds the join automatically.
51
+ * ```
52
+ */
3
53
  export interface UseJoinedRecordsOptions {
4
54
  client: LemmaClient;
5
55
  podId?: string;
6
- query: JoinedRecordsQueryDefinition;
56
+ /** Full join query definition. Mutually exclusive with `baseTable` + `joins`. */
57
+ query?: JoinedRecordsQueryDefinition;
58
+ /** Base table for shorthand mode. Mutually exclusive with `query`. */
59
+ baseTable?: string;
60
+ /** Shorthand join descriptors. Auto-resolves join conditions from FK metadata. */
61
+ joins?: JoinedRecordsShorthandJoin[];
7
62
  enabled?: boolean;
8
63
  autoLoad?: boolean;
9
64
  }
@@ -15,4 +70,4 @@ export interface UseJoinedRecordsResult<TRecord extends Record<string, unknown>
15
70
  error: Error | null;
16
71
  refresh: () => Promise<TRecord[]>;
17
72
  }
18
- export declare function useJoinedRecords<TRecord extends Record<string, unknown> = Record<string, unknown>>({ client, podId, query, enabled, autoLoad, }: UseJoinedRecordsOptions): UseJoinedRecordsResult<TRecord>;
73
+ export declare function useJoinedRecords<TRecord extends Record<string, unknown> = Record<string, unknown>>({ client, podId, query, baseTable, joins: shorthandJoins, enabled, autoLoad, }: UseJoinedRecordsOptions): UseJoinedRecordsResult<TRecord>;
@@ -1,18 +1,51 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
- import { buildJoinedRecordsQuery } from "../datastore-query.js";
2
+ import { buildJoinedRecordsQuery, parseForeignKeyReference, } from "../datastore-query.js";
3
3
  import { normalizeError, resolvePodId, stringifyComparable } from "./utils.js";
4
- export function useJoinedRecords({ client, podId, query, enabled = true, autoLoad = true, }) {
4
+ async function buildShorthandQuery(client, podId, baseTableName, shorthandJoins) {
5
+ const resolvedPodId = resolvePodId(client, podId);
6
+ const scopedClient = resolvedPodId === client.podId ? client : client.withPod(resolvedPodId);
7
+ const baseTable = await scopedClient.tables.get(baseTableName.trim());
8
+ const joins = await Promise.all(shorthandJoins.map(async (entry) => {
9
+ const baseColumn = baseTable.columns.find((col) => col.name === entry.on) ?? null;
10
+ const reference = baseColumn?.foreign_key?.references
11
+ ? parseForeignKeyReference(baseColumn.foreign_key.references)
12
+ : null;
13
+ if (!baseColumn) {
14
+ throw new Error(`Column "${entry.on}" was not found on table "${baseTableName}".`);
15
+ }
16
+ if (!reference) {
17
+ throw new Error(`Column "${entry.on}" on "${baseTableName}" is not a foreign key. Use the full query API for non-FK joins.`);
18
+ }
19
+ return {
20
+ type: entry.type ?? "left",
21
+ table: entry.table,
22
+ alias: entry.alias,
23
+ on: {
24
+ left: { table: baseTableName, column: entry.on },
25
+ right: { table: entry.alias ?? entry.table, column: reference.column },
26
+ },
27
+ };
28
+ }));
29
+ return {
30
+ from: { table: baseTableName, alias: baseTableName },
31
+ joins,
32
+ };
33
+ }
34
+ export function useJoinedRecords({ client, podId, query, baseTable, joins: shorthandJoins, enabled = true, autoLoad = true, }) {
5
35
  const [records, setRecords] = useState([]);
6
36
  const [total, setTotal] = useState(0);
37
+ const [sql, setSql] = useState("");
7
38
  const [isLoading, setIsLoading] = useState(false);
8
39
  const [error, setError] = useState(null);
40
+ const hasShorthand = !query && !!baseTable && !!shorthandJoins?.length;
9
41
  const queryKey = stringifyComparable(query);
42
+ const shorthandKey = stringifyComparable({ baseTable, shorthandJoins });
10
43
  const stableQuery = useMemo(() => query, [queryKey]);
11
- const sql = useMemo(() => buildJoinedRecordsQuery(stableQuery), [stableQuery]);
12
44
  const refresh = useCallback(async (signal) => {
13
45
  if (!enabled) {
14
46
  setRecords([]);
15
47
  setTotal(0);
48
+ setSql("");
16
49
  setError(null);
17
50
  setIsLoading(false);
18
51
  return [];
@@ -22,7 +55,22 @@ export function useJoinedRecords({ client, podId, query, enabled = true, autoLoa
22
55
  try {
23
56
  const resolvedPodId = resolvePodId(client, podId);
24
57
  const scopedClient = resolvedPodId === client.podId ? client : client.withPod(resolvedPodId);
25
- const response = await scopedClient.datastore.query(sql);
58
+ let resolvedQuery = stableQuery;
59
+ if (hasShorthand && baseTable && shorthandJoins) {
60
+ resolvedQuery = await buildShorthandQuery(client, podId, baseTable, shorthandJoins);
61
+ }
62
+ if (!resolvedQuery) {
63
+ setRecords([]);
64
+ setTotal(0);
65
+ setSql("");
66
+ setIsLoading(false);
67
+ return [];
68
+ }
69
+ if (signal?.aborted)
70
+ return [];
71
+ const nextSql = buildJoinedRecordsQuery(resolvedQuery);
72
+ setSql(nextSql);
73
+ const response = await scopedClient.datastore.query(nextSql);
26
74
  if (signal?.aborted)
27
75
  return [];
28
76
  const nextRecords = (response.items ?? []);
@@ -41,11 +89,12 @@ export function useJoinedRecords({ client, podId, query, enabled = true, autoLoa
41
89
  if (!signal?.aborted)
42
90
  setIsLoading(false);
43
91
  }
44
- }, [client, enabled, podId, sql]);
92
+ }, [client, enabled, hasShorthand, baseTable, podId, shorthandJoins, stableQuery]);
45
93
  useEffect(() => {
46
94
  if (!enabled) {
47
95
  setRecords([]);
48
96
  setTotal(0);
97
+ setSql("");
49
98
  setError(null);
50
99
  setIsLoading(false);
51
100
  return;
@@ -1,4 +1,20 @@
1
1
  import type { LemmaClient } from "../client.js";
2
+ /**
3
+ * React hook for fetching a single record by ID. Unwraps `.data`
4
+ * automatically so `record` is the plain object, not the API envelope.
5
+ *
6
+ * Perfect for detail panels — pair with `useRecords` for the list.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * const { record, isLoading } = useRecord({
11
+ * client,
12
+ * tableName: "issues",
13
+ * recordId: selectedId,
14
+ * });
15
+ * // record is the issue object directly (or null)
16
+ * ```
17
+ */
2
18
  export interface UseRecordOptions {
3
19
  client: LemmaClient;
4
20
  podId?: string;
@@ -1,7 +1,36 @@
1
1
  import type { LemmaClient } from "../client.js";
2
2
  import { type RecordSchemaField } from "../record-form.js";
3
- import type { RecordResponse } from "../types.js";
3
+ import type { FunctionRun, RecordResponse } from "../types.js";
4
4
  import { type UseRecordSchemaResult } from "./useRecordSchema.js";
5
+ /**
6
+ * React hook for schema-driven record forms with validation, dirty tracking,
7
+ * and create/update auto-detection.
8
+ *
9
+ * Supports two submit modes:
10
+ * - `"direct"` (default): persists via `records.create` / `records.update`.
11
+ * - `"function"`: persists via `functions.runs.create`, routing the form
12
+ * payload through a pod function that may enforce business logic
13
+ * (e.g. auto-generating identifiers, logging history).
14
+ *
15
+ * @example Direct create/update form
16
+ * ```tsx
17
+ * const form = useRecordForm({ client, tableName: "issues" });
18
+ * // form.fields → all schema fields, form.editableFields → user-fillable fields
19
+ * await form.submit(); // calls records.create or records.update
20
+ * ```
21
+ *
22
+ * @example Function-backed form
23
+ * ```tsx
24
+ * const form = useRecordForm({
25
+ * client,
26
+ * tableName: "issues",
27
+ * submitVia: "function",
28
+ * submitFunctionName: "create-issue",
29
+ * hiddenFields: ["identifier", "created_at"],
30
+ * });
31
+ * await form.submit(); // calls functions.runs.create("create-issue", { input: payload })
32
+ * ```
33
+ */
5
34
  export interface UseRecordFormOptions {
6
35
  client: LemmaClient;
7
36
  podId?: string;
@@ -11,7 +40,17 @@ export interface UseRecordFormOptions {
11
40
  mode?: "auto" | "create" | "update";
12
41
  enabled?: boolean;
13
42
  autoLoad?: boolean;
14
- onSubmitSuccess?: (record: Record<string, unknown>, response: RecordResponse) => void;
43
+ /** Field names to include in `fields` and `editableFields`. Takes precedence over `hiddenFields`. */
44
+ visibleFields?: string[];
45
+ /** Field names to exclude from `fields` and `editableFields`. Ignored for any field also listed in `visibleFields`. */
46
+ hiddenFields?: string[];
47
+ /** How the form persists data. `"direct"` calls `records.create`/`records.update`. `"function"` calls `functions.runs.create`. */
48
+ submitVia?: "direct" | "function";
49
+ /** Function name to run when `submitVia` is `"function"`. Required when `submitVia` is `"function"`. */
50
+ submitFunctionName?: string;
51
+ /** Transforms the form payload before passing it as function input. Receives the validated payload, returns the function input object. */
52
+ submitFunctionInput?: (payload: Record<string, unknown>) => Record<string, unknown>;
53
+ onSubmitSuccess?: (record: Record<string, unknown>, response: RecordResponse | FunctionRun) => void;
15
54
  onError?: (error: unknown) => void;
16
55
  }
17
56
  export interface UseRecordFormResult {
@@ -39,4 +78,4 @@ export interface UseRecordFormResult {
39
78
  mode?: "create" | "update";
40
79
  }) => Promise<Record<string, unknown> | null>;
41
80
  }
42
- export declare function useRecordForm({ client, podId, tableName, recordId, initialValues, mode, enabled, autoLoad, onSubmitSuccess, onError, }: UseRecordFormOptions): UseRecordFormResult;
81
+ export declare function useRecordForm({ client, podId, tableName, recordId, initialValues, mode, enabled, autoLoad, visibleFields, hiddenFields, submitVia, submitFunctionName, submitFunctionInput, onSubmitSuccess, onError, }: UseRecordFormOptions): UseRecordFormResult;