@geniee-inc/sfa-kit 1.11.0 → 1.13.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.
@@ -2,13 +2,48 @@ import { __awaiter } from "tslib";
2
2
  import { ERR_DIRECT_MODE_REQUIRED, ERR_EMPTY_RECORD_ID } from '../errors';
3
3
  /** Abort in-flight requests after this many milliseconds. */
4
4
  const REQUEST_TIMEOUT_MS = 30000;
5
+ /**
6
+ * @description How long a bulk write may take before the browser gives up.
7
+ *
8
+ * NOTE: a bulk write is slower than any other call — the server works through
9
+ * the rows one at a time, with a 45 s budget of its own — so the default 30 s
10
+ * would abort a full batch part-way. The abort only stops the browser: the
11
+ * server keeps writing, the response is dropped, and the caller loses the ids
12
+ * of everything that landed. Waiting longer than the server's own budget is
13
+ * what makes the answer reachable.
14
+ *
15
+ * NOTE: 60 s is the ceiling, not a preference. That is the ALB idle timeout
16
+ * (AWS default; the API gateway is off in both staging and production), so a
17
+ * request held open longer is cut by the load balancer instead.
18
+ */
19
+ const BULK_REQUEST_TIMEOUT_MS = 60000;
20
+ /**
21
+ * @description The most rows one bulk call sends.
22
+ *
23
+ * NOTE: the API's own contract is `minItems: 1, maxItems: 100`. 25 is the
24
+ * kit's stricter limit, and it comes from how long the call takes rather than
25
+ * from what the API accepts: Keystone delegates one row at a time, measured at
26
+ * about 1.4 s per row on staging (2026-09-04). 25 rows fits inside the
27
+ * server's own 45 s budget for a bulk write, which in turn fits inside the
28
+ * 60 s ALB idle timeout. Agreed with Kumada and Takeuchi.
29
+ *
30
+ * Do not raise this to match `maxItems` — a call of 100 rows would outlast
31
+ * the server's budget and then the load balancer, and the caller would learn
32
+ * nothing about which rows had been written. It can go up only once a row
33
+ * costs less, or those limits change.
34
+ *
35
+ * NOTE: public because a caller cannot size its batches or its own warning
36
+ * without it, and kept next to the check that enforces it so the two cannot
37
+ * disagree.
38
+ */
39
+ export const SFA_BULK_MAX_ROWS = 25;
5
40
  /**
6
41
  * @description Returns a timeout AbortSignal, or undefined where the runtime
7
42
  * doesn't support AbortSignal.timeout (e.g. older jsdom in tests).
8
43
  */
