@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.
- package/GUIDE.md +244 -1
- package/package.json +1 -1
- package/src/index.d.ts +10 -2
- package/src/index.js +6 -1
- package/src/lib/auth/StandalonePreviewContext.d.ts +1 -0
- package/src/lib/auth/embed-protocol.d.ts +1 -0
- package/src/lib/auth/embed-protocol.js +2 -1
- package/src/lib/components/ErrorSnackbar.d.ts +53 -0
- package/src/lib/components/ErrorSnackbar.js +112 -0
- package/src/lib/hooks/splitBulkRows.d.ts +30 -0
- package/src/lib/hooks/splitBulkRows.js +42 -0
- package/src/lib/hooks/useBulkCreateRecords.d.ts +56 -0
- package/src/lib/hooks/useBulkCreateRecords.js +41 -0
- package/src/lib/hooks/useBulkDeleteRecords.d.ts +50 -0
- package/src/lib/hooks/useBulkDeleteRecords.js +36 -0
- package/src/lib/hooks/useBulkUpdateRecords.d.ts +55 -0
- package/src/lib/hooks/useBulkUpdateRecords.js +37 -0
- package/src/lib/hooks/useBulkWrite.d.ts +55 -0
- package/src/lib/hooks/useBulkWrite.js +160 -0
- package/src/lib/hooks/useRecordList.js +12 -6
- package/src/lib/provider/SfaClient.d.ts +111 -3
- package/src/lib/provider/SfaClient.js +440 -29
- package/src/lib/provider/SfaProvider.js +24 -17
- package/src/lib/provider/types.d.ts +86 -2
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
|
@@ -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(
|
|
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.
|
|
169
|
-
*
|
|
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,
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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,
|
|
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' },
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}, [apiBase,
|
|
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
|
}
|