@geniee-inc/sfa-kit 1.8.0 → 1.10.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.
package/GUIDE.md CHANGED
@@ -18,7 +18,7 @@ The package ships built JavaScript and TypeScript declarations only. It contains
18
18
 
19
19
  ```ts
20
20
  import pkg from '@geniee-inc/sfa-kit/package.json';
21
- // pkg.version -> '1.8.0'
21
+ // pkg.version -> '1.10.0'
22
22
  ```
23
23
 
24
24
  Two additional subpaths are available: `@geniee-inc/sfa-kit/signing` exposes bundle-signing utilities (`computeContentHash`, `computeSignature`, `createManifest`, `createManifestFromDirectory`) for Node.js environments, and `./package.json` exposes the manifest. Internal modules cannot be imported directly.
@@ -137,10 +137,23 @@ Returns `{ record: SfaRecord | null, isLoading, error, refetch }`.
137
137
  Fetches a paginated list of records. Supports cursor-based pagination (Keystone API).
138
138
 
139
139
  ```tsx
140
- import { useRecordList } from '@geniee-inc/sfa-kit';
141
-
142
- const { items, totalCount, hasMore, pageIndex, goToPage, isLoading, error } =
143
- useRecordList('companies');
140
+ import { useRecordList, FetchErrorState } from '@geniee-inc/sfa-kit';
141
+
142
+ const {
143
+ items,
144
+ totalCount,
145
+ hasMore,
146
+ pageIndex,
147
+ goToPage,
148
+ status,
149
+ isLoading,
150
+ refetch,
151
+ } = useRecordList('companies');
152
+
153
+ // `items` describes the server only when the fetch succeeded. Render the
154
+ // failure before the list, or a rate limit shows up as "0 records".
155
+ if (status === 'error') return <FetchErrorState onRetry={refetch} />;
156
+ if (isLoading) return <p>Loading...</p>;
144
157
 
145
158
  // With params
146
159
  const result = useRecordList('companies', {
@@ -150,6 +163,10 @@ const result = useRecordList('companies', {
150
163
  });
151
164
  ```
152
165
 
166
+ **Starting position** — `offset` is a record position (`limit` 20 with `offset` 40 starts on page 2) and `cursor` names the same point opaquely. Both describe the _first_ page only: from there `goToPage` computes offsets from the page number and reuses the cursors the server hands back. Changing either starts a new query, so `status` reads `'loading'` with no items until the fetch from the new position settles.
167
+
168
+ Against Keystone (`apiBase` pointing at the API directly) pagination is **cursor-only** — `records/query` takes no offset. Pages are reached in order: the first page needs no cursor, and each response carries the cursor for the next one. Asking to start at `offset > 0` without the cursor for that page fails with an explanatory error rather than quietly answering with the first page, so `status` reads `'error'` and the caller can retry or start from the beginning.
169
+
153
170
  **Filters** must use react-querybuilder shape with `combinator` and `rules`. The backend rejects any other format with a 422.
154
171
 
155
172
  ```tsx
@@ -164,6 +181,14 @@ const result = useRecordList('companies', {
164
181
  });
165
182
  ```
166
183
 
184
+ **`status`** is the one field to branch on, and it always describes the query the hook is being asked about right now: `'idle'` (nothing will be fetched and nothing is known), `'loading'` (a fetch is in flight, or the query just changed and its fetch has not settled), `'success'` (the fetch for _this_ query completed and `items` came from the server), `'error'` (the fetch for _this_ query failed). Changing `collection`, `filter`, `sortBy`, `limit`, `offset` or `cursor` starts a new query: from that render on, `status` is `'loading'` and `items` is empty until the new fetch settles — the previous query's records, counts and error are never published as this query's answer. The client counts as part of the query: `SfaProvider` builds a new one whenever the access token, `organizationId`, `apiBase`, `mode` or `readOnly` change, so the same collection and filter can address a different organization. A failed fetch keeps the last successful `items` rather than emptying them, so an error never fabricates a zero — but it also means `items.length === 0` is only a real "the server has none" when `status === 'success'`.
185
+
186
+ `refetch` retries what the reader was trying to reach: after a failed page turn it asks for the page that failed, not the one still on screen. `pageIndex` follows the records being displayed, so the two differ only while an error is showing; once a fetch succeeds they are the same again and `refetch` targets the current page.
187
+
188
+ Disabling the hook, or clearing `collection`, discards any request still in flight: a response that lands after the caller stopped asking never becomes the result, and the `'loading'` a discarded refetch set does not outlive it — the hook goes back to how the query last ended.
189
+
190
+ Returns `{ items, totalCount, hasMore, pageIndex, status, isLoading, error, refetch, goToPage }`.
191
+
167
192
  For cross-object queries (fetching child records of a parent), prefer `useRelatedRecords` which builds the filter automatically from describe metadata.
168
193
 
169
194
  ### useRelatedRecords
@@ -232,14 +257,18 @@ const { items, isLoading, error } = useRelatedRecords(
232
257
  );
233
258
  ```
234
259
 
235
- **Error handling:** If the relationship doesn't exist in the parent's metadata, the hook returns a `RelationNotFoundError` with an `availableTargets` list so the caller can self-correct.
260
+ **Error handling:** `status` and `error` cover all three steps — a failed describe, a missing relationship, and a failed list fetch. If the relationship doesn't exist in the parent's metadata, `error` is a `RelationNotFoundError` with an `availableTargets` list so the caller can self-correct.
261
+
262
+ Check `status` for **every** hook on the page, not just the first one. Each related-records hook fails independently: with several fetches firing on mount, one can hit a rate limit or a timeout while the others succeed, and the failed one renders as an empty section unless its own status is read.
236
263
 
237
- Returns `{ items, totalCount, hasMore, pageIndex, goToPage, isLoading, error, refetch, relationship }`.
264
+ Returns `{ items, totalCount, hasMore, pageIndex, status, goToPage, isLoading, error, refetch, relationship }`.
238
265
 
239
266
  ### useDescribe
240
267
 
241
268
  Discovers object schema — fields, types, relationships, permissions. Use this to build dynamic UIs that adapt to any object.
242
269
 
270
+ `object`, `isLoading` and `error` always describe the key passed right now, through the client in use right now. Changing either reports `isLoading` with a `null` object until the new fetch settles, so one object's metadata — or one organization's schema — is never attributed to another.
271
+
243
272
  ```tsx
244
273
  import { useDescribe } from '@geniee-inc/sfa-kit';
245
274
 
