@geniee-inc/sfa-kit 1.14.0 → 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.0",
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
+ }
@@ -1,22 +1,17 @@
1
1
  import { __awaiter } from "tslib";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useMemo, useState } from 'react';
4
4
  import { SfaProvider } from '../provider/SfaProvider';
5
5
  import { color, font, radius, space, baseContainerStyle, baseButtonStyle, } from '../theme';
6
6
  import { SFA_KIT_VERSION } from '../version';
7
7
  import { DevToolbar } from './DevToolbar';
8
- import { parseInboundMessage, sendToHost } from './embed-protocol';
9
- import { exchangeCode } from './exchange-code';
10
8
  import { authenticate } from './keystone-auth';
9
+ import { ObjectKeyGuard } from './ObjectKeyGuard';
11
10
  import { KEYSTONE_PRODUCTION_API_BASE, resolveApiBase, } from './resolve-api-base';
12
11
  import { loadSession, saveSession, clearSession, loadLastOrgId, } from './session-store';
13
12
  import { StandalonePreviewCtx } from './StandalonePreviewContext';
14
13
  import { EmbedContextValue } from './useEmbedContext';
15
- const REFRESH_BUFFER_SECONDS = 120;
16
- /** After this many consecutive exchange failures, stop retrying and show session_expired. */
17
- const MAX_CONSECUTIVE_FAILURES = 3;
18
- /** Seconds to wait for the host to send a code before showing a connection error. */
19
- const CODE_TIMEOUT_SECONDS = 15;
14
+ import { useEmbedSession } from './useEmbedSession';
20
15
  /**
21
16
  * @description Returns true when the page is running inside an iframe.
22
17
  */
@@ -93,103 +88,35 @@ function resolvePageOrigin() {
93
88
  export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, objectKey, level, children, }) {
94
89
  const shouldEmbed = isEmbedded() && (trustedOrigin != null || hasContentToken());
95
90
  const resolvedOrigin = resolveTrustedOrigin(trustedOrigin);
96
- if (shouldEmbed && resolvedOrigin != null) {
91
+ if (shouldEmbed) {
92
+ // ホストオリジンを 1 つに決められない状態。postMessage の宛先も
93
+ // event.origin の照合相手も決まらないため、埋め込みの手順自体が成立
94
+ // しない。ここでログインフォームに落とすと、どの frame の中にいるか
95
+ // 分からないまま資格情報の入力欄を出すことになるので、描画せずに
96
+ // 理由だけ表示して止める。
97
+ //
98
+ // NOTE: resolveTrustedOrigin は trustedOrigin 未指定時に document.referrer
99
+ // を使う。referrer は埋め込む側が決める値なので、下の EmbedFlow の
100
+ // 経路は「埋め込み元の自己申告を信じる」ことになる。ホストを実際に
101
+ // 限定できるのは trustedOrigin を明示した場合だけ。
102
+ if (resolvedOrigin == null) {
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" }));
104
+ }
97
105
  // Embedded: the SFA/CRM host that framed us decides the environment.
98
- 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 }));
99
107
  }
100
108
  // Standalone: there is no host, so the page's own origin decides.
101
109
  return (_jsx(StandaloneFlow, { apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvePageOrigin()), defaultOrganizationId: defaultOrganizationId, objectKey: objectKey, level: level, children: children }));
102
110
  }
