lemma-sdk 0.2.31 → 0.2.32

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/README.md CHANGED
@@ -74,9 +74,9 @@ import {
74
74
  | Area | Hooks | Stability | Use when |
75
75
  | --- | --- | --- | --- |
76
76
  | Auth | `AuthGuard`, `useAuth`, `useCurrentUser`, `usePodAccess` | Stable | Gate an app, read signed-in user state, or request pod access. |
77
- | Tables | `useTables`, `useTable`, `useRecords`, `useRecord`, `useJoinedRecords`, `useRelatedRecords`, `useReverseRelatedRecords` | Stable | Build custom table browsers, details views, related-record views, and relational reads. |
78
- | Record mutations | `useCreateRecord`, `useUpdateRecord`, `useDeleteRecord`, `useBulkRecords` | Stable | Create, update, delete, or bulk-delete rows from headless UI. |
79
- | Record forms | `useRecordSchema`, `useRecordForm`, `useForeignKeyOptions`, `useSchemaForm` | Stable | Render schema-driven forms, enum fields, and foreign-key selectors. |
77
+ | Tables | `useTables`, `useTable`, `useRecords`, `useRecord`, `useJoinedRecords`, `useRelatedRecords`, `useReverseRelatedRecords`, `useReferencingRecords` | Stable | Build custom table browsers, details views, related-record views, and relational reads. |
78
+ | Record mutations | `useCreateRecord`, `useUpdateRecord`, `useDeleteRecord`, `useBulkRecords` | Stable | Create, update, delete, or bulk-delete rows from headless UI. Function-backed mutations via `createVia`/`updateVia` options. |
79
+ | Record forms | `useRecordSchema`, `useRecordForm`, `useForeignKeyOptions`, `useSchemaForm` | Stable | Render schema-driven forms, enum fields, and foreign-key selectors. `useRecordForm` supports `submitVia: "function"`, `visibleFields`, `hiddenFields`. |
80
80
  | Assistant | `useConversations`, `useConversation`, `useConversationMessages`, `useAssistantRun`, `useAssistantSession`, `useAssistantRuntime`, `useAssistantController` | Stable except controller/runtime | Build custom chat, conversation lists, streaming output, and final-output views. |
81
81
  | Agents | `useAgentRun`, `useAgentRuns`, `useAgentInputSchema`, `useTaskSession` | Stable except raw session | Start agent tasks, submit follow-up input, read task history, and inspect input/output schemas. |
82
82
  | Workflows | `useWorkflowStart`, `useWorkflowRun`, `useWorkflowRuns`, `useWorkflowResume` | Stable | Start, poll, resume, cancel, retry, and inspect workflow runs. |
@@ -86,6 +86,8 @@ import {
86
86
 
87
87
  ### Common Hook Shapes
88
88
 
89
+ For business-facing examples and a decision guide mapping "I want to..." to the right hook, see [docs/hooks-guide.md](docs/hooks-guide.md).
90
+
89
91
  List hooks generally expose:
90
92
 
91
93
  - `items` named for the resource, such as `records`, `runs`, or `members`
@@ -45,11 +45,13 @@ export type { UseDeleteRecordOptions, UseDeleteRecordResult } from "./useDeleteR
45
45
  export { useBulkRecords } from "./useBulkRecords.js";
46
46
  export type { UseBulkRecordsOptions, UseBulkRecordsResult } from "./useBulkRecords.js";
47
47
  export { useJoinedRecords } from "./useJoinedRecords.js";
48
- export type { UseJoinedRecordsOptions, UseJoinedRecordsResult } from "./useJoinedRecords.js";
48
+ export type { JoinedRecordsShorthandJoin, UseJoinedRecordsOptions, UseJoinedRecordsResult, } from "./useJoinedRecords.js";
49
49
  export { useRelatedRecords } from "./useRelatedRecords.js";
50
50
  export type { RelatedRecordsColumn, RelatedRecordsInclude, RelatedRecordsResolvedInclude, UseRelatedRecordsOptions, UseRelatedRecordsResult, } from "./useRelatedRecords.js";
51
51
  export { useReverseRelatedRecords } from "./useReverseRelatedRecords.js";
52
52
  export type { ReverseRelatedRecordsColumn, ReverseRelatedRelation, ReverseRelationSelector, UseReverseRelatedRecordsOptions, UseReverseRelatedRecordsResult, } from "./useReverseRelatedRecords.js";
53
+ export { useReferencingRecords } from "./useReferencingRecords.js";
54
+ export type { ReferencingRecordsColumn, UseReferencingRecordsOptions, UseReferencingRecordsResult, } from "./useReferencingRecords.js";
53
55
  export { useForeignKeyOptions } from "./useForeignKeyOptions.js";
54
56
  export type { ForeignKeyOption, UseForeignKeyOptionsOptions, UseForeignKeyOptionsResult, } from "./useForeignKeyOptions.js";
55
57
  export { useRecordSchema } from "./useRecordSchema.js";
@@ -24,6 +24,7 @@ export { useBulkRecords } from "./useBulkRecords.js";
24
24
  export { useJoinedRecords } from "./useJoinedRecords.js";
25
25
  export { useRelatedRecords } from "./useRelatedRecords.js";
26
26
  export { useReverseRelatedRecords } from "./useReverseRelatedRecords.js";
27
+ export { useReferencingRecords } from "./useReferencingRecords.js";
27
28
  export { useForeignKeyOptions } from "./useForeignKeyOptions.js";
28
29
  export { useRecordSchema } from "./useRecordSchema.js";
29
30
  export { useRecordForm } from "./useRecordForm.js";
@@ -1,11 +1,41 @@
1
1
  import type { LemmaClient } from "../client.js";
2
- import type { RecordResponse } from "../types.js";
2
+ import type { FunctionRun, RecordResponse } from "../types.js";
3
+ /**
4
+ * React hook for creating a single record. Manages loading/error state and
5
+ * exposes a `create` function you can call from event handlers.
6
+ *
7
+ * Supports two modes:
8
+ * - `"direct"` (default): calls `records.create` directly.
9
+ * - `"function"`: calls `functions.runs.create`, routing the create through
10
+ * a pod function that may enforce business logic.
11
+ *
12
+ * @example Direct create
13
+ * ```tsx
14
+ * const { create, isSubmitting } = useCreateRecord({ client, tableName: "comments" });
15
+ * await create({ body: "Hello", issue_id: "123" });
16
+ * ```
17
+ *
18
+ * @example Function-backed create
19
+ * ```tsx
20
+ * const { create, isSubmitting } = useCreateRecord({
21
+ * client,
22
+ * tableName: "issues",
23
+ * createVia: "function",
24
+ * createFunctionName: "create-issue",
25
+ * });
26
+ * await create({ title: "Bug", team_id: "team_1" });
27
+ * ```
28
+ */
3
29
  export interface UseCreateRecordOptions {
4
30
  client: LemmaClient;
5
31
  podId?: string;
6
32
  tableName: string;
7
33
  enabled?: boolean;
8
- onSuccess?: (record: Record<string, unknown>, response: RecordResponse) => void;
34
+ /** How the record is created. `"direct"` calls `records.create`. `"function"` calls `functions.runs.create`. */
35
+ createVia?: "direct" | "function";
36
+ /** Function name to run when `createVia` is `"function"`. Falls back to `tableName` if omitted. */
37
+ createFunctionName?: string;
38
+ onSuccess?: (record: Record<string, unknown>, response: RecordResponse | FunctionRun) => void;
9
39
  onError?: (error: unknown) => void;
10
40
  }
11
41
  export interface UseCreateRecordResult<TRecord extends Record<string, unknown> = Record<string, unknown>> {
@@ -15,4 +45,4 @@ export interface UseCreateRecordResult<TRecord extends Record<string, unknown> =
15
45
  create: (data: Record<string, unknown>) => Promise<TRecord | null>;
16
46
  reset: () => void;
17
47
  }
18
- export declare function useCreateRecord<TRecord extends Record<string, unknown> = Record<string, unknown>>({ client, podId, tableName, enabled, onSuccess, onError, }: UseCreateRecordOptions): UseCreateRecordResult<TRecord>;
48
+ export declare function useCreateRecord<TRecord extends Record<string, unknown> = Record<string, unknown>>({ client, podId, tableName, enabled, createVia, createFunctionName, onSuccess, onError, }: UseCreateRecordOptions): UseCreateRecordResult<TRecord>;
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { normalizeError, resolvePodClient } from "./utils.js";
3
- export function useCreateRecord({ client, podId, tableName, enabled = true, onSuccess, onError, }) {
3
+ export function useCreateRecord({ client, podId, tableName, enabled = true, createVia = "direct", createFunctionName, onSuccess, onError, }) {
4
4
  const [createdRecord, setCreatedRecord] = useState(null);
5
5
  const [isSubmitting, setIsSubmitting] = useState(false);
6
6
  const [error, setError] = useState(null);
@@ -18,6 +18,16 @@ export function useCreateRecord({ client, podId, tableName, enabled = true, onSu
18
18
  setError(null);
19
19
  try {
20
20
  const scopedClient = resolvePodClient(client, podId);
21
+ if (createVia === "function") {
22
+ const functionName = createFunctionName ?? trimmedTableName;
23
+ const run = await scopedClient.functions.runs.create(functionName, { input: data });
24
+ const nextRecord = (run.output_data ?? { id: run.id, ...data });
25
+ setCreatedRecord(nextRecord);
26
+ if (nextRecord) {
27
+ onSuccessRef.current?.(nextRecord, run);
28
+ }
29
+ return nextRecord;
30
+ }
21
31
  const response = await scopedClient.records.create(trimmedTableName, data);
22
32
  const nextRecord = (response.data ?? null);
23
33
  setCreatedRecord(nextRecord);
@@ -35,7 +45,7 @@ export function useCreateRecord({ client, podId, tableName, enabled = true, onSu
35
45
  finally {
36
46
  setIsSubmitting(false);
37
47
  }
38
- }, [client, isEnabled, podId, trimmedTableName]);
48
+ }, [client, createFunctionName, createVia, isEnabled, podId, trimmedTableName]);
39
49
  const reset = useCallback(() => {
40
50
  setCreatedRecord(null);
41
51
  setError(null);
@@ -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;
@@ -3,7 +3,7 @@ import { buildRecordFormValues, buildRecordPayload, } from "../record-form.js";
3
3
  import { normalizeError, resolvePodClient, stringifyComparable } from "./utils.js";
4
4
  import { useRecordSchema, } from "./useRecordSchema.js";
5
5
  const EMPTY_VALUES = {};
6
- export function useRecordForm({ client, podId, tableName, recordId = null, initialValues = EMPTY_VALUES, mode = "auto", enabled = true, autoLoad = true, onSubmitSuccess, onError, }) {
6
+ export function useRecordForm({ client, podId, tableName, recordId = null, initialValues = EMPTY_VALUES, mode = "auto", enabled = true, autoLoad = true, visibleFields, hiddenFields, submitVia = "direct", submitFunctionName, submitFunctionInput, onSubmitSuccess, onError, }) {
7
7
  const schema = useRecordSchema({
8
8
  client,
9
9
  podId,
@@ -11,6 +11,30 @@ export function useRecordForm({ client, podId, tableName, recordId = null, initi
11
11
  enabled,
12
12
  autoLoad,
13
13
  });
14
+ const visibleSet = useMemo(() => visibleFields ? new Set(visibleFields) : null, [visibleFields]);
15
+ const hiddenSet = useMemo(() => hiddenFields ? new Set(hiddenFields) : null, [hiddenFields]);
16
+ const filteredFields = useMemo(() => {
17
+ if (!visibleSet && !hiddenSet)
18
+ return schema.fields;
19
+ return schema.fields.filter((field) => {
20
+ if (visibleSet)
21
+ return visibleSet.has(field.name);
22
+ if (hiddenSet)
23
+ return !hiddenSet.has(field.name);
24
+ return true;
25
+ });
26
+ }, [hiddenSet, schema.fields, visibleSet]);
27
+ const filteredEditableFields = useMemo(() => {
28
+ if (!visibleSet && !hiddenSet)
29
+ return schema.editableFields;
30
+ return schema.editableFields.filter((field) => {
31
+ if (visibleSet)
32
+ return visibleSet.has(field.name);
33
+ if (hiddenSet)
34
+ return !hiddenSet.has(field.name);
35
+ return true;
36
+ });
37
+ }, [hiddenSet, schema.editableFields, visibleSet]);
14
38
  const [record, setRecord] = useState(null);
15
39
  const [values, setValuesState] = useState({});
16
40
  const [baselineValues, setBaselineValues] = useState({});
@@ -147,6 +171,19 @@ export function useRecordForm({ client, podId, tableName, recordId = null, initi
147
171
  setRecordError(null);
148
172
  try {
149
173
  const scopedClient = resolvePodClient(client, podId);
174
+ if (submitVia === "function") {
175
+ const functionName = submitFunctionName ?? tableName;
176
+ const functionInput = submitFunctionInput ? submitFunctionInput(payload.data) : payload.data;
177
+ const run = await scopedClient.functions.runs.create(functionName, { input: functionInput });
178
+ const nextRecord = run.output_data ?? { id: run.id, ...functionInput };
179
+ setRecord(nextRecord);
180
+ hydrateValues({
181
+ ...(nextRecord ?? {}),
182
+ ...stableInitialValues,
183
+ });
184
+ onSubmitSuccess?.(nextRecord ?? {}, run);
185
+ return nextRecord;
186
+ }
150
187
  const response = resolvedMode === "update" && recordId
151
188
  ? await scopedClient.records.update(tableName, recordId, payload.data)
152
189
  : await scopedClient.records.create(tableName, payload.data);
@@ -168,14 +205,14 @@ export function useRecordForm({ client, podId, tableName, recordId = null, initi
168
205
  finally {
169
206
  setIsSubmitting(false);
170
207
  }
171
- }, [client, hydrateValues, mode, onError, onSubmitSuccess, podId, recordId, schemaTable, stableInitialValues, tableName, values]);
208
+ }, [client, hydrateValues, mode, onError, onSubmitSuccess, podId, recordId, schemaTable, stableInitialValues, submitFunctionInput, submitFunctionName, submitVia, tableName, values]);
172
209
  const isDirty = useMemo(() => {
173
210
  return stringifyComparable(values) !== stringifyComparable(baselineValues);
174
211
  }, [baselineValues, values]);
175
212
  return useMemo(() => ({
176
213
  table: schema.table,
177
- fields: schema.fields,
178
- editableFields: schema.editableFields,
214
+ fields: filteredFields,
215
+ editableFields: filteredEditableFields,
179
216
  defaults: schema.defaults,
180
217
  values,
181
218
  baselineValues,
@@ -197,6 +234,8 @@ export function useRecordForm({ client, podId, tableName, recordId = null, initi
197
234
  }), [
198
235
  baselineValues,
199
236
  fieldErrors,
237
+ filteredEditableFields,
238
+ filteredFields,
200
239
  isDirty,
201
240
  isLoadingRecord,
202
241
  isSubmitting,
@@ -206,9 +245,7 @@ export function useRecordForm({ client, podId, tableName, recordId = null, initi
206
245
  refreshRecord,
207
246
  reset,
208
247
  schema.defaults,
209
- schema.editableFields,
210
248
  schema.error,
211
- schema.fields,
212
249
  schema.isLoading,
213
250
  schema.refresh,
214
251
  schema.table,
@@ -0,0 +1,66 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { Table } from "../types.js";
3
+ /**
4
+ * React hook for fetching records from a referencing table that point back
5
+ * to a specific record via a foreign key.
6
+ *
7
+ * Unlike `useReverseRelatedRecords` which starts from a parent table and
8
+ * discovers what references it, this hook starts from the child table and
9
+ * says "give me all rows in this table where this FK column equals this ID."
10
+ *
11
+ * @example Fetch all comments for an issue
12
+ * ```tsx
13
+ * const { records, isLoading } = useReferencingRecords({
14
+ * client,
15
+ * table: "comments",
16
+ * foreignKey: "issue_id",
17
+ * recordId: "issue_123",
18
+ * });
19
+ * ```
20
+ *
21
+ * @example Fetch history entries
22
+ * ```tsx
23
+ * const { records, columns, isLoading } = useReferencingRecords({
24
+ * client,
25
+ * table: "issue_history",
26
+ * foreignKey: "issue_id",
27
+ * recordId: selectedIssueId,
28
+ * sortBy: "created_at",
29
+ * order: "desc",
30
+ * });
31
+ * ```
32
+ */
33
+ export interface ReferencingRecordsColumn {
34
+ key: string;
35
+ field: string;
36
+ label: string;
37
+ }
38
+ export interface UseReferencingRecordsOptions {
39
+ client: LemmaClient;
40
+ podId?: string;
41
+ /** The referencing (child) table, e.g. "comments". */
42
+ table: string;
43
+ /** The foreign-key column in the referencing table, e.g. "issue_id". */
44
+ foreignKey: string;
45
+ /** The record ID value to match against the foreign-key column. */
46
+ recordId?: string | null;
47
+ /** Fields to select. Auto-resolved from the table schema if omitted. */
48
+ fields?: string[];
49
+ limit?: number;
50
+ offset?: number;
51
+ sortBy?: string;
52
+ order?: "asc" | "desc" | string;
53
+ enabled?: boolean;
54
+ autoLoad?: boolean;
55
+ }
56
+ export interface UseReferencingRecordsResult<TRow extends Record<string, unknown> = Record<string, unknown>> {
57
+ referencedTable: Table | null;
58
+ columns: ReferencingRecordsColumn[];
59
+ records: TRow[];
60
+ total: number;
61
+ nextPageToken: string | null;
62
+ isLoading: boolean;
63
+ error: Error | null;
64
+ refresh: () => Promise<TRow[]>;
65
+ }
66
+ export declare function useReferencingRecords<TRow extends Record<string, unknown> = Record<string, unknown>>({ client, podId, table, foreignKey, recordId, fields, limit, offset, sortBy, order, enabled, autoLoad, }: UseReferencingRecordsOptions): UseReferencingRecordsResult<TRow>;
@@ -0,0 +1,159 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { normalizeError, resolvePodClient, stringifyComparable } from "./utils.js";
3
+ function sentenceCase(value) {
4
+ return value
5
+ .replace(/[_\.]/g, " ")
6
+ .replace(/\s+/g, " ")
7
+ .trim()
8
+ .replace(/\b\w/g, (match) => match.toUpperCase());
9
+ }
10
+ function pickDefaultFields(table, foreignKey) {
11
+ const names = table.columns
12
+ .map((column) => column.name)
13
+ .filter((name) => name !== "created_at" && name !== "updated_at");
14
+ const prioritized = ["id", "name", "title", "label", "status", foreignKey];
15
+ const next = [];
16
+ prioritized.forEach((name) => {
17
+ if (names.includes(name) && !next.includes(name)) {
18
+ next.push(name);
19
+ }
20
+ });
21
+ names.forEach((name) => {
22
+ if (!next.includes(name)) {
23
+ next.push(name);
24
+ }
25
+ });
26
+ return next.slice(0, 6);
27
+ }
28
+ export function useReferencingRecords({ client, podId, table, foreignKey, recordId = null, fields, limit = 20, offset, sortBy, order, enabled = true, autoLoad = true, }) {
29
+ const [referencedTable, setReferencedTable] = useState(null);
30
+ const [columns, setColumns] = useState([]);
31
+ const [records, setRecords] = useState([]);
32
+ const [total, setTotal] = useState(0);
33
+ const [nextPageToken, setNextPageToken] = useState(null);
34
+ const [isLoading, setIsLoading] = useState(false);
35
+ const [error, setError] = useState(null);
36
+ const trimmedTable = table.trim();
37
+ const trimmedRecordId = typeof recordId === "string" ? recordId.trim() : "";
38
+ const fieldsKey = stringifyComparable(fields);
39
+ const stableFields = useMemo(() => fields, [fieldsKey]);
40
+ const isEnabled = enabled && trimmedTable.length > 0 && trimmedRecordId.length > 0;
41
+ const refresh = useCallback(async (signal) => {
42
+ if (!isEnabled) {
43
+ setReferencedTable(null);
44
+ setColumns([]);
45
+ setRecords([]);
46
+ setTotal(0);
47
+ setNextPageToken(null);
48
+ setError(null);
49
+ setIsLoading(false);
50
+ return [];
51
+ }
52
+ setIsLoading(true);
53
+ setError(null);
54
+ try {
55
+ const scopedClient = resolvePodClient(client, podId);
56
+ const tableResponse = await scopedClient.tables.get(trimmedTable);
57
+ if (signal?.aborted)
58
+ return [];
59
+ setReferencedTable(tableResponse);
60
+ const resolvedFields = (stableFields?.length ? stableFields : pickDefaultFields(tableResponse, foreignKey))
61
+ .filter((field, index, allFields) => field.trim().length > 0 && allFields.indexOf(field) === index);
62
+ const response = await scopedClient.records.list(trimmedTable, {
63
+ filters: [{
64
+ field: foreignKey,
65
+ op: "eq",
66
+ value: trimmedRecordId,
67
+ }],
68
+ limit,
69
+ offset,
70
+ sortBy,
71
+ order,
72
+ });
73
+ if (signal?.aborted)
74
+ return [];
75
+ const nextRecords = (response.items ?? []);
76
+ setColumns(resolvedFields.map((field) => ({
77
+ key: field,
78
+ field,
79
+ label: sentenceCase(field),
80
+ })));
81
+ setRecords(nextRecords);
82
+ setTotal(response.total ?? nextRecords.length);
83
+ setNextPageToken(response.next_page_token ?? null);
84
+ return nextRecords;
85
+ }
86
+ catch (refreshError) {
87
+ if (signal?.aborted)
88
+ return [];
89
+ const normalized = normalizeError(refreshError, "Failed to load referencing records.");
90
+ setError(normalized);
91
+ return [];
92
+ }
93
+ finally {
94
+ if (!signal?.aborted)
95
+ setIsLoading(false);
96
+ }
97
+ }, [
98
+ client,
99
+ foreignKey,
100
+ isEnabled,
101
+ limit,
102
+ offset,
103
+ order,
104
+ podId,
105
+ sortBy,
106
+ stableFields,
107
+ trimmedRecordId,
108
+ trimmedTable,
109
+ ]);
110
+ useEffect(() => {
111
+ if (!isEnabled) {
112
+ setReferencedTable(null);
113
+ setColumns([]);
114
+ setRecords([]);
115
+ setTotal(0);
116
+ setNextPageToken(null);
117
+ setError(null);
118
+ setIsLoading(false);
119
+ return;
120
+ }
121
+ if (!autoLoad)
122
+ return;
123
+ const controller = new AbortController();
124
+ let cancelled = false;
125
+ (async () => {
126
+ try {
127
+ await refresh(controller.signal);
128
+ }
129
+ catch {
130
+ if (!cancelled) {
131
+ setError(normalizeError(new Error("Failed to load referencing records."), "Failed to load referencing records."));
132
+ }
133
+ }
134
+ })();
135
+ return () => {
136
+ cancelled = true;
137
+ controller.abort();
138
+ };
139
+ }, [autoLoad, isEnabled, refresh]);
140
+ return useMemo(() => ({
141
+ referencedTable,
142
+ columns,
143
+ records,
144
+ total,
145
+ nextPageToken,
146
+ isLoading,
147
+ error,
148
+ refresh,
149
+ }), [
150
+ columns,
151
+ error,
152
+ isLoading,
153
+ nextPageToken,
154
+ records,
155
+ refresh,
156
+ referencedTable,
157
+ total,
158
+ ]);
159
+ }
@@ -1,5 +1,22 @@
1
1
  import type { LemmaClient } from "../client.js";
2
2
  import type { Table } from "../types.js";
3
+ /**
4
+ * React hook for fetching base-table rows with their FK-related data
5
+ * joined in a single query. You specify which FK columns to include and
6
+ * the hook auto-resolves the referenced table and join columns.
7
+ *
8
+ * The result has nested objects: `{ id, name, team: { id, name } }`.
9
+ *
10
+ * @example Issues with their team
11
+ * ```tsx
12
+ * const { records, isLoading } = useRelatedRecords({
13
+ * client,
14
+ * tableName: "issues",
15
+ * include: [{ foreignKey: "team_id" }],
16
+ * });
17
+ * // records[0] = { id: "1", title: "Bug", team: { id: "t1", name: "Eng" } }
18
+ * ```
19
+ */
3
20
  export interface RelatedRecordsInclude {
4
21
  foreignKey: string;
5
22
  as?: string;
@@ -1,5 +1,26 @@
1
1
  import type { LemmaClient } from "../client.js";
2
2
  import type { Table } from "../types.js";
3
+ /**
4
+ * React hook for finding records in *other* tables that reference a given
5
+ * record. Starts from the parent table, discovers all tables with FK
6
+ * columns pointing back to it, and fetches the referencing rows.
7
+ *
8
+ * For the simpler case where you already know the referencing table and
9
+ * FK column, prefer `useReferencingRecords` — it has a more intuitive API.
10
+ *
11
+ * @example All comments and history entries for an issue
12
+ * ```tsx
13
+ * const { relations, records, isLoading } = useReverseRelatedRecords({
14
+ * client,
15
+ * tableName: "issues",
16
+ * recordId: "issue_123",
17
+ * relation: { tableName: "comments", foreignKey: "issue_id" },
18
+ * });
19
+ * ```
20
+ *
21
+ * @see useReferencingRecords — flipped-perspective alias for the common
22
+ * "show me all rows in table X where FK = Y" pattern.
23
+ */
3
24
  export interface ReverseRelationSelector {
4
25
  tableName: string;
5
26
  foreignKey: string;
@@ -1,12 +1,43 @@
1
1
  import type { LemmaClient } from "../client.js";
2
- import type { RecordResponse } from "../types.js";
2
+ import type { FunctionRun, RecordResponse } from "../types.js";
3
+ /**
4
+ * React hook for updating a single record. Manages loading/error state and
5
+ * exposes an `update` function you can call from event handlers.
6
+ *
7
+ * Supports two modes:
8
+ * - `"direct"` (default): calls `records.update` directly.
9
+ * - `"function"`: calls `functions.runs.create`, routing the update through
10
+ * a pod function (e.g. for status transitions that log history).
11
+ *
12
+ * @example Direct update
13
+ * ```tsx
14
+ * const { update, isSubmitting } = useUpdateRecord({ client, tableName: "issues", recordId: "123" });
15
+ * await update({ status: "closed" });
16
+ * ```
17
+ *
18
+ * @example Function-backed update
19
+ * ```tsx
20
+ * const { update, isSubmitting } = useUpdateRecord({
21
+ * client,
22
+ * tableName: "issues",
23
+ * recordId: "123",
24
+ * updateVia: "function",
25
+ * updateFunctionName: "update-issue-status",
26
+ * });
27
+ * await update({ status: "in_progress" });
28
+ * ```
29
+ */
3
30
  export interface UseUpdateRecordOptions {
4
31
  client: LemmaClient;
5
32
  podId?: string;
6
33
  tableName: string;
7
34
  recordId?: string | null;
8
35
  enabled?: boolean;
9
- onSuccess?: (record: Record<string, unknown>, response: RecordResponse) => void;
36
+ /** How the record is updated. `"direct"` calls `records.update`. `"function"` calls `functions.runs.create`. */
37
+ updateVia?: "direct" | "function";
38
+ /** Function name to run when `updateVia` is `"function"`. Falls back to `tableName` if omitted. */
39
+ updateFunctionName?: string;
40
+ onSuccess?: (record: Record<string, unknown>, response: RecordResponse | FunctionRun) => void;
10
41
  onError?: (error: unknown) => void;
11
42
  }
12
43
  export interface UseUpdateRecordResult<TRecord extends Record<string, unknown> = Record<string, unknown>> {
@@ -18,4 +49,4 @@ export interface UseUpdateRecordResult<TRecord extends Record<string, unknown> =
18
49
  }) => Promise<TRecord | null>;
19
50
  reset: () => void;
20
51
  }
21
- export declare function useUpdateRecord<TRecord extends Record<string, unknown> = Record<string, unknown>>({ client, podId, tableName, recordId, enabled, onSuccess, onError, }: UseUpdateRecordOptions): UseUpdateRecordResult<TRecord>;
52
+ export declare function useUpdateRecord<TRecord extends Record<string, unknown> = Record<string, unknown>>({ client, podId, tableName, recordId, enabled, updateVia, updateFunctionName, onSuccess, onError, }: UseUpdateRecordOptions): UseUpdateRecordResult<TRecord>;
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { normalizeError, resolvePodClient } from "./utils.js";
3
- export function useUpdateRecord({ client, podId, tableName, recordId = null, enabled = true, onSuccess, onError, }) {
3
+ export function useUpdateRecord({ client, podId, tableName, recordId = null, enabled = true, updateVia = "direct", updateFunctionName, onSuccess, onError, }) {
4
4
  const [updatedRecord, setUpdatedRecord] = useState(null);
5
5
  const [isSubmitting, setIsSubmitting] = useState(false);
6
6
  const [error, setError] = useState(null);
@@ -22,6 +22,17 @@ export function useUpdateRecord({ client, podId, tableName, recordId = null, ena
22
22
  setError(null);
23
23
  try {
24
24
  const scopedClient = resolvePodClient(client, podId);
25
+ if (updateVia === "function") {
26
+ const functionName = updateFunctionName ?? trimmedTableName;
27
+ const input = { ...data, id: nextRecordId, record_id: nextRecordId };
28
+ const run = await scopedClient.functions.runs.create(functionName, { input });
29
+ const nextRecord = (run.output_data ?? { id: nextRecordId, ...data });
30
+ setUpdatedRecord(nextRecord);
31
+ if (nextRecord) {
32
+ onSuccessRef.current?.(nextRecord, run);
33
+ }
34
+ return nextRecord;
35
+ }
25
36
  const response = await scopedClient.records.update(trimmedTableName, nextRecordId, data);
26
37
  const nextRecord = (response.data ?? null);
27
38
  setUpdatedRecord(nextRecord);
@@ -39,7 +50,7 @@ export function useUpdateRecord({ client, podId, tableName, recordId = null, ena
39
50
  finally {
40
51
  setIsSubmitting(false);
41
52
  }
42
- }, [client, isEnabled, podId, trimmedRecordId, trimmedTableName]);
53
+ }, [client, isEnabled, podId, trimmedRecordId, trimmedTableName, updateFunctionName, updateVia]);
43
54
  const reset = useCallback(() => {
44
55
  setUpdatedRecord(null);
45
56
  setError(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemma-sdk",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
4
4
  "description": "Official TypeScript SDK for Lemma pod-scoped APIs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",