@@ -299,6 +328,63 @@ Access field values: `record.fields['company_name']?.value` for the raw value, `
299
328
 
300
329
  ## Error handling
301
330
 
331
+ ### Fetch errors are returned, not thrown
332
+
333
+ Every data hook reports a failed fetch through its own `status` / `error`. Nothing throws, so `ErrorBoundary` does not see these — the page must branch on them itself. Skipping that check is the one mistake that is invisible in review: the page renders, with an empty list and a count of 0, and looks exactly like a record that genuinely has no children.
334
+
335
+ Branch in this order, for **every** hook on the page:
336
+
337
+ ```tsx
338
+ import {
339
+ useRelatedRecords,
340
+ FetchErrorState,
341
+ EmptyState,
342
+ } from '@geniee-inc/sfa-kit';
343
+
344
+ const { items, status, refetch } = useRelatedRecords(
345
+ 'companies',
346
+ companyId,
347
+ 'tasks'
348
+ );
349
+
350
+ // 1. Failure first: never fall through to a count or an empty list
351
+ if (status === 'error')
352
+ return <FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />;
353
+
354
+ // 2. Nothing was fetched yet
355
+ if (status === 'loading' || status === 'idle') return <p>Loading...</p>;
356
+
357
+ // 3. Only now is an empty list a fact about the server
358
+ if (items.length === 0) return <EmptyState message="タスクはありません" />;
359
+
360
+ return (
361
+ <>
362
+ <h2>タスク ({items.length})</h2>
363
+ {items.map(task => (
364
+ <p key={task.record_id}>{task.title ?? task.record_id}</p>
365
+ ))}
366
+ </>
367
+ );
368
+ ```
369
+
370
+ Reading `items` before step 1 is what turns a rate limit into a confident `タスク (0)`. Each hook fails on its own, so one section can fail while the rest of the page renders normally — a shared `if (aError || bError)` at the top hides which one it was and blanks the sections that did load.
371
+
372
+ Two components exist for the two states so they are never confused:
373
+
374
+ ### FetchErrorState
375
+
376
+ The state for `status === 'error'`. Renders `⚠ 取得失敗` and, when given `onRetry`, a `再取得` link that refetches in place.
377
+
378
+ ```tsx
379
+ <FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />
380
+ ```
381
+
382
+ Props: `{ onRetry?: () => void, message?: string, retryLabel?: string }`. Without `onRetry` no link is rendered — a dead control is worse than none.
383
+
384
+ The server's error text is deliberately not shown: it is wording the reader cannot act on. Pages that need it still have `error` from the hook, and `RecordList` / `RecordView` report it through their `onError` prop.
385
+
386
+ `refetch` retries whichever step failed. On `useRelatedRecords` that includes a failed describe: retrying only the list would do nothing, because without relationship metadata the list never ran.
387
+
302
388
  Two baseline components for resilient pages:
303
389
 
304
390
  ### ErrorBoundary
@@ -315,7 +401,7 @@ import { ErrorBoundary } from '@geniee-inc/sfa-kit';
315
401
 
316
402
  ### EmptyState
317
403
 
318
- Placeholder for pages with no data.
404
+ Placeholder for pages with no data. Use it only once `status === 'success'` — before that an empty list means "not known", and "no data" states a fact the page never got back from the server.
319
405
 
320
406
  ```tsx
321
407
  import { EmptyState } from '@geniee-inc/sfa-kit';
@@ -383,6 +469,9 @@ Props:
383
469
  - `onRowClick` (optional) - `(record) => void` callback.
384
470
  - `showPagination` (optional) - Default true.
385
471
  - `params` (optional) - `{ limit?, offset?, sortBy?, sortOrder?, filter?, cursor? }`.
472
+ - `errorMessage` (optional) - what to say when the fetch fails, e.g. `"タスクの取得に失敗"`. `RecordList` has nothing to fall back on, so on a page with several sections pass the label that section already displays — a failure then says which one it was. `collection` is never used for this: it is the object key the API is addressed with (`business_discussions`), not something to show a reader.
473
+
474
+ `RecordView` takes the same prop and, without it, names its `title` — the label it already shows as the heading — so a page that passed `title="会社"` gets `⚠ 会社の取得に失敗` for free.
386
475
 
387
476
  ### FieldRenderer
388
477
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geniee-inc/sfa-kit",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "React hooks and components for building pages that connect to GENIEE SFA/CRM data.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export type { EmbedContext } from './lib/auth/embed-protocol';
9
9
  export type { StandalonePreviewState, StandalonePreviewControls, } from './lib/auth/StandalonePreviewContext';
10
10
  export { useRecord } from './lib/hooks/useRecord';
11
11
  export { useRecordList } from './lib/hooks/useRecordList';
12
+ export type { SfaListStatus } from './lib/hooks/useRecordList';
12
13
  export { useDescribe } from './lib/hooks/useDescribe';
13
14
  export { useObjectCatalog } from './lib/hooks/useObjectCatalog';
14
15
  export { useFieldTypes } from './lib/hooks/useFieldTypes';
@@ -25,6 +26,7 @@ export { RecordView } from './lib/components/RecordView';
25
26
  export { RecordList } from './lib/components/RecordList';
26
27
  export { ErrorBoundary } from './lib/components/ErrorBoundary';
27
28
  export { EmptyState } from './lib/components/EmptyState';
29
+ export { FetchErrorState } from './lib/components/FetchErrorState';
28
30
  export type { SfaAuth, SfaApiMode, SfaClientConfig, SfaRecord, SfaListResponse, SfaListParams, SfaFieldValue, SfaFieldKind, SfaFieldValueMapping, FieldGroupSpec, FieldGroupLayout, SfaFieldDescriptor, SfaFieldOption, SfaRelationDescriptor, SfaObjectDetail, SfaObjectSummary, SfaObjectCatalog, SfaObjectPermissions, SfaObjectOrigin, SfaRelationshipKind, SfaCardinality, SfaDescribeResult, SfaFieldTypeDescriptor, SfaFieldTypeCatalog, SfaFilterOperator, SfaCreateOptions, SfaFieldError, } from './lib/provider/types';
29
31
  export type { SfaSession, AuthResult, OrganizationChoice, } from './lib/auth/types';
30
32
  export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
package/src/index.js CHANGED
@@ -19,5 +19,6 @@ export { RecordView } from './lib/components/RecordView';
19
19
  export { RecordList } from './lib/components/RecordList';
20
20
  export { ErrorBoundary } from './lib/components/ErrorBoundary';
21
21
  export { EmptyState } from './lib/components/EmptyState';
22
+ export { FetchErrorState } from './lib/components/FetchErrorState';
22
23
  export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
23
24
  export { useRecordDetailUrl } from './lib/hooks/useRecordDetailUrl';