103
- function EmbedFlow({ trustedOrigin, apiBase, children, }) {
104
- const [phase, setPhase] = useState({ step: 'waiting_for_code' });
105
- const refreshTimerRef = useRef(null);
106
- const exchangingRef = useRef(false);
107
- const failureCountRef = useRef(0);
111
+ // ---------------------------------------------------------------------------
112
+ // Embed flow (iframe + postMessage)
113
+ // ---------------------------------------------------------------------------
114
+ function EmbedFlow({ objectKey, trustedOrigin, apiBase, children, }) {
108
115
  const normalizedBase = useMemo(() => apiBase.replace(/\/+$/, ''), [apiBase]);
109
- const clearRefreshTimer = useCallback(() => {
110
- if (refreshTimerRef.current != null) {
111
- clearTimeout(refreshTimerRef.current);
112
- refreshTimerRef.current = null;
113
- }
114
- }, []);
115
- const scheduleRefresh = useCallback((expiresInSeconds) => {
116
- clearRefreshTimer();
117
- const refreshAfterMs = (expiresInSeconds - REFRESH_BUFFER_SECONDS) * 1000;
118
- if (refreshAfterMs <= 0) {
119
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
120
- return;
121
- }
122
- refreshTimerRef.current = setTimeout(() => {
123
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
124
- }, refreshAfterMs);
125
- }, [trustedOrigin, clearRefreshTimer]);
126
- const handleCodeReceived = useCallback((code, context) => __awaiter(this, void 0, void 0, function* () {
127
- if (exchangingRef.current)
128
- return;
129
- exchangingRef.current = true;
130
- setPhase({ step: 'exchanging' });
131
- try {
132
- const session = yield exchangeCode(normalizedBase, code);
133
- failureCountRef.current = 0;
134
- setPhase({ step: 'authenticated', session, context });
135
- const expiresInSeconds = Math.max(0, Math.floor((session.expiresAt - Date.now()) / 1000));
136
- scheduleRefresh(expiresInSeconds);
137
- }
138
- catch (thrown) {
139
- failureCountRef.current += 1;
140
- if (failureCountRef.current >= MAX_CONSECUTIVE_FAILURES) {
141
- setPhase({ step: 'session_expired' });
142
- }
143
- else {
144
- const message = thrown instanceof Error ? thrown.message : 'Code exchange failed';
145
- setPhase({ step: 'error', message });
146
- }
147
- }
148
- finally {
149
- exchangingRef.current = false;
150
- }
151
- }), [normalizedBase, scheduleRefresh]);
152
- const handleAuthError = useCallback(() => {
153
- clearRefreshTimer();
154
- failureCountRef.current += 1;
155
- if (failureCountRef.current >= MAX_CONSECUTIVE_FAILURES) {
156
- setPhase({ step: 'session_expired' });
157
- return;
158
- }
159
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
160
- setPhase({ step: 'waiting_for_code' });
161
- }, [trustedOrigin, clearRefreshTimer]);
162
- useEffect(() => {
163
- const handleMessage = (event) => {
164
- var _a;
165
- const parsed = parseInboundMessage(event, trustedOrigin);
166
- if (!parsed)
167
- return;
168
- if (parsed.type === 'sfa-kit:code') {
169
- void handleCodeReceived(parsed.code, (_a = parsed.context) !== null && _a !== void 0 ? _a : null);
170
- }
171
- };
172
- window.addEventListener('message', handleMessage);
173
- sendToHost({ type: 'sfa-kit:ready' }, trustedOrigin);
174
- // If the host never sends a code (e.g. mintSfaKitExchangeCode is not
175
- // implemented or the host page doesn't support the embed protocol),
176
- // show an error instead of hanging on "Connecting..." forever.
177
- const codeTimeout = setTimeout(() => {
178
- setPhase(current => {
179
- if (current.step !== 'waiting_for_code')
180
- return current;
181
- return {
182
- step: 'error',
183
- message: 'SFA/CRM との接続がタイムアウトしました。ページを再読み込みしてください。',
184
- };
185
- });
186
- }, CODE_TIMEOUT_SECONDS * 1000);
187
- return () => {
188
- window.removeEventListener('message', handleMessage);
189
- clearTimeout(codeTimeout);
190
- clearRefreshTimer();
191
- };
192
- }, [trustedOrigin, handleCodeReceived, clearRefreshTimer]);
116
+ const { phase, handleAuthError } = useEmbedSession({
117
+ apiBase: normalizedBase,
118
+ trustedOrigin,
119
+ });
193
120
  const auth = useMemo(() => {
194
121
  if (phase.step !== 'authenticated')
195
122
  return null;
@@ -199,20 +126,23 @@ function EmbedFlow({ trustedOrigin, apiBase, children, }) {
199
126
  return (_jsx(EmbedStatus, { title: "\u30BB\u30C3\u30B7\u30E7\u30F3\u671F\u9650\u5207\u308C", message: "SFA/CRM \u306E\u30BB\u30C3\u30B7\u30E7\u30F3\u304C\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002\u30DA\u30FC\u30B8\u3092\u518D\u8AAD\u307F\u8FBC\u307F\u3057\u3066\u304F\u3060\u3055\u3044\u3002", showReload: true }));
200
127
  }
201
128
  if (phase.step === 'error') {
202
- return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u30A8\u30E9\u30FC", message: phase.message, showReload: true }));
129
+ return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u30A8\u30E9\u30FC", message: phase.message, showReload: phase.canRecoverByReload }));
130
+ }
131
+ if (phase.step === 'reconnecting') {
132
+ return (_jsx(EmbedStatus, { title: "\u518D\u63A5\u7D9A\u4E2D...", message: "SFA/CRM \u3078\u306E\u63A5\u7D9A\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u81EA\u52D5\u7684\u306B\u518D\u8A66\u884C\u3057\u3066\u3044\u307E\u3059\u3002" }));
203
133
  }
204
134
  if (phase.step === 'waiting_for_code' || phase.step === 'exchanging') {
205
135
  return (_jsx(EmbedStatus, { title: phase.step === 'exchanging' ? '認証中...' : 'SFA/CRM に接続中...', message: "\u30DB\u30B9\u30C8\u30A2\u30D7\u30EA\u30B1\u30FC\u30B7\u30E7\u30F3\u304B\u3089\u306E\u5FDC\u7B54\u3092\u5F85\u3063\u3066\u3044\u307E\u3059\u3002" }));
206
136
  }
207
137
  if (!auth)
208
138
  return null;
209
- 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) }) }));
210
140
  }
