@geniee-inc/sfa-kit 1.14.1 → 1.16.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
@@ -286,9 +286,11 @@ that did land, and those are not recoverable anywhere else.
286
286
  whole picture leaves it looking neither written nor retryable. Re-read the
287
287
  records to find out what landed
288
288
  - `failed` — the rows that did not land **and can be sent again**: `conflict`,
289
- `invalid` and `not_attempted`. Retry exactly these. What makes that safe is
290
- that they did not apply; there is no server-side de-duplication to fall back
291
- on
289
+ `invalid`, `not_attempted`, `not_found` and `not_permitted`. Retry exactly
290
+ these. What makes that safe is that they did not apply; there is no
291
+ server-side de-duplication to fall back on. Read each row's `status` before
292
+ offering a retry: `not_found` and `not_permitted` are the server's settled
293
+ answer, and resending alone will not change it
292
294
  - `unresolved` — the rows that may already be written but cannot be reported
293
295
  either way. They keep whatever `id` arrived. **Re-read these; never retry
294
296
  them**: a create that already landed would be written twice
@@ -375,7 +377,9 @@ return (
375
377
  <ErrorSnackbar
376
378
  error={null}
377
379
  message={`${results.length} 件中 ${updated.length} 件を保存しました。${failed.length} 件は保存されていません。`}
378
- onRetry={() => void bulkUpdate(rowsFor(failed)).catch(() => undefined)}
380
+ onRetry={() =>
381
+ void bulkUpdate(rowsFor(resendable(failed))).catch(() => undefined)
382
+ }
379
383
  onClose={reset}
380
384
  />
381
385
  )}