@@ -0,0 +1,32 @@
1
+ interface FetchErrorStateProps {
2
+ /** Runs the fetch again. Pass the hook's `refetch`. */
3
+ readonly onRetry?: () => void;
4
+ /** Label text after the warning mark. Override to name what failed. */
5
+ readonly message?: string;
6
+ readonly retryLabel?: string;
7
+ }
8
+ /**
9
+ * @description The state to render when a data hook reports
10
+ * `status === 'error'`. Says the fetch failed and offers a retry, so a rate
11
+ * limit or a dropped connection is never shown as an empty list.
12
+ *
13
+ * Do not use `EmptyState` for this: "no data" claims something the page
14
+ * never got back from the server, and gives the user nothing to act on.
15
+ *
16
+ * The underlying error is not shown: it is server wording the reader cannot
17
+ * act on. Pages that need it still have `error` from the hook, and
18
+ * RecordList / RecordView report it through their `onError` prop.
19
+ *
20
+ * ```tsx
21
+ * const { items, status, refetch } = useRelatedRecords(
22
+ * 'companies',
23
+ * companyId,
24
+ * 'tasks'
25
+ * );
26
+ *
27
+ * if (status === 'error')
28
+ * return <FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />;
29
+ * ```
30
+ */
31
+ export declare function FetchErrorState({ onRetry, message, retryLabel, }: FetchErrorStateProps): JSX.Element;
32
+ export {};
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { color, font, space, baseContainerStyle } from '../theme';
3
+ /**
4
+ * @description The state to render when a data hook reports
5
+ * `status === 'error'`. Says the fetch failed and offers a retry, so a rate
6
+ * limit or a dropped connection is never shown as an empty list.
7
+ *
8
+ * Do not use `EmptyState` for this: "no data" claims something the page
9
+ * never got back from the server, and gives the user nothing to act on.
10
+ *
11
+ * The underlying error is not shown: it is server wording the reader cannot
12
+ * act on. Pages that need it still have `error` from the hook, and
13
+ * RecordList / RecordView report it through their `onError` prop.
14
+ *
15
+ * ```tsx
16
+ * const { items, status, refetch } = useRelatedRecords(
17
+ * 'companies',
18
+ * companyId,
19
+ * 'tasks'
20
+ * );
21
+ *
22
+ * if (status === 'error')
23
+ * return <FetchErrorState onRetry={refetch} message="タスクの取得に失敗" />;
24
+ * ```
25
+ */
26
+ export function FetchErrorState({ onRetry, message = '取得失敗', retryLabel = '再取得', }) {
27
+ return (_jsxs("div", { style: styles.container, children: [_jsxs("p", { style: styles.message, role: "alert", children: [_jsx("span", { "aria-hidden": "true", children: "\u26A0" }), " ", message] }), onRetry && (_jsx("button", { type: "button", style: styles.retryLink, onClick: onRetry, children: retryLabel }))] }));
28
+ }
29
+ const styles = {
30
+ container: Object.assign(Object.assign({}, baseContainerStyle), { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: space.lg, textAlign: 'center' }),
31
+ message: {
32
+ fontSize: font.sizeMd,
33
+ fontWeight: font.weightMedium,
34
+ color: color.error,
35
+ margin: `0 0 ${space.sm}px`,
36
+ },
37
+ retryLink: {
38
+ padding: 0,
39
+ fontFamily: font.family,
40
+ fontSize: font.sizeMd,
41
+ color: color.primary,
42
+ backgroundColor: 'transparent',
43
+ border: 'none',
44
+ textDecoration: 'underline',
45
+ cursor: 'pointer',
46
+ },
47
+ };
@@ -12,9 +12,18 @@ interface RecordListProps {
12
12
  readonly showPagination?: boolean;
13
13
  readonly onRowClick?: (record: SfaRecord) => void;
14
14
  readonly onError?: (error: Error) => void;
15
+ /**
16
+ * What to say when the fetch fails. Defaults to a generic wording.
17
+ *
18
+ * NOTE: the component cannot name the records itself — `collection` is the
19
+ * object key the API is addressed with (`business_discussions`), not
20
+ * something to show a reader. A page with several sections should pass the
21
+ * label it already displays, so a failure says which section it was.
22
+ */
23
+ readonly errorMessage?: string;
15
24
  }
16
25
  /**
17
26
  * @description Fetches and displays a paginated list of SFA/CRM records as a table.
18
27
  */
19
- export declare function RecordList({ collection, columns, params, pageSize, showPagination, onRowClick, onError, }: RecordListProps): JSX.Element;
28
+ export declare function RecordList({ collection, columns, params, pageSize, showPagination, onRowClick, onError, errorMessage, }: RecordListProps): JSX.Element;
20
29
  export {};
@@ -3,14 +3,15 @@ import { useEffect, useMemo, useRef } from 'react';
3
3
  import { useRecordList } from '../hooks/useRecordList';
4
4
  import { color, font, radius, space, baseContainerStyle, baseButtonStyle, } from '../theme';
5
5
  import { SFA_KIT_VERSION } from '../version';
6
+ import { FetchErrorState } from './FetchErrorState';
6
7
  import { FieldRenderer } from './FieldRenderer';
7
8
  const MAX_AUTO_COLUMNS = 8;
8
9
  /**
9
10
  * @description Fetches and displays a paginated list of SFA/CRM records as a table.
10
11
  */