211
141
  function EmbedStatus({ title, message, showReload, }) {
212
142
  const handleReload = useCallback(() => {
213
143
  window.location.reload();
214
144
  }, []);
215
- return (_jsx("div", { style: styles.backdrop, children: _jsxs("div", { style: Object.assign(Object.assign({}, styles.card), { textAlign: 'center' }), children: [_jsxs("div", { style: loginStyles.logoGroup, children: [_jsx(GenieeLogoMark, {}), _jsxs("div", { style: loginStyles.logoText, children: [_jsx("span", { style: loginStyles.logoGeniee, children: "GENIEE" }), _jsx("span", { style: loginStyles.logoProduct, children: "SFA/CRM" })] })] }), _jsx("h2", { style: styles.title, children: title }), _jsx("p", { style: styles.subtitle, children: message }), showReload && (_jsx("button", { type: "button", style: styles.reloadButton, onClick: handleReload, children: "\u30DA\u30FC\u30B8\u3092\u518D\u8AAD\u307F\u8FBC\u307F" })), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }) }));
145
+ return (_jsx("div", { style: styles.backdrop, children: _jsxs("div", { role: "status", style: Object.assign(Object.assign({}, styles.card), { textAlign: 'center' }), children: [_jsxs("div", { style: loginStyles.logoGroup, children: [_jsx(GenieeLogoMark, {}), _jsxs("div", { style: loginStyles.logoText, children: [_jsx("span", { style: loginStyles.logoGeniee, children: "GENIEE" }), _jsx("span", { style: loginStyles.logoProduct, children: "SFA/CRM" })] })] }), _jsx("h2", { style: styles.title, children: title }), _jsx("p", { style: styles.subtitle, children: message }), showReload && (_jsx("button", { type: "button", style: styles.reloadButton, onClick: handleReload, children: "\u30DA\u30FC\u30B8\u3092\u518D\u8AAD\u307F\u8FBC\u307F" })), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }) }));
216
146
  }
217
147
  // ---------------------------------------------------------------------------
218
148
  // Standalone flow (login form + DevToolbar)
@@ -312,7 +242,7 @@ function StandaloneFlow({ apiBase, defaultOrganizationId, objectKey, level, chil
312
242
  if (!session || !auth) {
313
243
  return (_jsx(LoginForm, { defaultApiBase: apiBase, defaultOrganizationId: defaultOrganizationId, onSubmit: handleLogin, status: status, error: error }));
314
244
  }
315
- 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)] }) }));
316
246
  }
317
247
  // ---------------------------------------------------------------------------
318
248
  // GENIEE SFA/CRM-branded login form