@@ -385,17 +389,42 @@ return (
385
389
 
386
390
  Counts only: which row failed and why belongs in the grid, where the reader
387
391
  acts on it — `results` carries each row's `index` and its per-field errors.
388
- Offer `onRetry` for `failed` rows; `unresolved` rows are re-read instead, so
389
- label the action accordingly (`retryLabel`).
392
+ `unresolved` rows are re-read instead, so label the action accordingly
393
+ (`retryLabel`).
394
+
395
+ Offer `onRetry` for the `failed` rows a resend can still change — that is what
396
+ `resendable` stands for above:
397
+
398
+ ```tsx
399
+ const SETTLED = new Set(['not_found', 'not_permitted']);
400
+
401
+ const resendable = (rows: readonly SfaBulkRowResult[]) =>
402
+ rows.filter(row => !SETTLED.has(row.status));
403
+ ```
404
+
405
+ Every row in `failed` is safe to send again, but a `not_found` or
406
+ `not_permitted` one comes back refused however often it is sent. Showing those
407
+ under a retry button gives the reader an action that cannot succeed; report
408
+ what they say instead.
390
409
 
391
410
  A row counts as written only when it carries what a write produces: the record
392
411
  for a create or an update, the id for a delete. Anything short of that is
393
412
  `failed` or `unresolved`, and which one is decided by what the answer says,
394
- not by elimination. Three statuses mean the row did not apply, and only those
395
- may be sent again: `conflict`, `invalid`, and `not_attempted` — the last for a
396
- row the call ran out of budget before starting, which is resent exactly as it
397
- was. A `conflict` carries the server's current record: retry with the
398
- `version` it reports, or re-read first if none arrived.
413
+ not by elimination. Five statuses mean the row did not apply, and only those
414
+ may be sent again:
415
+
416
+ | Status | What it means | What to do |
417
+ | --------------- | -------------------------------------------------- | -------------------------------------------------------------------------- |
418
+ | `conflict` | Another write got there first | Retry with the `version` the row reports, or re-read first if none arrived |
419
+ | `invalid` | The row failed validation | Correct the row from its per-field errors, then resend |
420
+ | `not_attempted` | The call ran out of budget before starting the row | Resend exactly as it was |
421
+ | `not_found` | The record the row named does not exist | Tell the reader; a resend answers the same |
422
+ | `not_permitted` | The caller may not write that record | Tell the reader; a resend answers the same |
423
+
424
+ `not_found` and `not_permitted` are refusals of a row the call did reach, so no
425
+ write happened and resending is safe — it is simply futile until the record or
426
+ the permission changes. Their `error.code` may be an empty string: keystone has
427
+ not minted one for `not_permitted`, so branch on `status` rather than `code`.
399
428
 
400
429
  Everything else is `unresolved`: `indeterminate`, the server saying it cannot
401
430
  tell whether the row applied; a claimed success missing what it should carry —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geniee-inc/sfa-kit",
3
- "version": "1.14.1",
3
+ "version": "1.16.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",
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from 'react';
2
+ interface ObjectKeyGuardProps {
3
+ readonly objectKey: string;
4
+ readonly children: ReactNode;
5
+ }
6
+ /**
7
+ * objectKey が組織のオブジェクトカタログに存在することを検証する。
8
+ * 不一致の場合はページ本体の代わりにエラーバナーを表示し、
9
+ * singular / typo のキーがリフレッシュ時に 404 で壊れる前に検出する。
10
+ */
11
+ export declare function ObjectKeyGuard({ objectKey, children }: ObjectKeyGuardProps): JSX.Element;
12
+ export {};
@@ -0,0 +1,61 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useObjectCatalog } from '../hooks/useObjectCatalog';
3
+ import { font, space } from '../theme';
4
+ /**
5
+ * objectKey が組織のオブジェクトカタログに存在することを検証する。
6
+ * 不一致の場合はページ本体の代わりにエラーバナーを表示し、
7
+ * singular / typo のキーがリフレッシュ時に 404 で壊れる前に検出する。
8
+ */
9
+ export function ObjectKeyGuard({ objectKey, children }) {
10
+ const { objects, isLoading, error } = useObjectCatalog();
11
+ // カタログ取得中やエラー時はブロックせずそのまま表示する。
12
+ // ガードが止めるのは「カタログに無い」と確定した場合のみ。
13
+ if (isLoading || error || objects.length === 0)
14
+ return _jsx(_Fragment, { children: children });
15
+ const knownKeys = objects.map(o => o.key);
16
+ if (knownKeys.includes(objectKey))
17
+ return _jsx(_Fragment, { children: children });
18
+ // 提案のみ大文字小文字を無視する。一致判定は API がキーを厳密比較するため厳密のまま
19
+ const target = objectKey.toLowerCase();
20
+ const suggestion = knownKeys.find(knownKey => {
21
+ const key = knownKey.toLowerCase();
22
+ return key.startsWith(target) || target.startsWith(key);
23
+ });
24
+ return (_jsx("div", { style: {
25
+ padding: space.lg,
26
+ fontFamily: font.family,
27
+ maxWidth: 560,
28
+ margin: '80px auto',
29
+ }, children: _jsxs("div", { style: {
30
+ background: '#FEF2F2',
31
+ border: '1px solid #FECACA',
32
+ borderRadius: 8,
33
+ padding: space.lg,
34
+ }, children: [_jsx("div", { style: {
35
+ fontWeight: 700,
36
+ fontSize: 15,
37
+ color: '#991B1B',
38
+ marginBottom: 8,
39
+ }, children: "Invalid object key" }), _jsxs("p", { style: {
40
+ margin: 0,
41
+ fontSize: 14,
42
+ color: '#7F1D1D',
43
+ lineHeight: 1.5,
44
+ }, children: [_jsx("code", { style: {
45
+ background: '#FEE2E2',
46
+ padding: '2px 6px',
47
+ borderRadius: 4,
48
+ fontSize: 13,
49
+ }, children: objectKey }), ' ', "does not match any object in this organization.", suggestion && (_jsxs(_Fragment, { children: [' ', "Did you mean", ' ', _jsx("code", { style: {
50
+ background: '#DCFCE7',
51
+ padding: '2px 6px',
52
+ borderRadius: 4,
53
+ fontSize: 13,
54
+ color: '#166534',
55
+ }, children: suggestion }), "?"] }))] }), _jsx("p", { style: {
56
+ margin: '12px 0 0',
57
+ fontSize: 12,
58
+ color: '#9CA3AF',
59
+ lineHeight: 1.4,
60
+ }, children: "Object keys are the plural collection name from the object catalogue (e.g. \"business_discussions\", not \"business_discussion\"). Check the objectKey prop on SfaLoginGate and the manifest." })] }) }));
61
+ }
@@ -6,6 +6,7 @@ import { color, font, radius, space, baseContainerStyle, baseButtonStyle, } from
6
6
  import { SFA_KIT_VERSION } from '../version';
