@geniee-inc/sfa-kit 1.6.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.
Files changed (66) hide show
  1. package/GUIDE.md +585 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +31 -0
  4. package/package.json +36 -0
  5. package/src/index.d.ts +29 -0
  6. package/src/index.js +21 -0
  7. package/src/lib/auth/DevToolbar.d.ts +38 -0
  8. package/src/lib/auth/DevToolbar.js +271 -0
  9. package/src/lib/auth/SfaLoginGate.d.ts +53 -0
  10. package/src/lib/auth/SfaLoginGate.js +462 -0
  11. package/src/lib/auth/StandalonePreviewContext.d.ts +27 -0
  12. package/src/lib/auth/StandalonePreviewContext.js +14 -0
  13. package/src/lib/auth/embed-protocol.d.ts +40 -0
  14. package/src/lib/auth/embed-protocol.js +56 -0
  15. package/src/lib/auth/exchange-code.d.ts +14 -0
  16. package/src/lib/auth/exchange-code.js +45 -0
  17. package/src/lib/auth/keystone-auth.d.ts +16 -0
  18. package/src/lib/auth/keystone-auth.js +84 -0
  19. package/src/lib/auth/session-store.d.ts +19 -0
  20. package/src/lib/auth/session-store.js +73 -0
  21. package/src/lib/auth/types.d.ts +33 -0
  22. package/src/lib/auth/types.js +1 -0
  23. package/src/lib/auth/useEmbedContext.d.ts +22 -0
  24. package/src/lib/auth/useEmbedContext.js +31 -0
  25. package/src/lib/components/EmptyState.d.ts +18 -0
  26. package/src/lib/components/EmptyState.js +36 -0
  27. package/src/lib/components/ErrorBoundary.d.ts +31 -0
  28. package/src/lib/components/ErrorBoundary.js +100 -0
  29. package/src/lib/components/FieldRenderer.d.ts +11 -0
  30. package/src/lib/components/FieldRenderer.js +57 -0
  31. package/src/lib/components/RecordList.d.ts +20 -0
  32. package/src/lib/components/RecordList.js +139 -0
  33. package/src/lib/components/RecordView.d.ts +14 -0
  34. package/src/lib/components/RecordView.js +153 -0
  35. package/src/lib/errors.d.ts +15 -0
  36. package/src/lib/errors.js +15 -0
  37. package/src/lib/hooks/useCreateRecord.d.ts +17 -0
  38. package/src/lib/hooks/useCreateRecord.js +48 -0
  39. package/src/lib/hooks/useDeleteRecord.d.ts +16 -0
  40. package/src/lib/hooks/useDeleteRecord.js +48 -0
  41. package/src/lib/hooks/useDescribe.d.ts +16 -0
  42. package/src/lib/hooks/useDescribe.js +40 -0
  43. package/src/lib/hooks/useFieldTypes.d.ts +15 -0
  44. package/src/lib/hooks/useFieldTypes.js +39 -0
  45. package/src/lib/hooks/useObjectCatalog.d.ts +15 -0
  46. package/src/lib/hooks/useObjectCatalog.js +39 -0
  47. package/src/lib/hooks/useRecord.d.ts +15 -0
  48. package/src/lib/hooks/useRecord.js +49 -0
  49. package/src/lib/hooks/useRecordList.d.ts +19 -0
  50. package/src/lib/hooks/useRecordList.js +82 -0
  51. package/src/lib/hooks/useRelatedRecords.d.ts +65 -0
  52. package/src/lib/hooks/useRelatedRecords.js +227 -0
  53. package/src/lib/hooks/useUpdateRecord.d.ts +23 -0
  54. package/src/lib/hooks/useUpdateRecord.js +55 -0
  55. package/src/lib/provider/SfaClient.d.ts +98 -0
  56. package/src/lib/provider/SfaClient.js +688 -0
  57. package/src/lib/provider/SfaProvider.d.ts +38 -0
  58. package/src/lib/provider/SfaProvider.js +44 -0
  59. package/src/lib/provider/types.d.ts +184 -0
  60. package/src/lib/provider/types.js +1 -0
  61. package/src/lib/provider/useSfaClient.d.ts +11 -0
  62. package/src/lib/provider/useSfaClient.js +24 -0
  63. package/src/lib/theme.d.ts +55 -0
  64. package/src/lib/theme.js +68 -0
  65. package/src/lib/version.d.ts +1 -0
  66. package/src/lib/version.js +1 -0