11
- export function RecordList({ collection, columns, params, pageSize = 20, showPagination = true, onRowClick, onError, }) {
12
+ export function RecordList({ collection, columns, params, pageSize = 20, showPagination = true, onRowClick, onError, errorMessage, }) {
12
13
  const listParams = useMemo(() => (Object.assign(Object.assign({}, params), { limit: pageSize })), [params, pageSize]);
13
- const { items, totalCount, hasMore, pageIndex, isLoading, error, goToPage } = useRecordList(collection, listParams);
14
+ const { items, totalCount, hasMore, pageIndex, status, isLoading, error, refetch, goToPage, } = useRecordList(collection, listParams);
14
15
  // Use a ref so inline onError callbacks don't re-fire the effect on
15
16
  // every parent render when error is present.
16
17
  const onErrorRef = useRef(onError);
@@ -20,8 +21,10 @@ export function RecordList({ collection, columns, params, pageSize = 20, showPag
20
21
  if (error)
21
22
  (_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, error);
22
23
  }, [error]);
23
- if (error) {
24
- return (_jsxs("div", { style: styles.container, children: [_jsx("div", { style: styles.error, children: error.message }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
24
+ // A failed fetch gets its own state with a retry, never the empty table:
25
+ // "0 records" would state something the server never answered.
26
+ if (status === 'error') {
27
+ return (_jsxs("div", { style: styles.container, children: [_jsx(FetchErrorState, { message: errorMessage, onRetry: refetch }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
25
28
  }
26
29
  const resolvedColumns = columns !== null && columns !== void 0 ? columns : autoDetectColumns(items);
27
30
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
@@ -122,14 +125,6 @@ const styles = {
122
125
  fontSize: font.sizeSm,
123
126
  color: color.textMuted,
124
127
  },
125
- error: {
126
- padding: space.sm,
127
- backgroundColor: color.errorLight,
128
- border: `1px solid ${color.error}`,
129
- borderRadius: radius.sm,
130
- color: color.error,
131
- fontSize: font.sizeMd,
132
- },
133
128
  versionFooter: {
134
129
  textAlign: 'right',
135
130
  fontSize: font.sizeXs,
@@ -6,9 +6,18 @@ interface RecordViewProps {
6
6
  readonly fields?: readonly string[];
7
7
  readonly fieldGroups?: readonly FieldGroupSpec[];
8
8
  readonly onError?: (error: Error) => void;
9
+ /**
10
+ * What to say when the fetch fails. Falls back to naming `title` when one is
11
+ * given, and to a generic wording otherwise.
12
+ *
13
+ * NOTE: `collection` is never used for this — it is the object key the API is
14
+ * addressed with, not something to show a reader. See RecordList, which has
15
+ * no title to fall back on and so needs this prop to name its section.
16
+ */
17
+ readonly errorMessage?: string;
9
18
  }
10
19
  /**
11
20
  * @description Fetches and displays a single SFA/CRM record in a label-value grid.
12
21
  */
13
- export declare function RecordView({ collection, recordId, title, fields, fieldGroups, onError, }: RecordViewProps): JSX.Element;
22
+ export declare function RecordView({ collection, recordId, title, fields, fieldGroups, onError, errorMessage, }: RecordViewProps): JSX.Element;
14
23
  export {};
@@ -3,13 +3,14 @@ import { useEffect, useRef } from 'react';
3
3
  import { useRecord } from '../hooks/useRecord';
4
4
  import { color, font, radius, space, baseContainerStyle } from '../theme';
5
5
  import { SFA_KIT_VERSION } from '../version';
6
+ import { FetchErrorState } from './FetchErrorState';
6
7
  import { FieldRenderer } from './FieldRenderer';
7
8
  /**
8
9
  * @description Fetches and displays a single SFA/CRM record in a label-value grid.
9
10
  */
10
- export function RecordView({ collection, recordId, title, fields, fieldGroups, onError, }) {
11
+ export function RecordView({ collection, recordId, title, fields, fieldGroups, onError, errorMessage, }) {
11
12
  var _a;
12
- const { record, isLoading, error } = useRecord(collection, recordId);
13
+ const { record, isLoading, error, refetch } = useRecord(collection, recordId);
13
14
  // Use a ref so inline onError callbacks don't re-fire the effect on
14
15
  // every parent render when error is present.
15
16
  const onErrorRef = useRef(onError);
@@ -20,7 +21,11 @@ export function RecordView({ collection, recordId, title, fields, fieldGroups, o
20
21
  (_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, error);
21
22
  }, [error]);
22
23
  if (error) {
23
- return (_jsxs("div", { style: styles.container, children: [_jsx("div", { style: styles.error, children: error.message }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
24
+ // NOTE: `title` is the label the page already shows as the heading, so it
25
+ // names the record without exposing `collection` — the object key the API
26
+ // is addressed with. The derived heading is unavailable here: it reads the
27
+ // record, and on a failure there is none.
28
+ return (_jsxs("div", { style: styles.container, children: [_jsx(FetchErrorState, { message: errorMessage !== null && errorMessage !== void 0 ? errorMessage : (title ? `${title}の取得に失敗` : undefined), onRetry: refetch }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
24
29
  }
25
30
  if (isLoading || !record) {
26
31
  return (_jsxs("div", { style: styles.container, children: [_jsx("div", { style: styles.loading, children: "Loading..." }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
@@ -130,14 +135,6 @@ const styles = {
130
135
  color: color.textMuted,
131
136
  fontSize: font.sizeLg,
132
137
  },
133
- error: {
134
- padding: space.sm,
135
- backgroundColor: color.errorLight,
136
- border: `1px solid ${color.error}`,
137
- borderRadius: radius.sm,
138
- color: color.error,
139
- fontSize: font.sizeMd,
140
- },
141
138
  empty: {
142
139
  padding: space.lg,
143
140
  textAlign: 'center',
@@ -11,6 +11,11 @@ interface UseDescribeResult {
11
11
  /**
12
12
  * @description Fetches metadata for an object: fields, types, relationships, permissions.
13
13
  * The AI agent calls this to discover schema before rendering.
14
+ *
15
+ * `object`, `isLoading` and `error` always describe the `objectKey` being asked
16
+ * about right now, through the client in use right now. Until a fetch for that
17
+ * pair settles they read as loading, so one object's metadata — or one
18
+ * organization's — is never reported as another's.
14
19
  */
15
20
  export declare function useDescribe(objectKey: string, options?: UseDescribeOptions): UseDescribeResult;
16
21
  export {};
@@ -3,6 +3,11 @@ import { useSfaClient } from '../provider/useSfaClient';
3
3
  /**
4
4
  * @description Fetches metadata for an object: fields, types, relationships, permissions.
5
5
  * The AI agent calls this to discover schema before rendering.
6
+ *
7
+ * `object`, `isLoading` and `error` always describe the `objectKey` being asked
8
+ * about right now, through the client in use right now. Until a fetch for that
9
+ * pair settles they read as loading, so one object's metadata — or one
10
+ * organization's — is never reported as another's.
6
11
  */
7
12
  export function useDescribe(objectKey, options) {
8
13
  var _a;
@@ -12,6 +17,18 @@ export function useDescribe(objectKey, options) {
12
17
  const [isLoading, setIsLoading] = useState(enabled && !!objectKey);
13
18
  const [error, setError] = useState(null);
14
19
  const fetchIdRef = useRef(0);
20
+ /**
21
+ * The key and client whose fetch has settled — succeeded or failed. The state
22
+ * below only describes objectKey once both match.
23
+ *
24
+ * NOTE: the client is part of it, for the reason spelled out in
25
+ * useRecordList — SfaProvider rebuilds it when the organization or token
26
+ * changes, so the same key can describe another organization's object.
27
+ */
28
+ const settledRef = useRef(null);
29
+ // What the metadata in state describes, so a failure can tell "keep what we
30
+ // have" from "what we have describes something else".
31
+ const loadedRef = useRef(null);
15
32
  const doFetch = useCallback(() => {
16
33
  if (!objectKey || !enabled)
17
34
  return;
@@ -23,18 +40,59 @@ export function useDescribe(objectKey, options) {
23
40
  .then(result => {
24
41
  if (fetchId !== fetchIdRef.current)
25
42
  return;
43
+ // NOTE: refs before setState — see useRecordList for why.
44
+ settledRef.current = { client, objectKey };
45
+ loadedRef.current = { client, objectKey };
26
46
  setObject(result.object);
27
47
  setIsLoading(false);
28
48
  })
29
49
  .catch((err) => {
30
50
  if (fetchId !== fetchIdRef.current)
31
51
  return;
52
+ // Read before writing: this is what the metadata in state describes.
53
+ const loaded = loadedRef.current;
54
+ const describesSameThing = loaded != null &&
55
+ loaded.client === client &&
56
+ loaded.objectKey === objectKey;
57
+ if (!describesSameThing)
58
+ loadedRef.current = null;
59
+ settledRef.current = { client, objectKey };
32
60
  setError(err instanceof Error ? err : new Error(String(err)));
33
61
  setIsLoading(false);
62
+ // Metadata for another key or organization must not survive: it would
63
+ // describe one object as if it were another. A failure on the same
64
+ // question keeps it, so a transient error does not blank a form that
65
+ // was already rendering.
66
+ if (loaded != null && !describesSameThing)
67
+ setObject(null);
34
68
  });
35
69
  }, [client, objectKey, enabled]);
36
70
  useEffect(() => {
71
+ // Invalidate anything still in flight before deciding what to do next.
72
+ // doFetch cannot do this itself: with no objectKey, or while disabled, it
73
+ // returns before reaching its own bump, so a response that lands after the
74
+ // caller stopped asking would still pass the id check and be recorded.
75
+ fetchIdRef.current += 1;
76
+ if (!objectKey || !enabled) {
77
+ // A discarded refetch would otherwise leave isLoading true with nothing
78
+ // to resolve it. The metadata already in hand still stands.
79
+ setIsLoading(false);
80
+ return;
81
+ }
37
82
  doFetch();
38
- }, [doFetch]);
83
+ }, [doFetch, objectKey, enabled]);
84
+ // Until a fetch for the current key and client settles, the state describes
85
+ // what was asked before it.
86
+ const settled = settledRef.current;
87
+ if (settled == null ||
88
+ settled.client !== client ||
89
+ settled.objectKey !== objectKey) {
90
+ return {
91
+ object: null,
92
+ isLoading: enabled && !!objectKey,
93
+ error: null,
94
+ refetch: doFetch,
95
+ };
96
+ }
39
97
  return { object, isLoading, error, refetch: doFetch };
40
98
  }
@@ -2,11 +2,34 @@ import type { SfaListParams, SfaRecord } from '../provider/types';
2
2
  interface UseRecordListOptions {
3
3
  readonly enabled?: boolean;
4
4
  }
5
+ /**
6
+ * @description Lifecycle of a list fetch. `items` only describes the server
7
+ * when this is `'success'`.
8
+ *
9
+ * - `idle`: nothing will be fetched and nothing is known — no collection, or
10
+ * disabled before anything settled. A hook disabled *after* a successful
11
+ * fetch keeps reporting `'success'`, because its `items` did come from the
12
+ * server
13
+ * - `loading`: a fetch is in flight, or the query just changed and the fetch
14
+ * for it has not settled yet
15
+ * - `success`: the last fetch for *this* query completed and `items` came
16
+ * from the server
17
+ * - `error`: the last fetch for *this* query failed. `items` is the last
18
+ * success of the same query, or empty when there was none
19
+ */
20
+ export type SfaListStatus = 'idle' | 'loading' | 'success' | 'error';
5
21
  interface UseRecordListResult {
22
+ /**
23
+ * @description Records from the last successful fetch. An empty array is
24
+ * only meaningful when `status` is `'success'`: on `'idle'`, `'loading'`
25
+ * and `'error'` it means "not known", not "the server has none". Rendering
26
+ * it without checking `status` turns a failed fetch into a confident zero.
27
+ */
6
28
  readonly items: readonly SfaRecord[];
7
29
  readonly totalCount: number;
8
30
  readonly hasMore: boolean;
9
31
  readonly pageIndex: number;
32
+ readonly status: SfaListStatus;
10
33
  readonly isLoading: boolean;
11
34
  readonly error: Error | null;
12
35
  readonly refetch: () => void;
@@ -1,20 +1,36 @@
1
1
  import { __awaiter } from "tslib";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useSfaClient } from '../provider/useSfaClient';
4
+ /** Stable identity so an unanswered query never re-renders on a fresh array. */
5
+ const NO_ITEMS = [];
4
6
  /**
5
7
  * @description Fetches a paginated list of records from the SFA/CRM API.
6
8
  */
7
9
  export function useRecordList(collection, params, options) {
8
- var _a, _b;
10
+ var _a, _b, _c;
9
11
  const client = useSfaClient();
10
12
  const limit = (_a = params === null || params === void 0 ? void 0 : params.limit) !== null && _a !== void 0 ? _a : 20;
11
13
  const [items, setItems] = useState([]);
12
14
  const [totalCount, setTotalCount] = useState(0);
13
15
  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);
16
+ /**
17
+ * Where the caller asked the listing to start.
18
+ *
19
+ * `offset` is a record position, so the page it names is
20
+ * `floor(offset / limit)`. `cursor` addresses the same starting point
21
+ * opaquely and takes precedence over `offset` on the server. Both are only
22
+ * about the *first* page: from there the hook pages on its own, computing
23
+ * offsets from the page number and reusing cursors the server handed back.
24
+ */
25
+ const initialOffset = (_b = params === null || params === void 0 ? void 0 : params.offset) !== null && _b !== void 0 ? _b : 0;
26
+ const initialCursor = params === null || params === void 0 ? void 0 : params.cursor;
27
+ const initialPage = limit > 0 ? Math.floor(initialOffset / limit) : 0;
28
+ const [pageIndex, setPageIndex] = useState(initialPage);
29
+ const enabled = (_c = options === null || options === void 0 ? void 0 : options.enabled) !== null && _c !== void 0 ? _c : true;
30
+ // NOTE: the seed is never published. Until a fetch settles for the current
31
+ // query the reported status is derived below, so this only matters after the
32
+ // first settle — by which time a fetch has set it.
33
+ const [status, setStatus] = useState('idle');
18
34
  const [error, setError] = useState(null);
19
35
  const fetchId = useRef(0);
20
36
  const sortBy = params === null || params === void 0 ? void 0 : params.sortBy;
@@ -22,60 +38,220 @@ export function useRecordList(collection, params, options) {
22
38
  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
39
  const filterRef = useRef(params === null || params === void 0 ? void 0 : params.filter);
24
40
  filterRef.current = params === null || params === void 0 ? void 0 : params.filter;
41
+ // What identifies the query, page navigation aside: paging stays within one
42
+ // query, but the position the caller starts from does not — the same
43
+ // collection and filter read from a different offset or cursor is a
44
+ // different question, and asking it has to start a new fetch.
45
+ const queryKey = useMemo(() => JSON.stringify({
46
+ collection,
47
+ limit,
48
+ sortBy,
49
+ sortOrder,
50
+ filterKey,
51
+ initialOffset,
52
+ initialCursor,
53
+ }), [
54
+ collection,
55
+ limit,
56
+ sortBy,
57
+ sortOrder,
58
+ filterKey,
59
+ initialOffset,
60
+ initialCursor,
61
+ ]);
62
+ /**
63
+ * Which client, query and page the records in state came from, so a failure
64
+ * can tell "keep what we have" from "what we have answers something else".
65
+ *
66
+ * NOTE: the client is part of the question. SfaProvider builds a new
67
+ * SfaClient whenever the accessToken, organizationId, apiBase, mode or
68
+ * readOnly change, so the same collection and filter can address a different
69
+ * organization. Comparing the query alone would publish one organization's
70
+ * records as the answer for another, and keep them through a failure.
71
+ */
72
+ const loadedRef = useRef(null);
73
+ // Which query last completed and how it ended, so a request discarded
74
+ // mid-flight can fall back to it instead of leaving 'loading' with nothing
75
+ // to resolve it. The query is part of it: falling back to how *another*
76
+ // query ended would present its outcome as this one's answer.
77
+ const settledRef = useRef(null);
78
+ // The page of the last request that failed, kept apart from pageIndex:
79
+ // pageIndex follows the records on screen, this follows what the reader was
80
+ // trying to reach. Without it, retrying after a failed page turn re-fetches
81
+ // the page already displayed — the error clears and the reader stays put.
82
+ const failedPageRef = useRef(null);
25
83
  // Track cursors for each page so goToPage can navigate backwards.
26
84
  const cursorMapRef = useRef(new Map());
27
85
  const doFetch = useCallback((page) => __awaiter(this, void 0, void 0, function* () {
86
+ var _a;
28
87
  if (!collection || !enabled)
29
88
  return;
30
89
  fetchId.current += 1;
31
90
  const currentFetchId = fetchId.current;
32
- setIsLoading(true);
91
+ setStatus('loading');
33
92
  setError(null);
34
93
  try {
35
- const cursor = cursorMapRef.current.get(page);
36
- const result = yield client.list(collection, Object.assign({ limit, offset: page, sortBy,
94
+ // The caller's cursor and exact offset belong to the page they named.
95
+ // Every other page is reached with what the server handed back, or with
96
+ // an offset computed from the page number.
97
+ const cursor = (_a = cursorMapRef.current.get(page)) !== null && _a !== void 0 ? _a : (page === initialPage ? initialCursor : undefined);
98
+ const result = yield client.list(collection, Object.assign({ limit,
99
+ // NOTE: offset is a record position, not a page number. Sending the
100
+ // page number asks for records 1..limit for page 1: it overlaps
101
+ // page 0 and comes back reporting pageIndex 0, so the list never
102
+ // advances. SfaClient derives pageIndex from this offset even when
103
+ // the server paginates by cursor, so the two have to agree.
104
+ offset: page === initialPage ? initialOffset : page * limit, sortBy,
37
105
  sortOrder, filter: filterRef.current }, (cursor ? { cursor } : {})));
38
106
  if (currentFetchId === fetchId.current) {
107
+ // NOTE: the refs are written before the setState calls. Outside an
108
+ // event handler React 18 in legacy mode re-renders on every setState
109
+ // instead of batching, so a ref assigned afterwards is still stale
110
+ // in the renders those updates produce.
111
+ loadedRef.current = { client, queryKey, page: result.pageIndex };
112
+ settledRef.current = { client, queryKey, status: 'success' };
113
+ // Displayed page and target page are the same again
114
+ failedPageRef.current = null;
39
115
  setItems(result.items);
40
116
  setTotalCount(result.totalCount);
41
117
  setHasMore(result.hasMore);
42
118
  setPageIndex(result.pageIndex);
43
- setIsLoading(false);
44
- // Store cursor for the next page so goToPage can use it.
119
+ setStatus('success');
120
+ // Store cursors for adjacent pages so goToPage can navigate both ways.
45
121
  if (result.nextCursor) {
46
122
  cursorMapRef.current.set(page + 1, result.nextCursor);
47
123
  }
124
+ if (page > 0 && result.prevCursor) {
125
+ cursorMapRef.current.set(page - 1, result.prevCursor);
126
+ }
48
127
  }
49
128
  }
50
129
  catch (e) {
51
130
  if (currentFetchId === fetchId.current) {
131
+ settledRef.current = { client, queryKey, status: 'error' };
132
+ failedPageRef.current = page;
52
133
  setError(e instanceof Error ? e : new Error(String(e)));
53
- setItems([]);
54
- setIsLoading(false);
134
+ setStatus('error');
135
+ const loaded = loadedRef.current;
136
+ // NOTE: compared against the captured client / queryKey rather than
137
+ // the render-time helper, so this closure depends only on values
138
+ // already in the dependency list below
139
+ const answersSameQuestion = loaded != null &&
140
+ loaded.client === client &&
141
+ loaded.queryKey === queryKey;
142
+ if (answersSameQuestion) {
143
+ // Keep the records from the last successful fetch of this same
144
+ // query. Emptying them would make a rate limit or a dropped
145
+ // connection indistinguishable from "the server has no records",
146
+ // and a caller that renders items without reading status would
147
+ // show a confident zero. pageIndex goes back to the page those
148
+ // records came from, so what it says matches what is on screen —
149
+ // goToPage advanced it before the fetch failed. The page that
150
+ // failed stays in failedPageRef, which is what refetch retries.
151
+ setPageIndex(loaded.page);
152
+ }
153
+ else if (loaded != null) {
154
+ // The records in state answer a different question (another
155
+ // parent, another filter, another organization). Keeping them
156
+ // would show one record's children — or one organization's data —
157
+ // under another, so drop them and let status report them unknown.
158
+ setItems([]);
159
+ setTotalCount(0);
160
+ setHasMore(false);
161
+ loadedRef.current = null;
162
+ }
55
163
  }
56
164
  }
57
165
  }),
166
+ // NOTE: queryKey is read inside but left out on purpose — it is a useMemo
167
+ // over exactly the values below, so it cannot change without one of them
168
+ // changing and recreating this callback. Anything added to queryKey's deps
169
+ // must be added here too, or the closure keeps a stale key.
58
170
  // eslint-disable-next-line react-hooks/exhaustive-deps -- filterKey triggers re-fetch when filter object changes
59
- [client, collection, enabled, limit, sortBy, sortOrder, filterKey]);
171
+ [
172
+ client,
173
+ collection,
174
+ enabled,
175
+ limit,
176
+ sortBy,
177
+ sortOrder,
178
+ filterKey,
179
+ initialOffset,
180
+ initialCursor,
181
+ initialPage,
182
+ ]);
60
183
  useEffect(() => {
184
+ // Invalidate anything still in flight before deciding what to do next.
185
+ // doFetch cannot do this itself: while disabled or without a collection it
186
+ // returns before reaching its own bump, so a response that lands after the
187
+ // caller stopped asking would still pass the id check and be published.
188
+ fetchId.current += 1;
61
189
  cursorMapRef.current.clear();
62
- setPageIndex(0);
63
- doFetch(0);
64
- }, [doFetch]);
190
+ // A new question has no failed page yet.
191
+ failedPageRef.current = null;
192
+ if (!collection) {
193
+ // Nothing to ask about, and the records in hand answer another
194
+ // collection — see the 'idle' contract on SfaListStatus.
195
+ setStatus('idle');
196
+ return;
197
+ }
198
+ if (!enabled) {
199
+ // A discarded refetch would otherwise leave 'loading' with nothing to
200
+ // resolve it. Fall back to how *this* query last ended — its records are
201
+ // still in hand. Nothing settled for it means nothing is known: idle.
202
+ // NOTE: compared against the captured client / queryKey rather than the
203
+ // render-time helper, so this effect depends only on what is already in
204
+ // its dependency list
205
+ const settled = settledRef.current;
206
+ const answersSameQuestion = settled != null &&
207
+ settled.client === client &&
208
+ settled.queryKey === queryKey;
209
+ // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent
210
+ setStatus(answersSameQuestion ? settled.status : 'idle');
211
+ return;
212
+ }
213
+ setPageIndex(initialPage);
214
+ doFetch(initialPage);
215
+ }, [doFetch, collection, enabled, client, queryKey, initialPage]);
65
216
  const goToPage = useCallback((page) => {
66
217
  setPageIndex(page);
67
218
  doFetch(page);
68
219
  }, [doFetch]);
69
220
  const refetch = useCallback(() => {
70
- doFetch(pageIndex);
221
+ var _a;
222
+ // Retry what the reader was trying to reach. After a success the two are
223
+ // the same, so an ordinary refetch targets the page on screen.
224
+ doFetch((_a = failedPageRef.current) !== null && _a !== void 0 ? _a : pageIndex);
71
225
  }, [doFetch, pageIndex]);
226
+ /**
227
+ * The state above only answers `queryKey` once a fetch for it has settled:
228
+ * at mount nothing has, and right after the query changes it still holds the
229
+ * previous one's items, counts, error and status. Publishing those presents
230
+ * one query's answer as another's — the same lie as showing a failure as 0.
231
+ * Until this query settles, report only what is known.
232
+ */
233
+ const settled = settledRef.current;
234
+ const isSettledForCurrentQuery = settled != null &&
235
+ settled.client === client &&
236
+ settled.queryKey === queryKey;
237
+ const willFetch = enabled && !!collection;
238
+ const reported = isSettledForCurrentQuery
239
+ ? { items, totalCount, hasMore, status, error }
240
+ : {
241
+ items: NO_ITEMS,
242
+ totalCount: 0,
243
+ hasMore: false,
244
+ status: willFetch ? 'loading' : 'idle',
245
+ error: null,
246
+ };
72
247
  return {
73
- items,
74
- totalCount,
75
- hasMore,
248
+ items: reported.items,
249
+ totalCount: reported.totalCount,
250
+ hasMore: reported.hasMore,
76
251
  pageIndex,
77
- isLoading,
78
- error,
252
+ status: reported.status,
253
+ isLoading: reported.status === 'loading',
254
+ error: reported.error,
79
255
  refetch,
80
256
  goToPage,
81
257
  };
@@ -1,4 +1,5 @@
1
1
  import type { SfaRecord, SfaRelationDescriptor } from '../provider/types';
2
+ import type { SfaListStatus } from './useRecordList';
2
3
  /** @description Parameters for narrowing, sorting, and filtering related records. */
3
4
  export interface UseRelatedRecordsParams {
4
5
  readonly limit?: number;
@@ -16,10 +17,20 @@ export interface UseRelatedRecordsOptions {
16
17
  }
17
18
  /** @description Return value of useRelatedRecords: paginated items, loading/error state, and the resolved relationship descriptor. */
18
19
  export interface UseRelatedRecordsResult {
20
+ /**
21
+ * @description Records from the last successful fetch. An empty array is
22
+ * only meaningful when `status` is `'success'` — see useRecordList.
23
+ */
19
24
  readonly items: readonly SfaRecord[];
20
25
  readonly totalCount: number;
21
26
  readonly hasMore: boolean;
22
27
  readonly pageIndex: number;
28
+ /**
29
+ * @description Lifecycle across all three steps (describe, relationship
30
+ * resolution, list fetch). `'error'` covers a failed describe, a missing
31
+ * relationship, and a failed list fetch.
32
+ */
33
+ readonly status: SfaListStatus;
23
34
  readonly isLoading: boolean;
24
35
  readonly error: Error | null;
25
36
  readonly refetch: () => void;
@@ -1,4 +1,4 @@
1
- import { useMemo, useRef } from 'react';
1
+ import { useCallback, useMemo, useRef } from 'react';
2
2
  import { useDescribe } from './useDescribe';
3
3
  import { useRecordList } from './useRecordList';
4
4
  // ── Error ──
@@ -71,7 +71,7 @@ export function useRelatedRecords(parentCollection, parentId, relationTarget, pa
71
71
  // In the grandchild pattern the second hook's parentId starts as []
72
72
  // while the first hook fetches; starting describe early lets both
73
73
  // metadata calls overlap instead of running in sequence.
74
- const { object: parentObject, isLoading: isParentDescribeLoading, error: parentDescribeError, } = useDescribe(parentCollection, {
74
+ const { object: parentObject, isLoading: isParentDescribeLoading, error: parentDescribeError, refetch: refetchParentDescribe, } = useDescribe(parentCollection, {
75
75
  enabled,
76
76
  });
77
77
  // Only trust parentObject while it matches the current collection;
@@ -94,7 +94,7 @@ export function useRelatedRecords(parentCollection, parentId, relationTarget, pa
94
94
  parentObjectMatches &&
95
95
  !isParentDescribeLoading &&
96
96
  !forwardRelationship;
97
- const { object: childObject, isLoading: isChildDescribeLoading, error: childDescribeError, } = useDescribe(relationTarget, {
97
+ const { object: childObject, isLoading: isChildDescribeLoading, error: childDescribeError, refetch: refetchChildDescribe, } = useDescribe(relationTarget, {
98
98
  enabled: needsReverseLookup,
99
99
  });
100
100
  const childObjectMatches = (childObject === null || childObject === void 0 ? void 0 : childObject.key) === relationTarget;
@@ -213,14 +213,54 @@ export function useRelatedRecords(parentCollection, parentId, relationTarget, pa
213
213
  (recordList.isLoading || !listHasStartedRef.current));
214
214
  const describeError = parentDescribeError !== null && parentDescribeError !== void 0 ? parentDescribeError : (needsReverseLookup ? childDescribeError : null);
215
215
  const error = (_c = describeError !== null && describeError !== void 0 ? describeError : relationError) !== null && _c !== void 0 ? _c : recordList.error;
216
+ // Report the merged lifecycle, not just the list's. A failed describe or an
217
+ // unresolved relationship also leaves items empty, and a caller that reads
218
+ // items without status would render that as "no related records".
219
+ const status = (() => {
220
+ if (error)
221
+ return 'error';
222
+ if (isLoading)
223
+ return 'loading';
224
+ if (!listShouldBeActive)
225
+ return 'idle';
226
+ return recordList.status;
227
+ })();
228
+ // Retry whatever failed. The list is only enabled once a relationship is
229
+ // resolved, so while it is not, refetching the list issues no request at all
230
+ // and a retry control wired to it does nothing. Everything before the list
231
+ // comes from describe, so that is what gets retried — including
232
+ // RelationNotFoundError, which clears if the schema gains the relationship.
233
+ const refetchList = recordList.refetch;
234
+ const refetch = useCallback(() => {
235
+ if (relationship == null || parentDescribeError != null) {
236
+ refetchParentDescribe();
237
+ if (needsReverseLookup)
238
+ refetchChildDescribe();
239
+ return;
240
+ }
241
+ if (needsReverseLookup && childDescribeError != null) {
242
+ refetchChildDescribe();
243
+ return;
244
+ }
245
+ refetchList();
246
+ }, [
247
+ relationship,
248
+ parentDescribeError,
249
+ refetchParentDescribe,
250
+ needsReverseLookup,
251
+ childDescribeError,
252
+ refetchChildDescribe,
253
+ refetchList,
254
+ ]);
216
255
  return {
217
256
  items: recordList.items,
218
257
  totalCount: recordList.totalCount,
219
258
  hasMore: recordList.hasMore,
220
259
  pageIndex: recordList.pageIndex,
260
+ status,
221
261
  isLoading,
222
262
  error,
223
- refetch: recordList.refetch,
263
+ refetch,
224
264
  goToPage: recordList.goToPage,
225
265
  relationship,
226
266
  };
@@ -12,11 +12,18 @@ export declare class SfaClient implements SfaClientInterface {
12
12
  private readonly organizationId;
13
13
  private readonly onAuthError;
14
14
  readonly mode: SfaApiMode;
15
+ /** In-memory cache for describe results, keyed by objectKey. Stores Promises
16
+ * so concurrent callers share a single in-flight request. */
17
+ private readonly describeCache;
15
18
  constructor(config: SfaClientConfig);
16
19
  private resolveBase;
17
20
  get(collection: string, recordId: string): Promise<SfaRecord>;
18
21
  list(collection: string, params?: SfaListParams): Promise<SfaListResponse>;
19
22
  describe(objectKey: string): Promise<SfaDescribeResult>;
23
+ /** Cached describe fetch. Returns a shared Promise so concurrent callers
24
+ * for the same objectKey deduplicate into one HTTP request. On failure the
25
+ * entry is evicted so the next caller retries. */
26
+ private describeObject;
20
27
  listObjects(): Promise<SfaObjectCatalog>;
21
28
  listFieldTypes(): Promise<SfaFieldTypeCatalog>;
22
29
  private assertDirectMode;
@@ -33,6 +40,12 @@ export declare class SfaClient implements SfaClientInterface {
33
40
  * another write landed first.
34
41
  */
35
42
  update(collection: string, recordId: string, fields: Record<string, unknown>, version: string): Promise<SfaRecord>;
43
+ /**
44
+ * @description Fetches describe metadata (with cache) and normalizes
45
+ * cardinality:"many" reference fields to arrays. Falls back to no-op if
46
+ * describe fails, so a broken metadata endpoint never blocks writes.
47
+ */
48
+ private applySchemaAwareNormalization;
36
49
  /**
37
50
  * @description Deletes a record with optimistic locking. Returns void on
38
51
  * success (204). Throws SfaConflictError on 412 if the record was modified
@@ -21,6 +21,9 @@ function timeoutSignal() {
21
21
  */
22
22
  export class SfaClient {
23
23
  constructor(config) {
24
+ /** In-memory cache for describe results, keyed by objectKey. Stores Promises
25
+ * so concurrent callers share a single in-flight request. */
26
+ this.describeCache = new Map();
24
27
  this.apiBase = this.resolveBase(config.apiBase.replace(/\/+$/, ''), config.mode);
25
28
  this.auth = config.auth;
26
29
  this.mode = config.mode;
@@ -61,6 +64,16 @@ export class SfaClient {
61
64
  // offset is a record offset, not a page number: pageIndex = floor(offset / pageSize)
62
65
  const pageIndex = pageSize > 0 ? Math.floor(offset / pageSize) : 0;
63
66
  if (this.mode === 'direct') {
67
+ // Keystone paginates by cursor only: records/query takes no offset, so a
68
+ // later page can be reached only with the cursor that leads to it.
69
+ // Answering such a request with the first page while reporting the
70
+ // requested pageIndex would claim data the server never returned, so say
71
+ // it cannot be served instead.
72
+ if (offset > 0 && !(params === null || params === void 0 ? void 0 : params.cursor)) {
73
+ throw new Error(`Cannot start at record ${offset}: this API paginates by cursor. ` +
74
+ 'Reach later pages with goToPage from the first one, or pass the ' +
75
+ 'cursor for the page you want.');
76
+ }
64
77
  const body = {
65
78
  limit: pageSize,
66
79
  };
@@ -101,9 +114,25 @@ export class SfaClient {
101
114
  }
102
115
  describe(objectKey) {
103
116
  return __awaiter(this, void 0, void 0, function* () {
104
- const raw = yield this.fetchGet(this.objectsPath(`/${enc(objectKey)}`));
105
- return { object: normalizeObjectDetail(raw) };
117
+ const object = yield this.describeObject(objectKey);
118
+ return { object };
119
+ });
120
+ }
121
+ /** Cached describe fetch. Returns a shared Promise so concurrent callers
122
+ * for the same objectKey deduplicate into one HTTP request. On failure the
123
+ * entry is evicted so the next caller retries. */
124
+ describeObject(objectKey) {
125
+ const cached = this.describeCache.get(objectKey);
126
+ if (cached)
127
+ return cached;
128
+ const promise = this.fetchGet(this.objectsPath(`/${enc(objectKey)}`))
129
+ .then(raw => normalizeObjectDetail(raw))
130
+ .catch((err) => {
131
+ this.describeCache.delete(objectKey);
132
+ throw err;
106
133
  });
134
+ this.describeCache.set(objectKey, promise);
135
+ return promise;
107
136
  }
108
137
  listObjects() {
109
138
  return __awaiter(this, void 0, void 0, function* () {
@@ -142,12 +171,13 @@ export class SfaClient {
142
171
  create(collection, fields, options) {
143
172
  return __awaiter(this, void 0, void 0, function* () {
144
173
  this.assertDirectMode();
174
+ const normalized = yield this.applySchemaAwareNormalization(collection, fields);
145
175
  const path = `/v1/objects/${enc(collection)}/records`;
146
176
  const extra = {};
147
177
  if (options === null || options === void 0 ? void 0 : options.idempotencyKey) {
148
178
  extra['Idempotency-Key'] = options.idempotencyKey;
149
179
  }
150
- const response = yield this.fetchWithBody('POST', path, { fields: normalizeWriteFields(fields) }, extra);
180
+ const response = yield this.fetchWithBody('POST', path, { fields: normalizeWriteFields(normalized) }, extra);
151
181
  const raw = yield response.json();
152
182
  return normalizeKeystoneRecord(raw);
153
183
  });
@@ -161,12 +191,32 @@ export class SfaClient {
161
191
  return __awaiter(this, void 0, void 0, function* () {
162
192
  this.assertDirectMode();
163
193
  this.assertRecordId(recordId);
194
+ const normalized = yield this.applySchemaAwareNormalization(collection, fields);
164
195
  const path = `/v1/objects/${enc(collection)}/records/${enc(recordId)}`;
165
- const response = yield this.fetchWithBody('PATCH', path, { fields: normalizeWriteFields(fields) }, { 'If-Match': version });
196
+ const response = yield this.fetchWithBody('PATCH', path, { fields: normalizeWriteFields(normalized) }, { 'If-Match': version });
166
197
  const raw = yield response.json();
167
198
  return normalizeKeystoneRecord(raw);
168
199
  });
169
200
  }
201
+ /**
202
+ * @description Fetches describe metadata (with cache) and normalizes
203
+ * cardinality:"many" reference fields to arrays. Falls back to no-op if
204
+ * describe fails, so a broken metadata endpoint never blocks writes.
205
+ */
206
+ applySchemaAwareNormalization(collection, fields) {
207
+ return __awaiter(this, void 0, void 0, function* () {
208
+ try {
209
+ const detail = yield this.describeObject(collection);
210
+ return normalizeCardinalityFields(fields, detail.fields);
211
+ }
212
+ catch (_a) {
213
+ // Describe failed -- proceed without cardinality normalization.
214
+ // The write will either succeed (no many-cardinality fields in payload)
215
+ // or fail with the same 422 it would have before this feature.
216
+ return fields;
217
+ }
218
+ });
219
+ }
170
220
  /**
171
221
  * @description Deletes a record with optimistic locking. Returns void on
172
222
  * success (204). Throws SfaConflictError on 412 if the record was modified
@@ -293,6 +343,49 @@ function enc(segment) {
293
343
  return encodeURIComponent(segment);
294
344
  }
295
345
  // ── Keystone record normalization (direct mode) ──
346
+ // ── Write-path normalization ──
347
+ /** Field kinds whose values must be arrays when cardinality is 'many'. */
348
+ const ARRAY_WRAPPABLE_KINDS = new Set([
349
+ 'reference',
350
+ 'user_reference',
351
+ ]);
352
+ /**
353
+ * @description Wraps scalar values in arrays for fields whose schema declares
354
+ * cardinality:"many". Already-array values, null, and undefined are passed
355
+ * through unchanged. Runs *before* normalizeWriteFields so SfaFieldValue
356
+ * wrappers are still intact; when a wrapper is detected its inner `.value`
357
+ * is wrapped while the wrapper structure is preserved.
358
+ */
359
+ function normalizeCardinalityFields(fields, descriptors) {
360
+ const manyFields = new Set();
361
+ for (const desc of descriptors) {
362
+ if (desc.cardinality === 'many' && ARRAY_WRAPPABLE_KINDS.has(desc.kind)) {
363
+ manyFields.add(desc.key);
364
+ }
365
+ }
366
+ if (manyFields.size === 0)
367
+ return fields;
368
+ const result = {};
369
+ for (const [key, value] of Object.entries(fields)) {
370
+ if (manyFields.has(key) && value != null && !Array.isArray(value)) {
371
+ if (isSfaFieldValue(value)) {
372
+ // Wrap the inner value; preserve the SfaFieldValue wrapper so the
373
+ // subsequent normalizeWriteFields step can still unwrap it.
374
+ const inner = value.value;
375
+ result[key] =
376
+ inner != null && !Array.isArray(inner)
377
+ ? Object.assign(Object.assign({}, value), { value: [inner] }) : value;
378
+ }
379
+ else {
380
+ result[key] = [value];
381
+ }
382
+ }
383
+ else {
384
+ result[key] = value;
385
+ }
386
+ }
387
+ return result;
388
+ }
296
389
  /**
297
390
  * @description Converts a caller-supplied fields map into the raw values
298
391
  * expected by Keystone's RecordWrite body. If a value is already a raw value
@@ -1 +1 @@
1
- export declare const SFA_KIT_VERSION = "1.8.0";
1
+ export declare const SFA_KIT_VERSION = "1.10.0";
@@ -1,3 +1,3 @@
1
1
  // このファイルは libs/sfa-kit/scripts/sync-version.cjs が生成する。
2
2
  // 手で編集しないこと。バージョンを上げるときは libs/sfa-kit/package.json を変更する。
3
- export const SFA_KIT_VERSION = '1.8.0';
3
+ export const SFA_KIT_VERSION = '1.10.0';