7
7
  import { DevToolbar } from './DevToolbar';
8
8
  import { authenticate } from './keystone-auth';
9
+ import { ObjectKeyGuard } from './ObjectKeyGuard';
9
10
  import { KEYSTONE_PRODUCTION_API_BASE, resolveApiBase, } from './resolve-api-base';
10
11
  import { loadSession, saveSession, clearSession, loadLastOrgId, } from './session-store';
11
12
  import { StandalonePreviewCtx } from './StandalonePreviewContext';
@@ -102,7 +103,7 @@ export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, ob
102
103
  return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u3067\u304D\u307E\u305B\u3093", message: "SFA/CRM \u306E\u30DB\u30B9\u30C8\u30AA\u30EA\u30B8\u30F3\u3092\u78BA\u8A8D\u3067\u304D\u306A\u3044\u305F\u3081\u3001\u3053\u306E\u30DA\u30FC\u30B8\u3092\u8868\u793A\u3067\u304D\u307E\u305B\u3093\u3002\u57CB\u3081\u8FBC\u307F\u8A2D\u5B9A\u3092\u7BA1\u7406\u8005\u306B\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044\u3002" }));
103
104
  }
104
105
  // Embedded: the SFA/CRM host that framed us decides the environment.
105
- return (_jsx(EmbedFlow, { trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
106
+ return (_jsx(EmbedFlow, { objectKey: objectKey, trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
106
107
  }
107
108
  // Standalone: there is no host, so the page's own origin decides.
108
109
  return (_jsx(StandaloneFlow, { apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvePageOrigin()), defaultOrganizationId: defaultOrganizationId, objectKey: objectKey, level: level, children: children }));
@@ -110,7 +111,7 @@ export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, ob
110
111
  // ---------------------------------------------------------------------------
111
112
  // Embed flow (iframe + postMessage)
112
113
  // ---------------------------------------------------------------------------
113
- function EmbedFlow({ trustedOrigin, apiBase, children, }) {
114
+ function EmbedFlow({ objectKey, trustedOrigin, apiBase, children, }) {
114
115
  const normalizedBase = useMemo(() => apiBase.replace(/\/+$/, ''), [apiBase]);
115
116
  const { phase, handleAuthError } = useEmbedSession({
116
117
  apiBase: normalizedBase,
@@ -135,7 +136,7 @@ function EmbedFlow({ trustedOrigin, apiBase, children, }) {
135
136
  }
136
137
  if (!auth)
137
138
  return null;
138
- return (_jsx(EmbedContextValue.Provider, { value: phase.context, children: _jsx(SfaProvider, { apiBase: normalizedBase, auth: auth, mode: "direct", organizationId: phase.session.organizationId, onAuthError: handleAuthError, children: children }) }));
139
+ return (_jsx(EmbedContextValue.Provider, { value: phase.context, children: _jsx(SfaProvider, { apiBase: normalizedBase, auth: auth, mode: "direct", organizationId: phase.session.organizationId, onAuthError: handleAuthError, children: objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children) }) }));
139
140
  }
140
141
  function EmbedStatus({ title, message, showReload, }) {
141
142
  const handleReload = useCallback(() => {
@@ -241,7 +242,7 @@ function StandaloneFlow({ apiBase, defaultOrganizationId, objectKey, level, chil
241
242
  if (!session || !auth) {
242
243
  return (_jsx(LoginForm, { defaultApiBase: apiBase, defaultOrganizationId: defaultOrganizationId, onSubmit: handleLogin, status: status, error: error }));
243
244
  }
244
- return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }), children] }) }));
245
+ return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }), objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children)] }) }));
245
246
  }