@@ -0,0 +1,49 @@
1
+ import { __awaiter } from "tslib";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { ERR_EMPTY_RECORD_ID } from '../errors';
4
+ import { useSfaClient } from '../provider/useSfaClient';
5
+ /**
6
+ * @description Fetches a single record from the SFA/CRM API.
7
+ */
8
+ export function useRecord(collection, recordId, options) {
9
+ var _a;
10
+ const client = useSfaClient();
11
+ const enabled = (_a = options === null || options === void 0 ? void 0 : options.enabled) !== null && _a !== void 0 ? _a : true;
12
+ const [record, setRecord] = useState(null);
13
+ const [isLoading, setIsLoading] = useState(enabled && !!collection && !!recordId);
14
+ const [error, setError] = useState(null);
15
+ const fetchId = useRef(0);
16
+ const doFetch = useCallback(() => __awaiter(this, void 0, void 0, function* () {
17
+ if (!enabled || !collection)
18
+ return;
19
+ if (!recordId) {
20
+ fetchId.current += 1;
21
+ setRecord(null);
22
+ setIsLoading(false);
23
+ setError(new Error(ERR_EMPTY_RECORD_ID));
24
+ return;
25
+ }
26
+ fetchId.current += 1;
27
+ const currentFetchId = fetchId.current;
28
+ setIsLoading(true);
29
+ setError(null);
30
+ try {
31
+ const result = yield client.get(collection, recordId);
32
+ if (currentFetchId === fetchId.current) {
33
+ setRecord(result);
34
+ setIsLoading(false);
35
+ }
36
+ }
37
+ catch (e) {
38
+ if (currentFetchId === fetchId.current) {
39
+ setError(e instanceof Error ? e : new Error(String(e)));
40
+ setRecord(null);
41
+ setIsLoading(false);
42
+ }
43
+ }
44
+ }), [client, collection, recordId, enabled]);
45
+ useEffect(() => {
46
+ doFetch();
47
+ }, [doFetch]);
48
+ return { record, isLoading, error, refetch: doFetch };
49
+ }
@@ -0,0 +1,19 @@
1
+ import type { SfaListParams, SfaRecord } from '../provider/types';
2
+ interface UseRecordListOptions {
3
+ readonly enabled?: boolean;
4
+ }
5
+ interface UseRecordListResult {
6
+ readonly items: readonly SfaRecord[];
7
+ readonly totalCount: number;
8
+ readonly hasMore: boolean;
9
+ readonly pageIndex: number;
10
+ readonly isLoading: boolean;
11
+ readonly error: Error | null;
12
+ readonly refetch: () => void;
13
+ readonly goToPage: (page: number) => void;
14
+ }
15
+ /**
16
+ * @description Fetches a paginated list of records from the SFA/CRM API.
17
+ */
18
+ export declare function useRecordList(collection: string, params?: SfaListParams, options?: UseRecordListOptions): UseRecordListResult;
19
+ export {};
@@ -0,0 +1,82 @@
1
+ import { __awaiter } from "tslib";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useSfaClient } from '../provider/useSfaClient';
4
+ /**
5
+ * @description Fetches a paginated list of records from the SFA/CRM API.
6
+ */
7
+ export function useRecordList(collection, params, options) {
8
+ var _a, _b;
9
+ const client = useSfaClient();
10
+ const limit = (_a = params === null || params === void 0 ? void 0 : params.limit) !== null && _a !== void 0 ? _a : 20;
11
+ const [items, setItems] = useState([]);
12
+ const [totalCount, setTotalCount] = useState(0);
13
+ const [hasMore, setHasMore] = useState(false);
14
+ // offset is a record offset, not a page number: pageIndex = floor(offset / limit)
15
+ const [pageIndex, setPageIndex] = useState((params === null || params === void 0 ? void 0 : params.offset) != null && limit > 0 ? Math.floor(params.offset / limit) : 0);
16
+ const enabled = (_b = options === null || options === void 0 ? void 0 : options.enabled) !== null && _b !== void 0 ? _b : true;
17
+ const [isLoading, setIsLoading] = useState(enabled && !!collection);
18
+ const [error, setError] = useState(null);
19
+ const fetchId = useRef(0);
20
+ const sortBy = params === null || params === void 0 ? void 0 : params.sortBy;
21
+ const sortOrder = params === null || params === void 0 ? void 0 : params.sortOrder;
22
+ const filterKey = useMemo(() => ((params === null || params === void 0 ? void 0 : params.filter) ? JSON.stringify(params.filter) : ''), [params === null || params === void 0 ? void 0 : params.filter]);
23
+ const filterRef = useRef(params === null || params === void 0 ? void 0 : params.filter);
24
+ filterRef.current = params === null || params === void 0 ? void 0 : params.filter;
25
+ // Track cursors for each page so goToPage can navigate backwards.
26
+ const cursorMapRef = useRef(new Map());
27
+ const doFetch = useCallback((page) => __awaiter(this, void 0, void 0, function* () {
28
+ if (!collection || !enabled)
29
+ return;
30
+ fetchId.current += 1;
31
+ const currentFetchId = fetchId.current;
32
+ setIsLoading(true);
33
+ setError(null);
34
+ try {
35
+ const cursor = cursorMapRef.current.get(page);
36
+ const result = yield client.list(collection, Object.assign({ limit, offset: page, sortBy,
37
+ sortOrder, filter: filterRef.current }, (cursor ? { cursor } : {})));
38
+ if (currentFetchId === fetchId.current) {
39
+ setItems(result.items);
40
+ setTotalCount(result.totalCount);
41
+ setHasMore(result.hasMore);
42
+ setPageIndex(result.pageIndex);
43
+ setIsLoading(false);
44
+ // Store cursor for the next page so goToPage can use it.
45
+ if (result.nextCursor) {
46
+ cursorMapRef.current.set(page + 1, result.nextCursor);
47
+ }
48
+ }
49
+ }
50
+ catch (e) {
51
+ if (currentFetchId === fetchId.current) {
52
+ setError(e instanceof Error ? e : new Error(String(e)));
53
+ setItems([]);
54
+ setIsLoading(false);
55
+ }
56
+ }
57
+ }),
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- filterKey triggers re-fetch when filter object changes
59
+ [client, collection, enabled, limit, sortBy, sortOrder, filterKey]);
60
+ useEffect(() => {
61
+ cursorMapRef.current.clear();
62
+ setPageIndex(0);
63
+ doFetch(0);
64
+ }, [doFetch]);
65
+ const goToPage = useCallback((page) => {
66
+ setPageIndex(page);
67
+ doFetch(page);
68
+ }, [doFetch]);
69
+ const refetch = useCallback(() => {
70
+ doFetch(pageIndex);
71
+ }, [doFetch, pageIndex]);
72
+ return {
73
+ items,
74
+ totalCount,
75
+ hasMore,
76
+ pageIndex,
77
+ isLoading,
78
+ error,
79
+ refetch,
80
+ goToPage,
81
+ };
82
+ }
@@ -0,0 +1,65 @@
1
+ import type { SfaRecord, SfaRelationDescriptor } from '../provider/types';
2
+ /** @description Parameters for narrowing, sorting, and filtering related records. */
3
+ export interface UseRelatedRecordsParams {
4
+ readonly limit?: number;
5
+ readonly sortBy?: string;
6
+ readonly sortOrder?: 'asc' | 'desc';
7
+ /**
8
+ * @description Extra filter constraints merged alongside the auto-built
9
+ * relationship filter. Accepts any react-querybuilder-shaped group or rule.
10
+ */
11
+ readonly additionalFilter?: Record<string, unknown>;
12
+ }
13
+ /** @description Options controlling whether the hook fetches. */
14
+ export interface UseRelatedRecordsOptions {
15
+ readonly enabled?: boolean;
16
+ }
17
+ /** @description Return value of useRelatedRecords: paginated items, loading/error state, and the resolved relationship descriptor. */
18
+ export interface UseRelatedRecordsResult {
19
+ readonly items: readonly SfaRecord[];
20
+ readonly totalCount: number;
21
+ readonly hasMore: boolean;
22
+ readonly pageIndex: number;
23
+ readonly isLoading: boolean;
24
+ readonly error: Error | null;
25
+ readonly refetch: () => void;
26
+ readonly goToPage: (page: number) => void;
27
+ readonly relationship: SfaRelationDescriptor | null;
28
+ }
29
+ /**
30
+ * @description Thrown when the describe metadata for parentCollection
31
+ * contains no relationship pointing at the requested relationTarget.
32
+ */
33
+ export declare class RelationNotFoundError extends Error {
34
+ readonly parentCollection: string;
35
+ readonly relationTarget: string;
36
+ readonly availableTargets: readonly string[];
37
+ constructor(parentCollection: string, relationTarget: string, availableTargets: readonly string[]);
38
+ }
39
+ /**
40
+ * @description Finds the first relationship descriptor whose `target`
41
+ * matches relationTarget, falling back to a match on `name`.
42
+ */
43
+ export declare function resolveRelation(relationships: readonly SfaRelationDescriptor[], relationTarget: string): SfaRelationDescriptor | undefined;
44
+ /**
45
+ * @description Builds a react-querybuilder-shaped filter that constrains
46
+ * `viaField` to the given parent id(s). A single string uses `eq`;
47
+ * an array uses `in` (batch fetch for the grandchild pattern).
48
+ */
49
+ export declare function buildRelationFilter(viaField: string, parentId: string | readonly string[], additionalFilter?: Record<string, unknown>): Record<string, unknown>;
50
+ /**
51
+ * @description Fetches child records scoped to a parent by automatically
52
+ * resolving the join from describe metadata. The AI agent never needs to
53
+ * hand-wire foreign keys: pass the parent collection, a parent id (or array
54
+ * of ids for the grandchild batch pattern), and the target collection.
55
+ *
56
+ * Resolution is direction-agnostic: the hook first checks the parent's
57
+ * describe for a forward relationship to the target; if none is found it
58
+ * falls back to describing the target and looking for a reverse
59
+ * relationship back to the parent. This handles org schemas that only
60
+ * declare the FK on the "many" (child) side.
61
+ *
62
+ * Composes useDescribe (relationship lookup) and useRecordList (fetch +
63
+ * cursor pagination) internally.
64
+ */
65
+ export declare function useRelatedRecords(parentCollection: string, parentId: string | readonly string[], relationTarget: string, params?: UseRelatedRecordsParams, options?: UseRelatedRecordsOptions): UseRelatedRecordsResult;
@@ -0,0 +1,227 @@
1
+ import { useMemo, useRef } from 'react';
2
+ import { useDescribe } from './useDescribe';
3
+ import { useRecordList } from './useRecordList';
4
+ // ── Error ──
5
+ /**
6
+ * @description Thrown when the describe metadata for parentCollection
7
+ * contains no relationship pointing at the requested relationTarget.
8
+ */
9
+ export class RelationNotFoundError extends Error {
10
+ constructor(parentCollection, relationTarget, availableTargets) {
11
+ const available = availableTargets.length > 0 ? availableTargets.join(', ') : 'none';
12
+ super(`No relationship from "${parentCollection}" to "${relationTarget}". ` +
13
+ `Available targets: ${available}.`);
14
+ this.name = 'RelationNotFoundError';
15
+ this.parentCollection = parentCollection;
16
+ this.relationTarget = relationTarget;
17
+ this.availableTargets = availableTargets;
18
+ }
19
+ }
20
+ // ── Helpers ──
21
+ /**
22
+ * @description Finds the first relationship descriptor whose `target`
23
+ * matches relationTarget, falling back to a match on `name`.
24
+ */
25
+ export function resolveRelation(relationships, relationTarget) {
26
+ var _a;
27
+ return ((_a = relationships.find(r => r.target === relationTarget)) !== null && _a !== void 0 ? _a : relationships.find(r => r.name === relationTarget));
28
+ }
29
+ /**
30
+ * @description Builds a react-querybuilder-shaped filter that constrains
31
+ * `viaField` to the given parent id(s). A single string uses `eq`;
32
+ * an array uses `in` (batch fetch for the grandchild pattern).
33
+ */
34
+ export function buildRelationFilter(viaField, parentId, additionalFilter) {
35
+ const isArray = Array.isArray(parentId);
36
+ const relationRule = {
37
+ field: viaField,
38
+ operator: isArray ? 'in' : 'eq',
39
+ value: parentId,
40
+ };
41
+ const rules = [relationRule];
42
+ if (additionalFilter) {
43
+ rules.push(additionalFilter);
44
+ }
45
+ return { combinator: 'and', rules };
46
+ }
47
+ // ── Hook ──
48
+ /**
49
+ * @description Fetches child records scoped to a parent by automatically
50
+ * resolving the join from describe metadata. The AI agent never needs to
51
+ * hand-wire foreign keys: pass the parent collection, a parent id (or array
52
+ * of ids for the grandchild batch pattern), and the target collection.
53
+ *
54
+ * Resolution is direction-agnostic: the hook first checks the parent's
55
+ * describe for a forward relationship to the target; if none is found it
56
+ * falls back to describing the target and looking for a reverse
57
+ * relationship back to the parent. This handles org schemas that only
58
+ * declare the FK on the "many" (child) side.
59
+ *
60
+ * Composes useDescribe (relationship lookup) and useRecordList (fetch +
61
+ * cursor pagination) internally.
62
+ */
63
+ export function useRelatedRecords(parentCollection, parentId, relationTarget, params, options) {
64
+ var _a, _b, _c;
65
+ const enabled = (_a = options === null || options === void 0 ? void 0 : options.enabled) !== null && _a !== void 0 ? _a : true;
66
+ const hasParentId = Array.isArray(parentId)
67
+ ? parentId.length > 0
68
+ : !!parentId;
69
+ // ── Step 1: Describe the parent to discover relationships ──
70
+ // Describe is decoupled from hasParentId so it prefetches eagerly.
71
+ // In the grandchild pattern the second hook's parentId starts as []
72
+ // while the first hook fetches; starting describe early lets both
73
+ // metadata calls overlap instead of running in sequence.
74
+ const { object: parentObject, isLoading: isParentDescribeLoading, error: parentDescribeError, } = useDescribe(parentCollection, {
75
+ enabled,
76
+ });
77
+ // Only trust parentObject while it matches the current collection;
78
+ // useDescribe keeps the previous object around while a new key is loading.
79
+ const parentObjectMatches = (parentObject === null || parentObject === void 0 ? void 0 : parentObject.key) === parentCollection;
80
+ // ── Step 2: Resolve the relationship ──
81
+ // Try the parent's describe first (forward: parent declares a
82
+ // relationship to the child). If that misses, fall back to the
83
+ // child's describe and look for a reverse relationship back to the
84
+ // parent. Some org schemas only declare the FK on the "many" side;
85
+ // this two-pass lookup keeps callers direction-agnostic.
86
+ const forwardRelationship = useMemo(() => {
87
+ var _a;
88
+ return parentObject && parentObjectMatches
89
+ ? (_a = resolveRelation(parentObject.relationships, relationTarget)) !== null && _a !== void 0 ? _a : null
90
+ : null;
91
+ }, [parentObject, parentObjectMatches, relationTarget]);
92
+ // ── Step 2b: Reverse-lookup fallback via the child's describe ──
93
+ const needsReverseLookup = enabled &&
94
+ parentObjectMatches &&
95
+ !isParentDescribeLoading &&
96
+ !forwardRelationship;
97
+ const { object: childObject, isLoading: isChildDescribeLoading, error: childDescribeError, } = useDescribe(relationTarget, {
98
+ enabled: needsReverseLookup,
99
+ });
100
+ const childObjectMatches = (childObject === null || childObject === void 0 ? void 0 : childObject.key) === relationTarget;
101
+ const reverseRelationship = useMemo(() => {
102
+ var _a;
103
+ return needsReverseLookup && childObject && childObjectMatches
104
+ ? (_a = resolveRelation(childObject.relationships, parentCollection)) !== null && _a !== void 0 ? _a : null
105
+ : null;
106
+ }, [needsReverseLookup, childObject, childObjectMatches, parentCollection]);
107
+ const relationship = forwardRelationship !== null && forwardRelationship !== void 0 ? forwardRelationship : reverseRelationship;
108
+ // ── Step 3: Surface a clear error when the relation is absent ──
109
+ // Only error once both forward and reverse lookups have definitively failed.
110
+ const relationError = useMemo(() => {
111
+ if (relationship)
112
+ return null;
113
+ if (!parentObjectMatches)
114
+ return null;
115
+ if (isParentDescribeLoading)
116
+ return null;
117
+ if (needsReverseLookup && (isChildDescribeLoading || !childObjectMatches))
118
+ return null;
119
+ return new RelationNotFoundError(parentCollection, relationTarget, parentObject.relationships.map(r => r.target));
120
+ }, [
121
+ relationship,
122
+ parentObjectMatches,
123
+ isParentDescribeLoading,
124
+ needsReverseLookup,
125
+ isChildDescribeLoading,
126
+ childObjectMatches,
127
+ parentCollection,
128
+ relationTarget,
129
+ parentObject,
130
+ ]);
131
+ // ── Step 4: Build the relationship filter ──
132
+ // Stabilize array/object identity via serialized keys so downstream
133
+ // useRecordList only re-fetches when the actual values change.
134
+ const parentIdRef = useRef(parentId);
135
+ parentIdRef.current = parentId;
136
+ const parentIdKey = Array.isArray(parentId)
137
+ ? JSON.stringify(parentId)
138
+ : parentId;
139
+ const additionalFilterRef = useRef(params === null || params === void 0 ? void 0 : params.additionalFilter);
140
+ additionalFilterRef.current = params === null || params === void 0 ? void 0 : params.additionalFilter;
141
+ const additionalFilterKey = (params === null || params === void 0 ? void 0 : params.additionalFilter)
142
+ ? JSON.stringify(params.additionalFilter)
143
+ : '';
144
+ const filter = useMemo(() => relationship
145
+ ? buildRelationFilter(relationship.viaField, parentIdRef.current, additionalFilterRef.current)
146
+ : undefined,
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- parentIdKey and additionalFilterKey are stable serialized proxies for ref values
148
+ [relationship, parentIdKey, additionalFilterKey]);
149
+ // ── Step 5: Delegate to useRecordList ──
150
+ const listParams = useMemo(() => {
151
+ var _a;
152
+ return filter
153
+ ? {
154
+ limit: (_a = params === null || params === void 0 ? void 0 : params.limit) !== null && _a !== void 0 ? _a : 20,
155
+ sortBy: params === null || params === void 0 ? void 0 : params.sortBy,
156
+ sortOrder: params === null || params === void 0 ? void 0 : params.sortOrder,
157
+ filter,
158
+ }
159
+ : undefined;
160
+ }, [filter, params === null || params === void 0 ? void 0 : params.limit, params === null || params === void 0 ? void 0 : params.sortBy, params === null || params === void 0 ? void 0 : params.sortOrder]);
161
+ // When using a reverse relationship, relationship.target points back at
162
+ // the *parent* — not the child we want to query. Always prefer the
163
+ // forward relationship's target (which IS the child); otherwise fall
164
+ // back to the caller-supplied relationTarget.
165
+ const targetCollection = (_b = forwardRelationship === null || forwardRelationship === void 0 ? void 0 : forwardRelationship.target) !== null && _b !== void 0 ? _b : relationTarget;
166
+ const recordList = useRecordList(targetCollection, listParams, {
167
+ enabled: enabled &&
168
+ hasParentId &&
169
+ parentObjectMatches &&
170
+ !!relationship &&
171
+ !relationError,
172
+ });
173
+ // Detect any change to the list query identifiers so we can reset the
174
+ // "fetch has started" marker before useRecordList's effect flips isLoading
175
+ // back on. This prevents one render frame where isLoading is false while
176
+ // the previous fetch's items are still on display.
177
+ const listQueryKey = useMemo(() => JSON.stringify({
178
+ collection: targetCollection,
179
+ params: listParams,
180
+ relationTarget,
181
+ }), [targetCollection, listParams, relationTarget]);
182
+ // ── Step 6: Merge state ──
183
+ // listShouldBeActive covers the one-frame gap where useRecordList's
184
+ // enabled just flipped to true but its internal isLoading hasn't been
185
+ // set yet (useState initial value is only evaluated at mount).
186
+ // listQueryKey resets the marker whenever the query identity changes
187
+ // (parentId / filter / limit / sort / relationTarget) so we never return
188
+ // stale items with isLoading: false while a new fetch is about to start.
189
+ const listShouldBeActive = enabled &&
190
+ hasParentId &&
191
+ parentObjectMatches &&
192
+ !!relationship &&
193
+ !relationError;
194
+ const listHasStartedRef = useRef(false);
195
+ const prevListShouldBeActiveRef = useRef(listShouldBeActive);
196
+ const prevListQueryKeyRef = useRef(listQueryKey);
197
+ if (listShouldBeActive &&
198
+ (!prevListShouldBeActiveRef.current ||
199
+ prevListQueryKeyRef.current !== listQueryKey)) {
200
+ listHasStartedRef.current = false;
201
+ }
202
+ prevListShouldBeActiveRef.current = listShouldBeActive;
203
+ prevListQueryKeyRef.current = listQueryKey;
204
+ if (listShouldBeActive && recordList.isLoading) {
205
+ listHasStartedRef.current = true;
206
+ }
207
+ const isDescribeLoading = isParentDescribeLoading ||
208
+ (needsReverseLookup &&
209
+ !childDescribeError &&
210
+ (!childObjectMatches || isChildDescribeLoading));
211
+ const isLoading = isDescribeLoading ||
212
+ (listShouldBeActive &&
213
+ (recordList.isLoading || !listHasStartedRef.current));
214
+ const describeError = parentDescribeError !== null && parentDescribeError !== void 0 ? parentDescribeError : (needsReverseLookup ? childDescribeError : null);
215
+ const error = (_c = describeError !== null && describeError !== void 0 ? describeError : relationError) !== null && _c !== void 0 ? _c : recordList.error;
216
+ return {
217
+ items: recordList.items,
218
+ totalCount: recordList.totalCount,
219
+ hasMore: recordList.hasMore,
220
+ pageIndex: recordList.pageIndex,
221
+ isLoading,
222
+ error,
223
+ refetch: recordList.refetch,
224
+ goToPage: recordList.goToPage,
225
+ relationship,
226
+ };
227
+ }
@@ -0,0 +1,23 @@
1
+ import type { SfaRecord } from '../provider/types';
2
+ export interface SfaConflictDetail {
3
+ /** The current version of the record on the server. */
4
+ readonly currentRecord: SfaRecord;
5
+ }
6
+ export interface UseUpdateRecordResult {
7
+ /** Call to update a record. Requires the record's current version for optimistic locking. */
8
+ readonly update: (recordId: string, fields: Record<string, unknown>, version: string) => Promise<SfaRecord>;
9
+ /** The most recently updated record, or null before the first update. */
10
+ readonly data: SfaRecord | null;
11
+ readonly isLoading: boolean;
12
+ readonly error: Error | null;
13
+ /** Non-null when the last update failed with a 412 conflict. Contains the server's current record. */
14
+ readonly conflict: SfaConflictDetail | null;
15
+ /** Reset data, error, and conflict state to initial values. */
16
+ readonly reset: () => void;
17
+ }
18
+ /**
19
+ * @description Imperative hook for updating a single record with optimistic
20
+ * locking. Surfaces 412 conflicts as a dedicated `conflict` state so
21
+ * components can show "someone else updated this" with the current record.
22
+ */
23
+ export declare function useUpdateRecord(collection: string): UseUpdateRecordResult;
@@ -0,0 +1,55 @@
1
+ import { __awaiter } from "tslib";
2
+ import { useCallback, useRef, useState } from 'react';
3
+ import { ERR_READ_ONLY } from '../errors';
4
+ import { SfaConflictError } from '../provider/SfaClient';
5
+ import { useSfaContext } from '../provider/useSfaClient';
6
+ /**
7
+ * @description Imperative hook for updating a single record with optimistic
8
+ * locking. Surfaces 412 conflicts as a dedicated `conflict` state so
9
+ * components can show "someone else updated this" with the current record.
10
+ */
11
+ export function useUpdateRecord(collection) {
12
+ const { client, readOnly } = useSfaContext();
13
+ const [data, setData] = useState(null);
14
+ const [isLoading, setIsLoading] = useState(false);
15
+ const [error, setError] = useState(null);
16
+ const [conflict, setConflict] = useState(null);
17
+ const callId = useRef(0);
18
+ const update = useCallback((recordId, fields, version) => __awaiter(this, void 0, void 0, function* () {
19
+ callId.current += 1;
20
+ const currentCallId = callId.current;
21
+ if (readOnly) {
22
+ throw new Error(ERR_READ_ONLY);
23
+ }
24
+ setIsLoading(true);
25
+ setError(null);
26
+ setConflict(null);
27
+ try {
28
+ const record = yield client.update(collection, recordId, fields, version);
29
+ if (currentCallId === callId.current) {
30
+ setData(record);
31
+ setIsLoading(false);
32
+ }
33
+ return record;
34
+ }
35
+ catch (e) {
36
+ if (currentCallId === callId.current) {
37
+ if (e instanceof SfaConflictError && e.currentRecord) {
38
+ setConflict({ currentRecord: e.currentRecord });
39
+ }
40
+ const err = e instanceof Error ? e : new Error(String(e));
41
+ setError(err);
42
+ setIsLoading(false);
43
+ }
44
+ throw e;
45
+ }
46
+ }), [client, collection, readOnly]);
47
+ const reset = useCallback(() => {
48
+ callId.current += 1;
49
+ setData(null);
50
+ setError(null);
51
+ setConflict(null);
52
+ setIsLoading(false);
53
+ }, []);
54
+ return { update, data, isLoading, error, conflict, reset };
55
+ }
@@ -0,0 +1,98 @@
1
+ import type { SfaApiMode, SfaClientConfig, SfaClientInterface, SfaCreateOptions, SfaDescribeResult, SfaFieldError, SfaFieldTypeCatalog, SfaListParams, SfaListResponse, SfaObjectCatalog, SfaRecord } from './types';
2
+ /**
3
+ * @description API client for SFA/CRM data operations via keystone.
4
+ * Authenticates with a JWT Bearer token.
5
+ *
6
+ * In direct mode the client talks to keystone's v1 REST API.
7
+ * In proxy mode the client relays through a local Next.js API route.
8
+ */
9
+ export declare class SfaClient implements SfaClientInterface {
10
+ private readonly apiBase;
11
+ private readonly auth;
12
+ private readonly organizationId;
13
+ private readonly onAuthError;
14
+ readonly mode: SfaApiMode;
15
+ constructor(config: SfaClientConfig);
16
+ private resolveBase;
17
+ get(collection: string, recordId: string): Promise<SfaRecord>;
18
+ list(collection: string, params?: SfaListParams): Promise<SfaListResponse>;
19
+ describe(objectKey: string): Promise<SfaDescribeResult>;
20
+ listObjects(): Promise<SfaObjectCatalog>;
21
+ listFieldTypes(): Promise<SfaFieldTypeCatalog>;
22
+ private assertDirectMode;
23
+ /** @description Throws if recordId is empty to prevent malformed API requests. */
24
+ private assertRecordId;
25
+ /**
26
+ * @description Creates a new record. Optionally accepts an idempotency key
27
+ * to prevent duplicate creates on retries.
28
+ */
29
+ create(collection: string, fields: Record<string, unknown>, options?: SfaCreateOptions): Promise<SfaRecord>;
30
+ /**
31
+ * @description Updates a record with optimistic locking. The version token
32
+ * (from a prior GET) is sent as the If-Match header; a 412 response means
33
+ * another write landed first.
34
+ */
35
+ update(collection: string, recordId: string, fields: Record<string, unknown>, version: string): Promise<SfaRecord>;
36
+ /**
37
+ * @description Deletes a record with optimistic locking. Returns void on
38
+ * success (204). Throws SfaConflictError on 412 if the record was modified
39
+ * since the version was obtained.
40
+ */
41
+ delete(collection: string, recordId: string, version: string): Promise<void>;
42
+ /** @description Returns the objects metadata path for the current mode. */
43
+ private objectsPath;
44
+ /** @description Returns the field-types catalog path for the current mode. */
45
+ private fieldTypesPath;
46
+ private applyAuth;
47
+ /**
48
+ * @description GET request with auth headers. Works for all read-only
49
+ * endpoints in both direct and proxy modes.
50
+ */
51
+ private fetchGet;
52
+ /**
53
+ * @description POST request with auth headers and a JSON body.
54
+ * Body is sent directly (not wrapped in { data: ... }).
55
+ */
56
+ private postJson;
57
+ /**
58
+ * @description Generic fetch with a configurable HTTP method and optional
59
+ * body / extra headers. Returns the raw Response so callers can inspect
60
+ * status codes, headers, or handle empty bodies (204).
61
+ */
62
+ private fetchWithBody;
63
+ }
64
+ /**
65
+ * @description Error from the SFA API with parsed Keystone error details.
66
+ * Keystone returns structured errors with `detail`, `code`, and `title` fields.
67
+ * The message prefers the human-readable `detail` over the raw status text.
68
+ * The raw body is truncated to limit exposure of record data via error
69
+ * monitoring services.
70
+ */
71
+ export declare class SfaApiError extends Error {
72
+ readonly status: number;
73
+ readonly statusText: string;
74
+ readonly url: string;
75
+ readonly detail: string | null;
76
+ readonly code: string | null;
77
+ readonly body: string;
78
+ constructor(status: number, statusText: string, body: string, url: string);
79
+ }
80
+ /**
81
+ * @description Thrown on 412 Precondition Failed. The record was modified by
82
+ * another writer since the caller's version was obtained. The response body
83
+ * contains the current record so the UI can show what changed.
84
+ */
85
+ export declare class SfaConflictError extends SfaApiError {
86
+ readonly currentRecord: SfaRecord | null;
87
+ constructor(status: number, statusText: string, body: string, url: string, currentRecord: SfaRecord | null);
88
+ static fromBody(status: number, statusText: string, body: string, url: string): SfaConflictError;
89
+ }
90
+ /**
91
+ * @description Thrown on 422 with code VALIDATION_FAILED. Exposes per-field
92
+ * errors so the UI can highlight which fields need correction.
93
+ */
94
+ export declare class SfaValidationError extends SfaApiError {
95
+ readonly fieldErrors: readonly SfaFieldError[];
96
+ constructor(status: number, statusText: string, body: string, url: string, fieldErrors: readonly SfaFieldError[]);
97
+ static fromBody(status: number, statusText: string, body: string, url: string): SfaValidationError;
98
+ }