@@ -0,0 +1,49 @@
1
+ import type { EmbedContext } from './embed-protocol';
2
+ import type { SfaSession } from './types';
3
+ export type EmbedPhase = {
4
+ step: 'waiting_for_code';
5
+ } | {
6
+ step: 'exchanging';
7
+ } | {
8
+ step: 'authenticated';
9
+ session: SfaSession;
10
+ context: EmbedContext | null;
11
+ }
12
+ /** 交換に失敗したが、まだ試行回数が残っている状態。自力で復帰しうる。 */
13
+ | {
14
+ step: 'reconnecting';
15
+ } | {
16
+ step: 'session_expired';
17
+ }
18
+ /**
19
+ * 打ち切って画面を差し替えた状態。canRecoverByReload は再読み込みで
20
+ * 直りうるかどうか。設定の誤りのように直らない失敗で再読み込みを勧めると、
21
+ * 利用者は同じ操作を繰り返すことになる。
22
+ */
23
+ | {
24
+ step: 'error';
25
+ message: string;
26
+ canRecoverByReload: boolean;
27
+ };
28
+ export interface EmbedSessionState {
29
+ readonly phase: EmbedPhase;
30
+ /** Passed to SfaProvider so a 401 triggers the code-request fallback. */
31
+ readonly handleAuthError: () => void;
32
+ }
33
+ /**
34
+ * @description Keeps an embedded page authenticated over the postMessage
35
+ * code-exchange protocol: requests a code on mount, swaps the token before it
36
+ * expires, and falls back to a fresh code on 401.
37
+ *
38
+ * トークンを差し替えている間も phase は `authenticated` のまま据え置く。
39
+ * 状態画面に切り替えると子ツリーがアンマウントされ、埋め込みページの入力内容と
40
+ * React の状態が失われるため(authentication.md 条件 1)。
41
+ *
42
+ * 再要求は毎回 backoff 付きの見張りを伴う。手元のトークンが有効なうちは
43
+ * 諦めず、失効して規定回数を試し切った時点でセッション切れとして扱う
44
+ * (同 条件 3)。打ち切った後は isSettledRef で以降の遷移を止める。
45
+ */
46
+ export declare function useEmbedSession({ apiBase, trustedOrigin, }: {
47
+ readonly apiBase: string;
48
+ readonly trustedOrigin: string;
49
+ }): EmbedSessionState;
@@ -0,0 +1,404 @@
1
+ import { __awaiter } from "tslib";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { parseInboundMessage, sendToHost } from './embed-protocol';
4
+ import { exchangeCode, ExchangeError } from './exchange-code';
5
+ /** How many seconds before expiry a fresh code is requested from the host. */
6
+ const REFRESH_BUFFER_SECONDS = 120;
7
+ /**
8
+ * Fraction of the remaining lifetime used for the refresh timer when a token
9
+ * lives for less than REFRESH_BUFFER_SECONDS. A flat floor would only set the
10
+ * period of a permanent re-exchange loop, not stop it.
11
+ */
12
+ const SHORT_TOKEN_REFRESH_RATIO = 0.5;
13
+ /** Lower bound for the refresh timer. */
14
+ const MIN_REFRESH_DELAY_SECONDS = 5;
15
+ /** First wait before asking the host for a code again. */
16
+ const CODE_RETRY_BASE_SECONDS = 5;
17
+ /** Upper bound for the backoff between code requests. */
18
+ const CODE_RETRY_MAX_SECONDS = 20;
19
+ /** Seconds to wait for the host's first code before showing a connection error. */
20
+ const CODE_TIMEOUT_SECONDS = 15;
21
+ /** Consecutive failed attempts to obtain a usable session before giving up. */
22
+ const MAX_RECOVERY_ATTEMPTS = 4;
23
+ /**
24
+ * A 401 landing within this window of the previous one belongs to the same
25
+ * recovery run. Without it, a token Keystone happily mints but the API keeps
26
+ * rejecting loops forever, because every exchange succeeds and resets counters.
27
+ */
28
+ const AUTH_ERROR_WINDOW_SECONDS = 30;
29
+ /** 401-triggered recovery cycles allowed inside AUTH_ERROR_WINDOW_SECONDS. */
30
+ const MAX_AUTH_ERROR_CYCLES = 3;
31
+ /** An exchange still unsettled after this long is treated as abandoned. */
32
+ const STALE_EXCHANGE_SECONDS = 20;
33
+ /**
34
+ * Statuses below 500 that are still worth retrying. Every attempt asks the host
35
+ * for a fresh code, so a rejected code (401) can succeed on the next one.
36
+ * Everything else in the 4xx range (wrong apiBase, malformed request) fails
37
+ * identically however many times it is tried.
38
+ */
39
+ const RETRYABLE_CLIENT_STATUSES = new Set([401, 408, 425, 429]);
40
+ /**
41
+ * @description Whether asking for another code could plausibly succeed.
42
+ * Non-ExchangeError throws are network faults and aborts, which are transient.
43
+ */
44
+ function isTransientExchangeFailure(thrown) {
45
+ if (!(thrown instanceof ExchangeError))
46
+ return true;
47
+ // 200 なのに本文が欠けていた場合。ゲートウェイが一度だけ返すことがあるので、
48
+ // 本文が JSON として壊れていた場合(SyntaxError)と同じ扱いにする。
49
+ if (thrown.httpStatus === 0)
50
+ return true;
51
+ if (thrown.httpStatus >= 500)
52
+ return true;
53
+ return RETRYABLE_CLIENT_STATUSES.has(thrown.httpStatus);
54
+ }
55
+ const CONNECT_FAILED_MESSAGE = 'SFA/CRM に接続できませんでした。ページを再読み込みしてください。';
56
+ /**
57
+ * 何度試しても同じ結果になる失敗。再読み込みでは解消しないため、
58
+ * CONNECT_FAILED_MESSAGE とは別の案内を出す。
59
+ */
60
+ const PERMANENT_FAILURE_MESSAGE = 'SFA/CRM に接続できませんでした。埋め込み設定を管理者にご確認ください。';
61
+ /**
62
+ * @description 交換の失敗理由をコンソールに残す。画面には出さない。
63
+ * ExchangeError の message は応答本文をそのまま含むため、日本語の案内画面に
64
+ * 流すと利用者に読めない内容が出るうえ、カードの幅を越えて崩れる。
65
+ */
66
+ function logExchangeFailure(thrown) {
67
+ // eslint-disable-next-line no-console
68
+ console.warn('[sfa-kit] code exchange failed:', thrown);
69
+ }
70
+ /**
71
+ * @description Keeps an embedded page authenticated over the postMessage
72
+ * code-exchange protocol: requests a code on mount, swaps the token before it
73
+ * expires, and falls back to a fresh code on 401.
74
+ *
75
+ * トークンを差し替えている間も phase は `authenticated` のまま据え置く。
76
+ * 状態画面に切り替えると子ツリーがアンマウントされ、埋め込みページの入力内容と
77
+ * React の状態が失われるため(authentication.md 条件 1)。
78
+ *
79
+ * 再要求は毎回 backoff 付きの見張りを伴う。手元のトークンが有効なうちは
80
+ * 諦めず、失効して規定回数を試し切った時点でセッション切れとして扱う
81
+ * (同 条件 3)。打ち切った後は isSettledRef で以降の遷移を止める。
82
+ */
83
+ export function useEmbedSession({ apiBase, trustedOrigin, }) {
84
+ const [phase, setPhase] = useState({ step: 'waiting_for_code' });
85
+ const refreshTimerRef = useRef(null);
86
+ const retryTimerRef = useRef(null);
87
+ const exchangingRef = useRef(false);
88
+ const exchangeStartedAtRef = useRef(0);
89
+ /** 追い越された交換の結果で新しいセッションを上書きしないための世代番号。 */
90
+ const exchangeGenerationRef = useRef(0);
91
+ /**
92
+ * 直近に交換できたセッション。トークンがまだ有効かどうかの判定に使う。
93
+ * 401 を受け取った時点で null に戻す(期限内でもそのトークンは使えない)。
94
+ */
95
+ const sessionRef = useRef(null);
96
+ /** 使えるセッションを得られなかった連続回数。交換成功で 0 に戻す。 */
97
+ const recoveryAttemptsRef = useRef(0);
98
+ const authErrorCyclesRef = useRef(0);
99
+ /** 連鎖の起点。最後の 1 件ではなく最初の 1 件から窓を測る。 */
100
+ const authErrorRunStartedAtRef = useRef(0);
101
+ /**
102
+ * 401 を受けてコードを要求済みかどうか。SfaClient は 401 を返した
103
+ * リクエストごとに onAuthError を呼ぶため、並行リクエストの分をまとめる。
104
+ */
105
+ const isAuthErrorPendingRef = useRef(false);
106
+ const hasEverAuthenticatedRef = useRef(false);
107
+ /** 同じ復帰の試行でコンソールに書いたかどうか。交換成功で false に戻す。 */
108
+ const hasLoggedExchangeFailureRef = useRef(false);
109
+ /** 打ち切り済み。以降は再要求も画面遷移もしない。 */
110
+ const isSettledRef = useRef(false);
111
+ const clearRefreshTimer = useCallback(() => {
112
+ if (refreshTimerRef.current != null) {
113
+ clearTimeout(refreshTimerRef.current);
114
+ refreshTimerRef.current = null;
115
+ }
116
+ }, []);
117
+ const clearRetryTimer = useCallback(() => {
118
+ if (retryTimerRef.current != null) {
119
+ clearTimeout(retryTimerRef.current);
120
+ retryTimerRef.current = null;
121
+ }
122
+ }, []);
123
+ const isTokenUsable = useCallback(() => {
124
+ const session = sessionRef.current;
125
+ return session != null && session.expiresAt > Date.now();
126
+ }, []);
127
+ /** 打ち切って画面を差し替える。手元に使えるものが何も残っていないとき。 */
128
+ const settle = useCallback((next) => {
129
+ isSettledRef.current = true;
130
+ // 進行中の交換を無効化する。これが無いと、打ち切った後に解決した交換が
131
+ // authenticated を書き戻し、二度と操作できない画面が残る。
132
+ exchangeGenerationRef.current += 1;
133
+ clearRefreshTimer();
134
+ clearRetryTimer();
135
+ setPhase(next);
136
+ }, [clearRefreshTimer, clearRetryTimer]);
137
+ /**
138
+ * 状態画面は子ツリーがまだ描画されていないときだけ出す。認証済みの表示中は
139
+ * phase を動かさず、ページの状態をそのまま残す。打ち切り後も動かさない。
140
+ */
141
+ const showStatusIfNotMounted = useCallback((next) => {
142
+ if (isSettledRef.current)
143
+ return;
144
+ setPhase(current => (current.step === 'authenticated' ? current : next));
145
+ }, []);
146
+ /**
147
+ * 交換中であることを示す。すでに再接続中の表示なら据え置く。再試行のたびに
148
+ * 「認証中...」と「再接続中...」が入れ替わると、見出しが点滅して見えるため。
149
+ */
150
+ const showExchangingIfNotMounted = useCallback(() => {
151
+ if (isSettledRef.current)
152
+ return;
153
+ setPhase(current => current.step === 'authenticated' || current.step === 'reconnecting'
154
+ ? current
155
+ : { step: 'exchanging' });
156
+ }, []);
157
+ const sendCodeRequest = useCallback(() => {
158
+ sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
159
+ }, [trustedOrigin]);
160
+ /** 打ち切り後にトークンが切れた時点で next に落とすためのタイマー。 */
161
+ const scheduleExpiryCutoff = useCallback((next) => {
162
+ clearRefreshTimer();
163
+ const session = sessionRef.current;
164
+ if (session == null)
165
+ return;
166
+ refreshTimerRef.current = setTimeout(() => {
167
+ refreshTimerRef.current = null;
168
+ setPhase(next);
169
+ }, Math.max(0, session.expiresAt - Date.now()));
170
+ }, [clearRefreshTimer]);
171
+ /**
172
+ * 打ち切る。手元のトークンがまだ使えるなら描画済みの子ツリーを残し、
173
+ * 失効した時点で next に切り替える。使えるものが無ければその場で next を出す。
174
+ *
175
+ * next は打ち切った理由をそのまま表す。設定の誤りで再交換が詰んだ場合に
176
+ * 失効を待って「セッション期限切れ」を出すと、原因の見当がつかなくなる。
177
+ *
178
+ * トークンの有無の判定はここに閉じる。呼び出し側で分岐させると、据え置きの
179
+ * 経路だけを通って失効タイマーの無いまま固まる呼び方ができてしまう。
180
+ */
181
+ const stopRetrying = useCallback((next) => {
182
+ if (!isTokenUsable()) {
183
+ settle(next);
184
+ return;
185
+ }
186
+ isSettledRef.current = true;
187
+ exchangeGenerationRef.current += 1;
188
+ clearRetryTimer();
189
+ setPhase(current => (current.step === 'authenticated' ? current : next));
190
+ scheduleExpiryCutoff(next);
191
+ }, [clearRetryTimer, isTokenUsable, scheduleExpiryCutoff, settle]);
192
+ // 見張りは自分自身を再武装する。useCallback は自己参照できないため ref 経由。
193
+ const armCodeRetryRef = useRef(() => undefined);
194
+ /**
195
+ * コード要求に応答が無かった場合に備えて次の要求を仕込む。間隔は指数的に
196
+ * 広げ、揺らぎを加える。複数のタブが同じ間隔で一斉に再要求すると、弱った
197
+ * ホストと Keystone をそろって叩くことになるため。
198
+ */
199
+ const armCodeRetry = useCallback(() => {
200
+ if (isSettledRef.current)
201
+ return;
202
+ clearRetryTimer();
203
+ const backoffSeconds = Math.min(CODE_RETRY_BASE_SECONDS * Math.pow(2, recoveryAttemptsRef.current), CODE_RETRY_MAX_SECONDS);
204
+ const delayMs = backoffSeconds * 1000 * (0.75 + Math.random() * 0.5);
205
+ retryTimerRef.current = setTimeout(() => {
206
+ retryTimerRef.current = null;
207
+ if (isSettledRef.current)
208
+ return;
209
+ recoveryAttemptsRef.current += 1;
210
+ // トークンが生きているうちは諦めない。間隔だけ広げる。
211
+ if (!isTokenUsable() &&
212
+ recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) {
213
+ settle(hasEverAuthenticatedRef.current
214
+ ? { step: 'session_expired' }
215
+ : {
216
+ step: 'error',
217
+ message: CONNECT_FAILED_MESSAGE,
218
+ canRecoverByReload: true,
219
+ });
220
+ return;
221
+ }
222
+ sendCodeRequest();
223
+ armCodeRetryRef.current();
224
+ }, delayMs);
225
+ }, [clearRetryTimer, isTokenUsable, sendCodeRequest, settle]);
226
+ armCodeRetryRef.current = armCodeRetry;
227
+ /** コードを要求し、応答が無かったときのために次の要求を仕込む。 */
228
+ const requestCode = useCallback(() => {
229
+ sendCodeRequest();
230
+ armCodeRetry();
231
+ }, [sendCodeRequest, armCodeRetry]);
232
+ const scheduleRefresh = useCallback((expiresInSeconds) => {
233
+ clearRefreshTimer();
234
+ const lifetime = Number.isFinite(expiresInSeconds)
235
+ ? Math.max(0, expiresInSeconds)
236
+ : 0;
237
+ // 3 つの下限の最大値をとる。分岐で選ぶと 125 秒付近で不連続になり、
238
+ // 1 秒長いトークンの交換頻度が 10 倍になっていた。
239
+ const delaySeconds = Math.max(MIN_REFRESH_DELAY_SECONDS, lifetime - REFRESH_BUFFER_SECONDS, lifetime * SHORT_TOKEN_REFRESH_RATIO);
240
+ refreshTimerRef.current = setTimeout(() => {
241
+ refreshTimerRef.current = null;
242
+ requestCode();
243
+ }, delaySeconds * 1000);
244
+ }, [clearRefreshTimer, requestCode]);
245
+ const handleCodeReceived = useCallback((code, context) => __awaiter(this, void 0, void 0, function* () {
246
+ if (isSettledRef.current)
247
+ return;
248
+ const startedAt = Date.now();
249
+ // 決着しない交換に居座られてコードを取りこぼし続けないよう、古い交換は
250
+ // 見捨てる(authTimeoutSignal は AbortSignal.timeout が無い環境で
251
+ // undefined を返すため、fetch 側で必ず中断されるとは限らない)。
252
+ if (exchangingRef.current &&
253
+ startedAt - exchangeStartedAtRef.current < STALE_EXCHANGE_SECONDS * 1000) {
254
+ return;
255
+ }
256
+ exchangingRef.current = true;
257
+ exchangeStartedAtRef.current = startedAt;
258
+ const generation = (exchangeGenerationRef.current += 1);
259
+ // 交換中も見張りを残す。ここでタイマーを消すと、決着しない交換に
260
+ // 入った時点で armed なタイマーが 1 つも無くなり、復帰できなくなる。
261
+ armCodeRetry();
262
+ showExchangingIfNotMounted();
263
+ try {
264
+ const session = yield exchangeCode(apiBase, code);
265
+ if (exchangeGenerationRef.current !== generation)
266
+ return;
267
+ recoveryAttemptsRef.current = 0;
268
+ isAuthErrorPendingRef.current = false;
269
+ hasLoggedExchangeFailureRef.current = false;
270
+ hasEverAuthenticatedRef.current = true;
271
+ sessionRef.current = session;
272
+ // 交換中に 401 で仕込まれた見張りが生き残ると、成功後も 5 秒ごとに
273
+ // コードを要求し続けることになる。
274
+ clearRetryTimer();
275
+ setPhase(current => ({
276
+ step: 'authenticated',
277
+ session,
278
+ // context を省いた再交換で object_key / record_id を失わない。
279
+ context: context !== null && context !== void 0 ? context : (current.step === 'authenticated' ? current.context : null),
280
+ }));
281
+ scheduleRefresh(Math.max(0, Math.floor((session.expiresAt - Date.now()) / 1000)));
282
+ }
283
+ catch (thrown) {
284
+ if (exchangeGenerationRef.current !== generation)
285
+ return;
286
+ // 同じ復帰の試行が続く間は 1 回だけ出す。トークンが生きている間は
287
+ // 20 秒ごとに再交換を続けるため、毎回書くとホストのコンソールが埋まる。
288
+ if (!hasLoggedExchangeFailureRef.current) {
289
+ hasLoggedExchangeFailureRef.current = true;
290
+ logExchangeFailure(thrown);
291
+ }
292
+ // 何度やっても通らない失敗。使い捨てコードを浪費しない。
293
+ if (!isTransientExchangeFailure(thrown)) {
294
+ stopRetrying({
295
+ step: 'error',
296
+ message: PERMANENT_FAILURE_MESSAGE,
297
+ canRecoverByReload: false,
298
+ });
299
+ return;
300
+ }
301
+ // 試行回数は見張りのタイマー側だけで数える。ここでも足すと 1 周で
302
+ // 2 つ進み、実際にホストへ届く要求が半分になる。
303
+ if (isTokenUsable()) {
304
+ armCodeRetry();
305
+ return;
306
+ }
307
+ // ここはまだ打ち切っていない。error を出すと再読み込みボタン付きの
308
+ // 終了画面になるが、実際には最大 55 秒ほど再試行が続き、そのまま
309
+ // ログインできることがある。
310
+ showStatusIfNotMounted({ step: 'reconnecting' });
311
+ armCodeRetry();
312
+ }
313
+ finally {
314
+ if (exchangeGenerationRef.current === generation) {
315
+ exchangingRef.current = false;
316
+ }
317
+ }
318
+ }), [
319
+ apiBase,
320
+ scheduleRefresh,
321
+ armCodeRetry,
322
+ clearRetryTimer,
323
+ isTokenUsable,
324
+ stopRetrying,
325
+ showStatusIfNotMounted,
326
+ showExchangingIfNotMounted,
327
+ ]);
328
+ const handleAuthError = useCallback(() => {
329
+ if (isSettledRef.current)
330
+ return;
331
+ if (isAuthErrorPendingRef.current)
332
+ return;
333
+ isAuthErrorPendingRef.current = true;
334
+ const now = Date.now();
335
+ // 窓は連鎖の起点から測る。直近の 1 件から測ると、正常に復帰している
336
+ // 25 秒間隔の 401 でも窓が滑り続けて打ち切りに達してしまう。
337
+ if (now - authErrorRunStartedAtRef.current >
338
+ AUTH_ERROR_WINDOW_SECONDS * 1000) {
339
+ authErrorCyclesRef.current = 0;
340
+ authErrorRunStartedAtRef.current = now;
341
+ }
342
+ authErrorCyclesRef.current += 1;
343
+ // ここに達した時点で交換自体は通り続けている。その 401 は差し替え前の
344
+ // トークンで投げられたリクエストの遅れ着信であることが多く、手元では
345
+ // 見分けられない(onAuthError は拒否されたトークンを渡さない)。
346
+ // 動いているセッションを巻き添えにしない側を採る。
347
+ if (authErrorCyclesRef.current > MAX_AUTH_ERROR_CYCLES) {
348
+ // 手元のトークンが期限内なら表示は保ち、コードの取り直しだけをやめて、
349
+ // 失効した時点でセッション切れとして扱う。
350
+ //
351
+ // 据え置きにする必要がある。ホストは要求を受けてから非同期にコードを
352
+ // 発行するので、打ち切った後に以前の要求への応答が届く。交換を続けて
353
+ // しまうと更新タイマーが張り直され、打ち切りが無かったことになる。
354
+ // stopRetrying が世代を進めて以降のコードを無視させる。
355
+ stopRetrying({ step: 'session_expired' });
356
+ return;
357
+ }
358
+ clearRefreshTimer();
359
+ // 401 を返されたトークンは期限内でも使えない。保持判定から外す。
360
+ //
361
+ // NOTE: phase.session のトークンは差し替えるまで残る。SfaProvider に渡す
362
+ // auth は phase.session から作るため、ここで消すと auth が null になり
363
+ // 子ツリーが落ちる(それを避けるのがこの修正の目的)。復帰までの数秒は
364
+ // 無効なトークンでリクエストが失敗するが、トークンを外しても同じく
365
+ // 失敗するため、表示を保つ側を採る。
366
+ sessionRef.current = null;
367
+ requestCode();
368
+ showStatusIfNotMounted({ step: 'waiting_for_code' });
369
+ }, [requestCode, clearRefreshTimer, stopRetrying, showStatusIfNotMounted]);
370
+ useEffect(() => {
371
+ const handleMessage = (event) => {
372
+ var _a;
373
+ const parsed = parseInboundMessage(event, trustedOrigin);
374
+ if (!parsed)
375
+ return;
376
+ if (parsed.type === 'sfa-kit:code') {
377
+ void handleCodeReceived(parsed.code, (_a = parsed.context) !== null && _a !== void 0 ? _a : null);
378
+ }
379
+ };
380
+ window.addEventListener('message', handleMessage);
381
+ sendToHost({ type: 'sfa-kit:ready' }, trustedOrigin);
382
+ // If the host never sends a code (e.g. mintSfaKitExchangeCode is not
383
+ // implemented or the host page doesn't support the embed protocol),
384
+ // show an error instead of hanging on "Connecting..." forever.
385
+ const codeTimeout = setTimeout(() => {
386
+ setPhase(current => {
387
+ if (current.step !== 'waiting_for_code')
388
+ return current;
389
+ return {
390
+ step: 'error',
391
+ message: 'SFA/CRM との接続がタイムアウトしました。ページを再読み込みしてください。',
392
+ canRecoverByReload: true,
393
+ };
394
+ });
395
+ }, CODE_TIMEOUT_SECONDS * 1000);
396
+ return () => {
397
+ window.removeEventListener('message', handleMessage);
398
+ clearTimeout(codeTimeout);
399
+ clearRefreshTimer();
400
+ clearRetryTimer();
401
+ };
402
+ }, [trustedOrigin, handleCodeReceived, clearRefreshTimer, clearRetryTimer]);
403
+ return { phase, handleAuthError };
404
+ }
@@ -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.0";
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.0';
3
+ export const SFA_KIT_VERSION = '1.16.0';