246
247
  // ---------------------------------------------------------------------------
247
248
  // GENIEE SFA/CRM-branded login form
@@ -8,7 +8,9 @@ export interface BulkRowSplit<T> {
8
8
  readonly conflicts: readonly SfaBulkRowResult[];
9
9
  /**
10
10
  * The rows that did not land and can be sent again. A `conflict` is one of
11
- * them, and also appears in `conflicts`.
11
+ * them, and also appears in `conflicts`. Read each row's `status` to say why
12
+ * it failed: `not_found` and `not_permitted` are settled refusals that a
13
+ * resend alone will not clear.
12
14
  */
13
15
  readonly failed: readonly SfaBulkRowResult[];
14
16
  }
@@ -16,9 +18,11 @@ export interface BulkRowSplit<T> {
16
18
  * @description Splits per-row outcomes by what the caller may do with them.
17
19
  *
18
20
  * NOTE: `failed` is decided by what the answer says, never by exclusion.
19
- * `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
20
- * those are the ones safe to send again — a row the call never started is as
21
- * unwritten as one the server rejected. Anything else the kit cannot place —
21
+ * `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
22
+ * mean the row did not apply, so those are the ones safe to send again — a row
23
+ * the call never started is as unwritten as one the server refused. Each keeps
24
+ * its own `status`, so a caller can tell "no permission" from "failed
25
+ * validation" without a bucket per reason. Anything else the kit cannot place —
22
26
  * `indeterminate`, no record, no version, a status outside the contract or
23
27
  * belonging to another operation — may already be written, and a retry would
24
28
  * write it twice.
@@ -1,16 +1,29 @@
1
- /** The outcomes that mean the row did not apply, so sending it again is safe. */
1
+ /**
2
+ * The outcomes that mean the row did not apply, so sending it again is safe.
3
+ *
4
+ * NOTE: safe to send again, not sure to succeed. `invalid` needs the row
5
+ * corrected first, `conflict` a fresh `version`, and `not_found` /
6
+ * `not_permitted` answer the same until the record or the permission changes.
7
+ * What the set decides is that no write happened, so the caller may resend
8
+ * without writing a second record — which is the question `unresolved` exists
9
+ * to answer, and the reason these belong in `failed` instead.
10
+ */
2
11
  const RETRYABLE_STATUSES = new Set([
3
12
  'conflict',
4
13
  'invalid',
5
14
  'not_attempted',
15
+ 'not_found',
16
+ 'not_permitted',
6
17
  ]);
7
18
  /**
8
19
  * @description Splits per-row outcomes by what the caller may do with them.
9
20
  *
10
21
  * NOTE: `failed` is decided by what the answer says, never by exclusion.
11
- * `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
12
- * those are the ones safe to send again — a row the call never started is as
13
- * unwritten as one the server rejected. Anything else the kit cannot place —
22
+ * `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
23
+ * mean the row did not apply, so those are the ones safe to send again — a row
24
+ * the call never started is as unwritten as one the server refused. Each keeps
25
+ * its own `status`, so a caller can tell "no permission" from "failed
26
+ * validation" without a bucket per reason. Anything else the kit cannot place —
14
27
  * `indeterminate`, no record, no version, a status outside the contract or
15
28
  * belonging to another operation — may already be written, and a retry would
16
29
  * write it twice.
@@ -18,7 +18,9 @@ export interface UseBulkCreateRecordsResult {
18
18
  /**
19
19
  * The rows that did not land and can be sent again, with their `index` and
20
20
  * `error`. A batch can be partly successful, so this being non-empty does
21
- * not mean nothing was written — retry exactly these rows.
21
+ * not mean nothing was written. Read each row's `status` before retrying:
22
+ * `not_found` and `not_permitted` are settled refusals that come back the
23
+ * same however often they are resent.
22
24
  */
23
25
  readonly failed: readonly SfaBulkRowResult[];
24
26
  /**
@@ -50,7 +52,10 @@ export interface UseBulkCreateRecordsResult {
50
52
  * the call as a whole.
51
53
  *
52
54
  * Retry only the rows in `failed`: those did not apply, which is what makes
53
- * resending them safe. Re-read the rows in `unresolved` instead — nothing
54
- * protects against writing them twice.
55
+ * resending them safe — safe, not sure to succeed. A `not_found` or
56
+ * `not_permitted` row is refused again until the record or the permission
57
+ * changes, so branch on `status` rather than offering one retry for the whole
58
+ * bucket. Re-read the rows in `unresolved` instead — nothing protects against
59
+ * writing them twice.
55
60
  */
56
61
  export declare function useBulkCreateRecords(collection: string): UseBulkCreateRecordsResult;
@@ -14,8 +14,11 @@ const sendBulkCreate = (client, collection, rows) => client.bulkCreate(collectio
14
14
  * the call as a whole.
15
15
  *
16
16
  * Retry only the rows in `failed`: those did not apply, which is what makes
17
- * resending them safe. Re-read the rows in `unresolved` instead — nothing
18
- * protects against writing them twice.
17
+ * resending them safe — safe, not sure to succeed. A `not_found` or
18
+ * `not_permitted` row is refused again until the record or the permission
19
+ * changes, so branch on `status` rather than offering one retry for the whole
20
+ * bucket. Re-read the rows in `unresolved` instead — nothing protects against
21
+ * writing them twice.
19
22
  */
20
23
  export function useBulkCreateRecords(collection) {
21
24
  const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkCreate);
@@ -20,7 +20,16 @@ export interface UseBulkDeleteRecordsResult {
20
20
  * whether it still wants the record gone.
21
21
  */
22
22
  readonly conflicts: readonly SfaBulkRowResult[];
23
- /** The rows still in place that can be sent again, conflicts included. */
23
+ /**
24
+ * The rows that were not removed by this call, conflicts included.
25
+ *
26
+ * NOTE: not all of them are still in place. A `not_found` row says the
27
+ * record was not there to delete — already gone, or never existing. It is
28
+ * reported here rather than in `deletedIds` because this call removed
29
+ * nothing, and the kit will not claim a deletion the server did not make.
30
+ * A caller whose grid wants the row cleared checks for that `status`;
31
+ * `not_permitted` is the one that leaves the record in place.
32
+ */
24
33
  readonly failed: readonly SfaBulkRowResult[];
25
34
  /**
26
35
  * The rows that may already be removed but cannot be reported either way.
@@ -46,5 +55,9 @@ export interface UseBulkDeleteRecordsResult {
46
55
  * place while the rest of the batch applies. There is no rollback, so a
47
56
  * partly failed batch has already removed records — which is why per-row
48
57
  * outcomes are returned as data rather than thrown.
58
+ *
59
+ * Retry only the rows in `failed`, and read each row's `status` first: a
60
+ * `not_found` row was not there to delete and a `not_permitted` one may not be
61
+ * deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
49
62
  */
50
63
  export declare function useBulkDeleteRecords(collection: string): UseBulkDeleteRecordsResult;
@@ -11,6 +11,10 @@ const sendBulkDelete = (client, collection, rows) => client.bulkDelete(collectio
11
11
  * place while the rest of the batch applies. There is no rollback, so a
12
12
  * partly failed batch has already removed records — which is why per-row
13
13
  * outcomes are returned as data rather than thrown.
14
+ *
15
+ * Retry only the rows in `failed`, and read each row's `status` first: a
16
+ * `not_found` row was not there to delete and a `not_permitted` one may not be
17
+ * deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
14
18
  */
15
19
  export function useBulkDeleteRecords(collection) {
16
20
  const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkDelete);
@@ -21,7 +21,11 @@ export interface UseBulkUpdateRecordsResult {
21
21
  * without a `version`, re-read it first — there is nothing to lock against.
22
22
  */
23
23
  readonly conflicts: readonly SfaBulkRowResult[];
24
- /** The rows that did not land and can be sent again, conflicts included. */
24
+ /**
25
+ * The rows that did not land and can be sent again, conflicts included.
26
+ * Read each row's `status`: `not_found` and `not_permitted` are settled
27
+ * refusals, and a resend alone will not clear them.
28
+ */
25
29
  readonly failed: readonly SfaBulkRowResult[];
26
30
  /**
27
31
  * The rows that may already be applied but cannot be reported either way.
@@ -49,7 +53,9 @@ export interface UseBulkUpdateRecordsResult {
49
53
  * as data rather than thrown errors.
50
54
  *
51
55
  * Retry only the rows in `failed`. A conflict is retried with the version from
52
- * its `record`, never by re-sending the stale one. Rows in `unresolved` are
56
+ * its `record`, never by re-sending the stale one. A `not_found` or
57
+ * `not_permitted` row is refused again until the record or the permission
58
+ * changes — report it rather than offering a retry. Rows in `unresolved` are
53
59
  * re-read, not retried.
54
60
  */
55
61
  export declare function useBulkUpdateRecords(collection: string): UseBulkUpdateRecordsResult;
@@ -13,7 +13,9 @@ const sendBulkUpdate = (client, collection, rows) => client.bulkUpdate(collectio
13
13
  * as data rather than thrown errors.
14
14
  *
15
15
  * Retry only the rows in `failed`. A conflict is retried with the version from
16
- * its `record`, never by re-sending the stale one. Rows in `unresolved` are
16
+ * its `record`, never by re-sending the stale one. A `not_found` or
17
+ * `not_permitted` row is refused again until the record or the permission
18
+ * changes — report it rather than offering a retry. Rows in `unresolved` are
17
19
  * re-read, not retried.
18
20
  */
19
21
  export function useBulkUpdateRecords(collection) {
@@ -35,6 +35,7 @@ export declare class SfaClient implements SfaClientInterface {
35
35
  /** In-memory cache for describe results, keyed by objectKey. Stores Promises
36
36
  * so concurrent callers share a single in-flight request. */
37
37
  private readonly describeCache;
38
+ private listObjectsCache;
38
39
  constructor(config: SfaClientConfig);
39
40
  private resolveBase;
40
41
  get(collection: string, recordId: string): Promise<SfaRecord>;
@@ -59,6 +59,7 @@ export class SfaClient {
59
59
  /** In-memory cache for describe results, keyed by objectKey. Stores Promises
60
60
  * so concurrent callers share a single in-flight request. */
61
61
  this.describeCache = new Map();
62
+ this.listObjectsCache = null;
62
63
  this.apiBase = this.resolveBase(config.apiBase.replace(/\/+$/, ''), config.mode);
63
64
  this.auth = config.auth;
64
65
  this.mode = config.mode;
@@ -174,11 +175,21 @@ export class SfaClient {
174
175
  }
175
176
  listObjects() {
176
177
  return __awaiter(this, void 0, void 0, function* () {
177
- var _a;
178
- const raw = yield this.fetchGet(this.objectsPath());
179
- const data = raw;
180
- const objects = ((_a = data.objects) !== null && _a !== void 0 ? _a : []).map(o => normalizeObjectSummary(o));
181
- return { objects };
178
+ if (this.listObjectsCache)
179
+ return this.listObjectsCache;
180
+ const promise = this.fetchGet(this.objectsPath())
181
+ .then(raw => {
182
+ var _a;
183
+ const data = raw;
184
+ const objects = ((_a = data.objects) !== null && _a !== void 0 ? _a : []).map(o => normalizeObjectSummary(o));
185
+ return { objects };
186
+ })
187
+ .catch((err) => {
188
+ this.listObjectsCache = null;
189
+ throw err;
190
+ });
191
+ this.listObjectsCache = promise;
192
+ return promise;
182
193
  });
183
194
  }
184
195
  listFieldTypes() {
@@ -1040,6 +1051,8 @@ function readBulkRowStatus(raw) {
1040
1051
  conflict: 'conflict',
1041
1052
  invalid: 'invalid',
1042
1053
  not_attempted: 'not_attempted',
1054
+ not_found: 'not_found',
1055
+ not_permitted: 'not_permitted',
1043
1056
  indeterminate: 'indeterminate',
1044
1057
  };
1045
1058
  return typeof raw === 'string' ? (_a = known[raw]) !== null && _a !== void 0 ? _a : 'unknown' : 'unknown';
@@ -103,13 +103,19 @@ export interface SfaFieldError {
103
103
  * All are reported per row with HTTP 200 — a bulk call is best-effort and has
104
104
  * no all-or-nothing mode.
105
105
  *
106
+ * `not_found` and `not_permitted` are refusals of a row the call did reach:
107
+ * the record it named does not exist, or the caller may not write it. No write
108
+ * happened either way, so both are settled failures and reach the caller in
109
+ * `failed`. Sending them again is safe, but returns the same answer until the
110
+ * record or the permission changes — the decision belongs to the server.
111
+ *
106
112
  * `unknown` is not sent by the API: it is what the kit reads an unrecognised
107
113
  * status as, and what a claimed success it cannot make sense of becomes — no
108
114
  * record, or no `version` for the next write to lock against. The row keeps
109
115
  * its `id`, and reaches the caller in `unresolved`, never in `failed`:
110
116
  * retrying a create that already landed writes a second record.
111
117
  */
112
- export type SfaBulkRowStatus = 'created' | 'updated' | 'deleted' | 'conflict' | 'invalid' | 'not_attempted' | 'indeterminate' | 'unknown';
118
+ export type SfaBulkRowStatus = 'created' | 'updated' | 'deleted' | 'conflict' | 'invalid' | 'not_attempted' | 'not_found' | 'not_permitted' | 'indeterminate' | 'unknown';
113
119
  /** @description One row of a bulk update: what to change and from which version. */
114
120
  export interface SfaBulkUpdateRow {
115
121
  readonly id: string;
@@ -146,12 +152,22 @@ export interface SfaBulkRowResult {
146
152
  * against.
147
153
  */
148
154
  readonly record: SfaRecord | null;
149
- /** Set on `conflict` and `invalid`: the problem detail for this row. */
155
+ /**
156
+ * The problem detail for this row. Set on the statuses that report one:
157
+ * `conflict`, `invalid`, `not_found` and `not_permitted`.
158
+ */
150
159
  readonly error: SfaBulkRowError | null;
151
160
  }
152
161
  /** @description Why one row failed, in the same shape single writes report. */
153
162
  export interface SfaBulkRowError {
154
- /** Machine-readable problem code, e.g. `version_mismatch`. */
163
+ /**
164
+ * Machine-readable problem code, e.g. `version_mismatch`.
165
+ *
166
+ * NOTE: empty when the response carried none. Keystone has not minted a code
167
+ * for `not_permitted` and widened that field of its contract to any string,
168
+ * so a caller that branches on `code` has to treat `''` as "not stated" and
169
+ * fall back to `status`, rather than assume every failure names itself.
170
+ */
155
171
  readonly code: string;
156
172
  readonly message: string;
157
173
  /** Per-field errors when the row failed validation. */
@@ -1 +1 @@
1
- export declare const SFA_KIT_VERSION = "1.14.1";
1
+ export declare const SFA_KIT_VERSION = "1.16.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.14.1';
3
+ export const SFA_KIT_VERSION = '1.16.0';