9
- function timeoutSignal() {
44
+ function timeoutSignal(timeoutMs = REQUEST_TIMEOUT_MS) {
10
45
  if (typeof AbortSignal.timeout === 'function') {
11
- return AbortSignal.timeout(REQUEST_TIMEOUT_MS);
46
+ return AbortSignal.timeout(timeoutMs);
12
47
  }
13
48
  return undefined;
14
49
  }
@@ -91,6 +126,9 @@ export class SfaClient {
91
126
  if (params === null || params === void 0 ? void 0 : params.filter) {
92
127
  body['filter'] = params.filter;
93
128
  }
129
+ if (params === null || params === void 0 ? void 0 : params.listId) {
130
+ body['list_id'] = params.listId;
131
+ }
94
132
  const path = `/v1/objects/${enc(collection)}/records/query`;
95
133
  const raw = yield this.postJson(path, body);
96
134
  return normalizeKeystoneListResponse(raw, pageIndex, pageSize);
@@ -165,23 +203,119 @@ export class SfaClient {
165
203
  }
166
204
  }
167
205
  /**
168
- * @description Creates a new record. Optionally accepts an idempotency key
169
- * to prevent duplicate creates on retries.
206
+ * @description Creates a new record.
207
+ *
208
+ * NOTE: a retried create writes a second record — nothing de-duplicates it.
170
209
  */
171
- create(collection, fields, options) {
210
+ create(collection, fields,
211
+ // NOTE: kept so the class matches the published interface. Its only member
212
+ // is inert, so nothing reads it; it goes when the type does
213
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
214
+ options) {
172
215
  return __awaiter(this, void 0, void 0, function* () {
173
216
  this.assertDirectMode();
174
217
  const normalized = yield this.applySchemaAwareNormalization(collection, fields);
175
218
  const path = `/v1/objects/${enc(collection)}/records`;
176
- const extra = {};
177
- if (options === null || options === void 0 ? void 0 : options.idempotencyKey) {
178
- extra['Idempotency-Key'] = options.idempotencyKey;
179
- }
180
- const response = yield this.fetchWithBody('POST', path, { fields: normalizeWriteFields(normalized) }, extra);
219
+ const response = yield this.fetchWithBody('POST', path, {
220
+ fields: normalizeWriteFields(normalized),
221
+ });
181
222
  const raw = yield response.json();
182
223
  return normalizeKeystoneRecord(raw);
183
224
  });
184
225
  }
226
+ /**
227
+ * @description Creates many records of one object in a single call.
228
+ *
229
+ * The call is best-effort per row: the API answers 200 and reports every
230
+ * row's outcome, so a failed row does not undo the ones that worked. Per-row
231
+ * failures come back as results, not as thrown errors — throwing would
232
+ * discard the ids of the rows that succeeded, which are not recoverable
233
+ * anywhere else. SfaValidationError and the rest still surface when the
234
+ * whole call is rejected.
235
+ *
236
+ * Retry only the failed rows. What makes that safe is that those rows did
237
+ * not apply: nothing on the server de-duplicates a repeated write.
238
+ */
239
+ bulkCreate(collection, rows) {
240
+ return __awaiter(this, void 0, void 0, function* () {
241
+ this.assertDirectMode();
242
+ assertBulkRowCount(rows.length);
243
+ const normalizedRows = yield Promise.all(rows.map((fields) => __awaiter(this, void 0, void 0, function* () {
244
+ return ({
245
+ fields: yield this.normalizeRowFields(collection, fields),
246
+ });
247
+ })));
248
+ return this.postBulk(collection, 'bulk-create', normalizedRows);
249
+ });
250
+ }
251
+ /**
252
+ * @description Updates many records of one object in a single call.
253
+ *
254
+ * Each row carries its own `version`, so a stale row comes back as
255
+ * `conflict` with the server's current record to reconcile from — the rest
256
+ * of the batch still applies, and nothing is rolled back. Per-row outcomes
257
+ * are results, not thrown errors: a throw would hide which rows landed.
258
+ */
259
+ bulkUpdate(collection, rows) {
260
+ return __awaiter(this, void 0, void 0, function* () {
261
+ this.assertDirectMode();
262
+ assertBulkRowCount(rows.length);
263
+ for (const row of rows)
264
+ this.assertRecordId(row.id);
265
+ const normalizedRows = yield Promise.all(rows.map((row) => __awaiter(this, void 0, void 0, function* () {
266
+ return ({
267
+ id: row.id,
268
+ version: row.version,
269
+ fields: yield this.normalizeRowFields(collection, row.fields),
270
+ });
271
+ })));
272
+ return this.postBulk(collection, 'bulk-update', normalizedRows);
273
+ });
274
+ }
275
+ /**
276
+ * @description Deletes many records of one object in a single call.
277
+ *
278
+ * Each row carries the `version` last read for it; a stale row comes back as
279
+ * `conflict` and is left in place while the rest of the batch applies.
280
+ */
281
+ bulkDelete(collection, rows) {
282
+ return __awaiter(this, void 0, void 0, function* () {
283
+ this.assertDirectMode();
284
+ assertBulkRowCount(rows.length);
285
+ for (const row of rows)
286
+ this.assertRecordId(row.id);
287
+ return this.postBulk(collection, 'bulk-delete', rows.map(row => ({ id: row.id, version: row.version })));
288
+ });
289
+ }
290
+ /**
291
+ * @description Prepares one row's fields for a bulk write.
292
+ *
293
+ * NOTE: shared by bulk-create and bulk-update so the two steps stay in this
294
+ * order for both. Schema-aware normalisation reads the object's field types
295
+ * — a select written by label, a date written loosely — and the write
296
+ * normalisation that follows only shapes what it produced.
297
+ */
298
+ normalizeRowFields(collection, fields) {
299
+ return __awaiter(this, void 0, void 0, function* () {
300
+ return normalizeWriteFields(yield this.applySchemaAwareNormalization(collection, fields));
301
+ });
302
+ }
303
+ /**
304
+ * @description Sends one bulk request and reads its per-row outcomes.
305
+ *
306
+ * NOTE: shared by the three bulk methods so the envelope and the result
307
+ * parsing cannot drift apart between them.
308
+ */
309
+ postBulk(collection, operation, records) {
310
+ return __awaiter(this, void 0, void 0, function* () {
311
+ const path = `/v1/objects/${enc(collection)}/records/${operation}`;
312
+ const response = yield this.fetchWithBody('POST', path, { records }, {
313
+ timeoutMs: BULK_REQUEST_TIMEOUT_MS,
314
+ bulkUnsupportedOn501: true,
315
+ });
316
+ return normalizeBulkResults(yield response.json(), records.length);
317
+ });
318
+ }
185
319
  /**
186
320
  * @description Updates a record with optimistic locking. The version token
187
321
  * (from a prior GET) is sent as the If-Match header; a 412 response means
@@ -193,7 +327,7 @@ export class SfaClient {
193
327
  this.assertRecordId(recordId);
194
328
  const normalized = yield this.applySchemaAwareNormalization(collection, fields);
195
329
  const path = `/v1/objects/${enc(collection)}/records/${enc(recordId)}`;
196
- const response = yield this.fetchWithBody('PATCH', path, { fields: normalizeWriteFields(normalized) }, { 'If-Match': version });
330
+ const response = yield this.fetchWithBody('PATCH', path, { fields: normalizeWriteFields(normalized) }, { headers: { 'If-Match': version } });
197
331
  const raw = yield response.json();
198
332
  return normalizeKeystoneRecord(raw);
199
333
  });
@@ -228,7 +362,7 @@ export class SfaClient {
228
362
  this.assertRecordId(recordId);
229
363
  const path = `/v1/objects/${enc(collection)}/records/${enc(recordId)}`;
230
364
  yield this.fetchWithBody('DELETE', path, undefined, {
231
- 'If-Match': version,
365
+ headers: { 'If-Match': version },
232
366
  });
233
367
  });
234
368
  }
@@ -310,17 +444,17 @@ export class SfaClient {
310
444
  * body / extra headers. Returns the raw Response so callers can inspect
311
445
  * status codes, headers, or handle empty bodies (204).
312
446
  */
313
- fetchWithBody(method, path, body, extraHeaders) {
447
+ fetchWithBody(method, path, body, options) {
314
448
  return __awaiter(this, void 0, void 0, function* () {
315
449
  var _a;
316
450
  const url = `${this.apiBase}${path}`;
317
- const headers = Object.assign({ 'Content-Type': 'application/json' }, extraHeaders);
451
+ const headers = Object.assign({ 'Content-Type': 'application/json' }, options === null || options === void 0 ? void 0 : options.headers);
318
452
  this.applyAuth(headers);
319
453
  const response = yield globalThis.fetch(url, {
320
454
  method,
321
455
  headers,
322
456
  body: body ? JSON.stringify(body) : undefined,
323
- signal: timeoutSignal(),
457
+ signal: timeoutSignal(options === null || options === void 0 ? void 0 : options.timeoutMs),
324
458
  });
325
459
  if (!response.ok) {
326
460
  if (response.status === 401)
@@ -332,6 +466,11 @@ export class SfaClient {
332
466
  if (response.status === 422) {
333
467
  throw SfaValidationError.fromBody(response.status, response.statusText, text, url);
334
468
  }
469
+ // NOTE: only the bulk endpoints answer 501, and only they can say what
470
+ // it means to the reader. A 501 elsewhere stays a plain SfaApiError
471
+ if (response.status === 501 && (options === null || options === void 0 ? void 0 : options.bulkUnsupportedOn501)) {
472
+ throw new SfaBulkUnsupportedError(response.status, response.statusText, text, url);
473
+ }
335
474
  throw new SfaApiError(response.status, response.statusText, text, url);
336
475
  }
337
476
  return response;
@@ -725,27 +864,240 @@ function parseConflictRecord(body) {
725
864
  }
726
865
  return null;
727
866
  }
867
+ /**
868
+ * @description Guards the row count before a request goes out.
869
+ *
870
+ * NOTE: an empty array is rejected by the API with 422 for the whole call, and
871
+ * a selection over the kit's limit is refused before it can be sent at all.
872
+ * Either way the caller loses every row, so this names the limit and the count
873
+ * instead of leaving a generic validation failure to explain it.
874
+ */
875
+ function assertBulkRowCount(count) {
876
+ if (count === 0 || count > SFA_BULK_MAX_ROWS) {
877
+ throw new SfaBulkRowCountError(count);
878
+ }
879
+ }
880
+ /**
881
+ * @description Reads the per-row outcomes out of a bulk response body.
882
+ *
883
+ * NOTE: results are ordered by `index`, not by their position in the array.
884
+ * That field exists to align them with the request, and a caller writing ids
885
+ * back into a grid needs submission order to be true.
886
+ *
887
+ * NOTE: a count that does not match the request is refused rather than
888
+ * returned. Unlike a row that failed — an expected outcome the caller must see
889
+ * — a short response leaves some submitted rows with no reported fate, and
890
+ * showing the rest as the whole picture makes those rows look neither written
891
+ * nor retryable. The caller has to re-read to learn the truth.
892
+ */
893
+ function normalizeBulkResults(raw, submittedRowCount) {
894
+ const data = raw;
895
+ const rawResults = Array.isArray(data['results']) ? data['results'] : [];
896
+ const rows = rawResults
897
+ .filter((row) => row != null && typeof row === 'object')
898
+ .map(row => downgradeUnusableSuccess({
899
+ index: readRowIndex(row['index']),
900
+ id: readIdentifier(row['id']),
901
+ status: readBulkRowStatus(row['status']),
902
+ record: readBulkRecord(row['record']),
903
+ error: normalizeBulkRowError(row['error']),
904
+ }));
905
+ return alignedToTheRequest(rows, submittedRowCount);
906
+ }
907
+ /**
908
+ * @description The position in the request a result answers for, or null when
909
+ * the response does not say.
910
+ *
911
+ * NOTE: `index` is a required field, and the order the rows arrive in is not
912
+ * part of the contract. Filling a missing one from the array position would
913
+ * turn that order into a guarantee the API never gave, and a caller pairing
914
+ * results with its own rows would silently act on the wrong ones. A value that
915
+ * is not a whole position — fractional, negative, NaN, Infinity — says just as
916
+ * little.
917
+ */
918
+ function readRowIndex(raw) {
919
+ const isWholePosition = typeof raw === 'number' && Number.isInteger(raw) && raw >= 0;
920
+ return isWholePosition ? raw : null;
921
+ }
922
+ /**
923
+ * @description Puts the results in request order, refusing a response that
924
+ * cannot be paired with the request.
925
+ *
926
+ * NOTE: the outcome for each submitted position is looked up by `index`, so
927
+ * the order the rows arrived in is never relied on. A position no row answers
928
+ * for — an index missing, invalid, repeated, or past the end — is refused for
929
+ * the whole call: that submitted row would have no reported outcome, and
930
+ * handing back the rest would show it as neither written nor retryable.
931
+ *
932
+ * NOTE: the count is checked after parsing, not on the raw array. An entry
933
+ * that is not an object is dropped on the way in, so counting the raw array
934
+ * would pass a response of the right length that answers for fewer rows than
935
+ * it appears to.
936
+ */
937
+ function alignedToTheRequest(rows, submittedRowCount) {
938
+ if (rows.length !== submittedRowCount) {
939
+ throw new Error(`A bulk write answered with ${rows.length} results for ` +
940
+ `${submittedRowCount} submitted rows. Re-read the records to find ` +
941
+ 'out which ones landed.');
942
+ }
943
+ const byPosition = new Map(rows.flatMap(row => (row.index == null ? [] : [[row.index, row]])));
944
+ return Array.from({ length: submittedRowCount }, (_row, position) => {
945
+ const answered = byPosition.get(position);
946
+ if ((answered === null || answered === void 0 ? void 0 : answered.index) == null) {
947
+ throw new Error(`A bulk write answered for rows [${rows
948
+ .map(row => { var _a; return (_a = row.index) !== null && _a !== void 0 ? _a : '?'; })
949
+ .join(', ')}] of ${submittedRowCount} submitted, so a result ` +
950
+ 'cannot be paired with the row that produced it. Re-read the ' +
951
+ 'records to find out which ones landed.');
952
+ }
953
+ return Object.assign(Object.assign({}, answered), { index: answered.index });
954
+ });
955
+ }
956
+ /**
957
+ * @description An identifier, or null when the row does not carry a usable one.
958
+ *
959
+ * NOTE: an empty string is not an id. Reported as one it would reach a caller
960
+ * as a row it believes was written — a delete would list `''` among the ids it
961
+ * removed — so the row counts as not landed instead.
962
+ */
963
+ function readIdentifier(raw) {
964
+ return typeof raw === 'string' && raw !== '' ? raw : null;
965
+ }
966
+ /**
967
+ * @description The record a row produced, or null when there is none to use.
968
+ *
969
+ * NOTE: normalisation answers with an empty shell for a body it cannot read,
970
+ * and a shell is not what a written row produces: it has no id and no version.
971
+ * Passing it on would let the row count as written and hand a grid a blank row
972
+ * with nothing to save against, so the row is reported as not landed. The same
973
+ * applies to a conflict, whose record exists only to reconcile against.
974
+ */
975
+ function readBulkRecord(raw) {
976
+ if (raw == null || typeof raw !== 'object')
977
+ return null;
978
+ const record = normalizeKeystoneRecord(raw);
979
+ return record.record_id === '' ? null : record;
980
+ }
981
+ /**
982
+ * @description Refuses to report a write that nothing can be built on.
983
+ *
984
+ * NOTE: a created or updated row answers with the record it produced, and the
985
+ * contract has that record carry a `version` — the token the next update or
986
+ * delete locks against. Reported as written, such a row would be handed to a
987
+ * grid as the saved state of that row, and the next save would have nothing to
988
+ * address it by. A row answering with no record at all is the same problem one
989
+ * step earlier, and each says so in its own code: a reader of the log should
990
+ * not have to guess which of the two the server sent.
991
+ *
992
+ * NOTE: it is not reported as failed either. The row did land, and `failed`
993
+ * asks the caller to retry it — for a create that would write a second record.
994
+ * `unknown` says what a retry cannot fix while keeping the id: re-read the
995
+ * record.
996
+ *
997
+ * NOTE: a conflict is left as it is. Its record exists to reconcile against,
998
+ * it already counts as not landed, and calling it `unknown` would throw away
999
+ * the one thing the caller needs to know about it.
1000
+ */
1001
+ function downgradeUnusableSuccess(row) {
1002
+ const wroteARecord = row.status === 'created' || row.status === 'updated';
1003
+ if (!wroteARecord)
1004
+ return row;
1005
+ if (row.record == null) {
1006
+ return unresolved(row, 'missing_record', 'The write reported success but answered with no record, so there is ' +
1007
+ 'nothing to write back. Re-read the record to find out what landed.');
1008
+ }
1009
+ if (!isUsableVersion(row.record.version)) {
1010
+ return unresolved(row, 'missing_version', 'The written record carried no version to save against. Re-read it ' +
1011
+ 'before writing to it again.');
1012
+ }
1013
+ return row;
1014
+ }
1015
+ /**
1016
+ * @description Marks a row as one the kit cannot report either way, keeping
1017
+ * whatever the server did say about it.
1018
+ */
1019
+ function unresolved(row, code, message) {
1020
+ var _a;
1021
+ return Object.assign(Object.assign({}, row), { status: 'unknown', error: (_a = row.error) !== null && _a !== void 0 ? _a : { code, message, fieldErrors: [] } });
1022
+ }
1023
+ function isUsableVersion(version) {
1024
+ return typeof version === 'string' && version !== '';
1025
+ }
1026
+ /**
1027
+ * @description Reads a row's outcome, keeping unrecognised ones distinguishable.
1028
+ *
1029
+ * NOTE: a status outside the contract becomes `unknown` rather than being
1030
+ * passed through under the union's type. The row still comes back — a create
1031
+ * that landed carries an id recoverable nowhere else — but no caller can
1032
+ * mistake it for a written row.
1033
+ */
1034
+ function readBulkRowStatus(raw) {
1035
+ var _a;
1036
+ const known = {
1037
+ created: 'created',
1038
+ updated: 'updated',
1039
+ deleted: 'deleted',
1040
+ conflict: 'conflict',
1041
+ invalid: 'invalid',
1042
+ not_attempted: 'not_attempted',
1043
+ indeterminate: 'indeterminate',
1044
+ };
1045
+ return typeof raw === 'string' ? (_a = known[raw]) !== null && _a !== void 0 ? _a : 'unknown' : 'unknown';
1046
+ }
1047
+ function normalizeBulkRowError(raw) {
1048
+ // NOTE: a plain string is not the Problem shape the contract promises, but
1049
+ // it is still the server's own account of the failure — kept as the message
1050
+ // rather than dropped, or the row would fail with nothing said about why
1051
+ if (typeof raw === 'string') {
1052
+ return { code: '', message: raw, fieldErrors: [] };
1053
+ }
1054
+ if (raw == null || typeof raw !== 'object')
1055
+ return null;
1056
+ const problem = raw;
1057
+ return {
1058
+ code: typeof problem['code'] === 'string' ? problem['code'] : '',
1059
+ // NOTE: Problem carries the human-readable text in `detail`, with `title`
1060
+ // as the fallback when the server sent only a summary
1061
+ message: readProblemText(problem['detail'], problem['title']),
1062
+ fieldErrors: readFieldErrors(problem['errors']),
1063
+ };
1064
+ }
1065
+ /** @description Problem's human-readable text, `detail` first, `title` after. */
1066
+ function readProblemText(detail, title) {
1067
+ if (typeof detail === 'string')
1068
+ return detail;
1069
+ return typeof title === 'string' ? title : '';
1070
+ }
728
1071
  function parseFieldErrors(body) {
729
1072
  try {
730
1073
  const parsed = JSON.parse(body);
731
- const rawErrors = parsed['errors'];
732
- if (Array.isArray(rawErrors)) {
733
- return rawErrors
734
- .filter((e) => e != null && typeof e === 'object')
735
- .map(e => {
736
- var _a, _b, _c;
737
- return ({
738
- field: (_a = e['field']) !== null && _a !== void 0 ? _a : '',
739
- code: (_b = e['code']) !== null && _b !== void 0 ? _b : '',
740
- message: (_c = e['message']) !== null && _c !== void 0 ? _c : '',
741
- });
742
- });
743
- }
1074
+ return readFieldErrors(parsed['errors']);
744
1075
  }
745
1076
  catch (_a) {
746
1077
  // Body may not be parseable
1078
+ return [];
747
1079
  }
748
- return [];
1080
+ }
1081
+ /**
1082
+ * @description Reads Problem's `errors` extension member.
1083
+ *
1084
+ * NOTE: separate from parseFieldErrors so a caller that already has the parsed
1085
+ * Problem — a bulk row, which arrives inside a 200 body — does not serialize it
1086
+ * back to a string just to read it again.
1087
+ */
1088
+ function readFieldErrors(rawErrors) {
1089
+ if (!Array.isArray(rawErrors))
1090
+ return [];
1091
+ return rawErrors
1092
+ .filter((e) => e != null && typeof e === 'object')
1093
+ .map(e => {
1094
+ var _a, _b, _c;
1095
+ return ({
1096
+ field: (_a = e['field']) !== null && _a !== void 0 ? _a : '',
1097
+ code: (_b = e['code']) !== null && _b !== void 0 ? _b : '',
1098
+ message: (_c = e['message']) !== null && _c !== void 0 ? _c : '',
1099
+ });
1100
+ });
749
1101
  }
750
1102
  // ── Mutation error classes ──
751
1103
  /**
@@ -764,6 +1116,65 @@ export class SfaConflictError extends SfaApiError {
764
1116
  return new SfaConflictError(status, statusText, body, url, currentRecord);
765
1117
  }
766
1118
  }
1119
+ /**
1120
+ * @description A bulk write refused before a request went out: nothing was
1121
+ * selected, or more rows than the kit sends in one call.
1122
+ *
1123
+ * NOTE: `message` is for the log, like the rest of the kit's errors, and
1124
+ * `userMessage` is what a page shows the reader — the same split the kit
1125
+ * already makes between an error and the text in `FetchErrorState`. The page
1126
+ * decides where to show it; the kit has no UI of its own.
1127
+ *
1128
+ * NOTE: a distinct class so a page recognises this without matching on the
1129
+ * message. It is the one write failure a reader causes and can fix.
1130
+ */
1131
+ export class SfaBulkRowCountError extends Error {
1132
+ /** @param submitted how many rows the caller tried to send */
1133
+ constructor(submitted) {
1134
+ super(rowCountLogText(submitted));
1135
+ this.submitted = submitted;
1136
+ /** The most rows one call sends, so a page can say it without importing it. */
1137
+ this.limit = SFA_BULK_MAX_ROWS;
1138
+ this.name = 'SfaBulkRowCountError';
1139
+ this.userMessage = rowCountReaderText(submitted);
1140
+ }
1141
+ }
1142
+ /**
1143
+ * @description Thrown when the object offers no bulk writes at all: the
1144
+ * endpoint answers 501.
1145
+ *
1146
+ * NOTE: told apart from other failures because waiting changes nothing. A
1147
+ * page with no wording of its own says to try again later, which is the one
1148
+ * thing that cannot work here — the operation is not implemented for this
1149
+ * object, today or in an hour.
1150
+ */
1151
+ export class SfaBulkUnsupportedError extends SfaApiError {
1152
+ /**
1153
+ * NOTE: takes the response as it arrived, like every other error here, so
1154
+ * the server's `detail` and `code` are read from the whole body. Building
1155
+ * it from an `SfaApiError` instead would re-read a body already cut to
1156
+ * `ERROR_BODY_MAX_LENGTH`, and a cut inside the JSON leaves nothing to read.
1157
+ */
1158
+ constructor(status, statusText, body, url) {
1159
+ super(status, statusText, body, url);
1160
+ /** What a page shows the reader. `message` is the English text for the log. */
1161
+ this.userMessage = 'このオブジェクトでは一括操作を利用できません。';
1162
+ this.name = 'SfaBulkUnsupportedError';
1163
+ }
1164
+ }
1165
+ function rowCountLogText(submitted) {
1166
+ if (submitted === 0)
1167
+ return 'A bulk write needs at least one row.';
1168
+ return (`A bulk write accepts at most ${SFA_BULK_MAX_ROWS} rows, ` +
1169
+ `got ${submitted}. Split the selection into batches.`);
1170
+ }
1171
+ function rowCountReaderText(submitted) {
1172
+ if (submitted === 0)
1173
+ return '保存する行が選択されていません。';
1174
+ return (`一度に保存できるのは ${SFA_BULK_MAX_ROWS} 件までです。` +
1175
+ `選択されているのは ${submitted} 件です。` +
1176
+ `選択を ${SFA_BULK_MAX_ROWS} 件以下に減らしてから保存してください。`);
1177
+ }
767
1178
  /**
768
1179
  * @description Thrown on 422 with code VALIDATION_FAILED. Exposes per-field
769
1180
  * errors so the UI can highlight which fields need correction.
@@ -23,22 +23,29 @@ export function SfaProvider({ apiBase, auth, mode, organizationId, onAuthError,
23
23
  // Use a ref for onAuthError so updates don't trigger client re-creation.
24
24
  const onAuthErrorRef = useRef(onAuthError);
25
25
  onAuthErrorRef.current = onAuthError;
26
- const value = useMemo(() => {
27
- const stableAuth = accessToken ? { accessToken } : undefined;
28
- const client = new SfaClient({
29
- apiBase,
30
- auth: stableAuth,
31
- mode: resolvedMode,
32
- organizationId: resolvedOrgId,
33
- onAuthError: () => { var _a; return (_a = onAuthErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onAuthErrorRef); },
34
- });
35
- return {
36
- client,
37
- auth: stableAuth,
38
- mode: resolvedMode,
39
- organizationId: resolvedOrgId,
40
- readOnly,
41
- };
42
- }, [apiBase, accessToken, resolvedMode, resolvedOrgId, readOnly]);
26
+ const stableAuth = useMemo(() => (accessToken ? { accessToken } : undefined), [accessToken]);
27
+ /**
28
+ * NOTE: `readOnly` is deliberately not a dependency here. The client does
29
+ * not read it — it is a context flag the hooks check before writing — and
30
+ * rebuilding the client would change its identity. Consumers treat a new
31
+ * client as a different session: a bulk write in flight would be judged to
32
+ * belong to the previous one and its per-row report dropped, while the
33
+ * request itself carries on and the server applies the rows. The describe
34
+ * cache would go with it.
35
+ */
36
+ const client = useMemo(() => new SfaClient({
37
+ apiBase,
38
+ auth: stableAuth,
39
+ mode: resolvedMode,
40
+ organizationId: resolvedOrgId,
41
+ onAuthError: () => { var _a; return (_a = onAuthErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onAuthErrorRef); },
42
+ }), [apiBase, stableAuth, resolvedMode, resolvedOrgId]);
43
+ const value = useMemo(() => ({
44
+ client,
45
+ auth: stableAuth,
46
+ mode: resolvedMode,
47
+ organizationId: resolvedOrgId,
48
+ readOnly,
49
+ }), [client, stableAuth, resolvedMode, resolvedOrgId, readOnly]);
43
50
  return _jsx(SfaContext.Provider, { value: value, children: children });
44
51
  }