@geniee-inc/sfa-kit 1.10.0 → 1.12.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 +161 -1
- package/package.json +1 -1
- package/src/index.d.ts +8 -1
- package/src/index.js +4 -0
- package/src/lib/auth/SfaLoginGate.js +2 -2
- 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/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 +69 -3
- package/src/lib/provider/SfaClient.js +377 -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,47 @@ 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 batch without it, and kept next to the
|
|
36
|
+
* check that enforces it so the two cannot disagree.
|
|
37
|
+
*/
|
|
38
|
+
export const SFA_BULK_MAX_ROWS = 25;
|
|
5
39
|
/**
|
|
6
40
|
* @description Returns a timeout AbortSignal, or undefined where the runtime
|
|
7
41
|
* doesn't support AbortSignal.timeout (e.g. older jsdom in tests).
|
|
8
42
|
*/
|
|
9
|
-
function timeoutSignal() {
|
|
43
|
+
function timeoutSignal(timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
10
44
|
if (typeof AbortSignal.timeout === 'function') {
|
|
11
|
-
return AbortSignal.timeout(
|
|
45
|
+
return AbortSignal.timeout(timeoutMs);
|
|
12
46
|
}
|
|
13
47
|
return undefined;
|
|
14
48
|
}
|
|
@@ -91,6 +125,9 @@ export class SfaClient {
|
|
|
91
125
|
if (params === null || params === void 0 ? void 0 : params.filter) {
|
|
92
126
|
body['filter'] = params.filter;
|
|
93
127
|
}
|
|
128
|
+
if (params === null || params === void 0 ? void 0 : params.listId) {
|
|
129
|
+
body['list_id'] = params.listId;
|
|
130
|
+
}
|
|
94
131
|
const path = `/v1/objects/${enc(collection)}/records/query`;
|
|
95
132
|
const raw = yield this.postJson(path, body);
|
|
96
133
|
return normalizeKeystoneListResponse(raw, pageIndex, pageSize);
|
|
@@ -165,23 +202,118 @@ export class SfaClient {
|
|
|
165
202
|
}
|
|
166
203
|
}
|
|
167
204
|
/**
|
|
168
|
-
* @description Creates a new record.
|
|
169
|
-
*
|
|
205
|
+
* @description Creates a new record.
|
|
206
|
+
*
|
|
207
|
+
* NOTE: a retried create writes a second record — nothing de-duplicates it.
|
|
170
208
|
*/
|
|
171
|
-
create(collection, fields,
|
|
209
|
+
create(collection, fields,
|
|
210
|
+
// NOTE: kept so the class matches the published interface. Its only member
|
|
211
|
+
// is inert, so nothing reads it; it goes when the type does
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
213
|
+
options) {
|
|
172
214
|
return __awaiter(this, void 0, void 0, function* () {
|
|
173
215
|
this.assertDirectMode();
|
|
174
216
|
const normalized = yield this.applySchemaAwareNormalization(collection, fields);
|
|
175
217
|
const path = `/v1/objects/${enc(collection)}/records`;
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
const response = yield this.fetchWithBody('POST', path, { fields: normalizeWriteFields(normalized) }, extra);
|
|
218
|
+
const response = yield this.fetchWithBody('POST', path, {
|
|
219
|
+
fields: normalizeWriteFields(normalized),
|
|
220
|
+
});
|
|
181
221
|
const raw = yield response.json();
|
|
182
222
|
return normalizeKeystoneRecord(raw);
|
|
183
223
|
});
|
|
184
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* @description Creates many records of one object in a single call.
|
|
227
|
+
*
|
|
228
|
+
* The call is best-effort per row: the API answers 200 and reports every
|
|
229
|
+
* row's outcome, so a failed row does not undo the ones that worked. Per-row
|
|
230
|
+
* failures come back as results, not as thrown errors — throwing would
|
|
231
|
+
* discard the ids of the rows that succeeded, which are not recoverable
|
|
232
|
+
* anywhere else. SfaValidationError and the rest still surface when the
|
|
233
|
+
* whole call is rejected.
|
|
234
|
+
*
|
|
235
|
+
* Retry only the failed rows. What makes that safe is that those rows did
|
|
236
|
+
* not apply: nothing on the server de-duplicates a repeated write.
|
|
237
|
+
*/
|
|
238
|
+
bulkCreate(collection, rows) {
|
|
239
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
240
|
+
this.assertDirectMode();
|
|
241
|
+
assertBulkRowCount(rows.length);
|
|
242
|
+
const normalizedRows = yield Promise.all(rows.map((fields) => __awaiter(this, void 0, void 0, function* () {
|
|
243
|
+
return ({
|
|
244
|
+
fields: yield this.normalizeRowFields(collection, fields),
|
|
245
|
+
});
|
|
246
|
+
})));
|
|
247
|
+
return this.postBulk(collection, 'bulk-create', normalizedRows);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* @description Updates many records of one object in a single call.
|
|
252
|
+
*
|
|
253
|
+
* Each row carries its own `version`, so a stale row comes back as
|
|
254
|
+
* `conflict` with the server's current record to reconcile from — the rest
|
|
255
|
+
* of the batch still applies, and nothing is rolled back. Per-row outcomes
|
|
256
|
+
* are results, not thrown errors: a throw would hide which rows landed.
|
|
257
|
+
*/
|
|
258
|
+
bulkUpdate(collection, rows) {
|
|
259
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
260
|
+
this.assertDirectMode();
|
|
261
|
+
assertBulkRowCount(rows.length);
|
|
262
|
+
for (const row of rows)
|
|
263
|
+
this.assertRecordId(row.id);
|
|
264
|
+
const normalizedRows = yield Promise.all(rows.map((row) => __awaiter(this, void 0, void 0, function* () {
|
|
265
|
+
return ({
|
|
266
|
+
id: row.id,
|
|
267
|
+
version: row.version,
|
|
268
|
+
fields: yield this.normalizeRowFields(collection, row.fields),
|
|
269
|
+
});
|
|
270
|
+
})));
|
|
271
|
+
return this.postBulk(collection, 'bulk-update', normalizedRows);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* @description Deletes many records of one object in a single call.
|
|
276
|
+
*
|
|
277
|
+
* Each row carries the `version` last read for it; a stale row comes back as
|
|
278
|
+
* `conflict` and is left in place while the rest of the batch applies.
|
|
279
|
+
*/
|
|
280
|
+
bulkDelete(collection, rows) {
|
|
281
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
282
|
+
this.assertDirectMode();
|
|
283
|
+
assertBulkRowCount(rows.length);
|
|
284
|
+
for (const row of rows)
|
|
285
|
+
this.assertRecordId(row.id);
|
|
286
|
+
return this.postBulk(collection, 'bulk-delete', rows.map(row => ({ id: row.id, version: row.version })));
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* @description Prepares one row's fields for a bulk write.
|
|
291
|
+
*
|
|
292
|
+
* NOTE: shared by bulk-create and bulk-update so the two steps stay in this
|
|
293
|
+
* order for both. Schema-aware normalisation reads the object's field types
|
|
294
|
+
* — a select written by label, a date written loosely — and the write
|
|
295
|
+
* normalisation that follows only shapes what it produced.
|
|
296
|
+
*/
|
|
297
|
+
normalizeRowFields(collection, fields) {
|
|
298
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
299
|
+
return normalizeWriteFields(yield this.applySchemaAwareNormalization(collection, fields));
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* @description Sends one bulk request and reads its per-row outcomes.
|
|
304
|
+
*
|
|
305
|
+
* NOTE: shared by the three bulk methods so the envelope and the result
|
|
306
|
+
* parsing cannot drift apart between them.
|
|
307
|
+
*/
|
|
308
|
+
postBulk(collection, operation, records) {
|
|
309
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
310
|
+
const path = `/v1/objects/${enc(collection)}/records/${operation}`;
|
|
311
|
+
const response = yield this.fetchWithBody('POST', path, { records }, {
|
|
312
|
+
timeoutMs: BULK_REQUEST_TIMEOUT_MS,
|
|
313
|
+
});
|
|
314
|
+
return normalizeBulkResults(yield response.json(), records.length);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
185
317
|
/**
|
|
186
318
|
* @description Updates a record with optimistic locking. The version token
|
|
187
319
|
* (from a prior GET) is sent as the If-Match header; a 412 response means
|
|
@@ -193,7 +325,7 @@ export class SfaClient {
|
|
|
193
325
|
this.assertRecordId(recordId);
|
|
194
326
|
const normalized = yield this.applySchemaAwareNormalization(collection, fields);
|
|
195
327
|
const path = `/v1/objects/${enc(collection)}/records/${enc(recordId)}`;
|
|
196
|
-
const response = yield this.fetchWithBody('PATCH', path, { fields: normalizeWriteFields(normalized) }, { 'If-Match': version });
|
|
328
|
+
const response = yield this.fetchWithBody('PATCH', path, { fields: normalizeWriteFields(normalized) }, { headers: { 'If-Match': version } });
|
|
197
329
|
const raw = yield response.json();
|
|
198
330
|
return normalizeKeystoneRecord(raw);
|
|
199
331
|
});
|
|
@@ -228,7 +360,7 @@ export class SfaClient {
|
|
|
228
360
|
this.assertRecordId(recordId);
|
|
229
361
|
const path = `/v1/objects/${enc(collection)}/records/${enc(recordId)}`;
|
|
230
362
|
yield this.fetchWithBody('DELETE', path, undefined, {
|
|
231
|
-
'If-Match': version,
|
|
363
|
+
headers: { 'If-Match': version },
|
|
232
364
|
});
|
|
233
365
|
});
|
|
234
366
|
}
|
|
@@ -310,17 +442,17 @@ export class SfaClient {
|
|
|
310
442
|
* body / extra headers. Returns the raw Response so callers can inspect
|
|
311
443
|
* status codes, headers, or handle empty bodies (204).
|
|
312
444
|
*/
|
|
313
|
-
fetchWithBody(method, path, body,
|
|
445
|
+
fetchWithBody(method, path, body, options) {
|
|
314
446
|
return __awaiter(this, void 0, void 0, function* () {
|
|
315
447
|
var _a;
|
|
316
448
|
const url = `${this.apiBase}${path}`;
|
|
317
|
-
const headers = Object.assign({ 'Content-Type': 'application/json' },
|
|
449
|
+
const headers = Object.assign({ 'Content-Type': 'application/json' }, options === null || options === void 0 ? void 0 : options.headers);
|
|
318
450
|
this.applyAuth(headers);
|
|
319
451
|
const response = yield globalThis.fetch(url, {
|
|
320
452
|
method,
|
|
321
453
|
headers,
|
|
322
454
|
body: body ? JSON.stringify(body) : undefined,
|
|
323
|
-
signal: timeoutSignal(),
|
|
455
|
+
signal: timeoutSignal(options === null || options === void 0 ? void 0 : options.timeoutMs),
|
|
324
456
|
});
|
|
325
457
|
if (!response.ok) {
|
|
326
458
|
if (response.status === 401)
|
|
@@ -725,27 +857,243 @@ function parseConflictRecord(body) {
|
|
|
725
857
|
}
|
|
726
858
|
return null;
|
|
727
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* @description Guards the row count before a request goes out.
|
|
862
|
+
*
|
|
863
|
+
* NOTE: the API rejects the whole call with 422 when the array is empty or
|
|
864
|
+
* longer than the limit, so a caller that over-sends loses every row. Saying
|
|
865
|
+
* so here names the limit instead of surfacing a generic validation failure.
|
|
866
|
+
*/
|
|
867
|
+
function assertBulkRowCount(count) {
|
|
868
|
+
if (count === 0) {
|
|
869
|
+
throw new Error('A bulk write needs at least one row.');
|
|
870
|
+
}
|
|
871
|
+
if (count > SFA_BULK_MAX_ROWS) {
|
|
872
|
+
throw new Error(`A bulk write accepts at most ${SFA_BULK_MAX_ROWS} rows, got ${count}. ` +
|
|
873
|
+
'Split the selection into batches.');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* @description Reads the per-row outcomes out of a bulk response body.
|
|
878
|
+
*
|
|
879
|
+
* NOTE: results are ordered by `index`, not by their position in the array.
|
|
880
|
+
* That field exists to align them with the request, and a caller writing ids
|
|
881
|
+
* back into a grid needs submission order to be true.
|
|
882
|
+
*
|
|
883
|
+
* NOTE: a count that does not match the request is refused rather than
|
|
884
|
+
* returned. Unlike a row that failed — an expected outcome the caller must see
|
|
885
|
+
* — a short response leaves some submitted rows with no reported fate, and
|
|
886
|
+
* showing the rest as the whole picture makes those rows look neither written
|
|
887
|
+
* nor retryable. The caller has to re-read to learn the truth.
|
|
888
|
+
*/
|
|
889
|
+
function normalizeBulkResults(raw, submittedRowCount) {
|
|
890
|
+
const data = raw;
|
|
891
|
+
const rawResults = Array.isArray(data['results']) ? data['results'] : [];
|
|
892
|
+
const rows = rawResults
|
|
893
|
+
.filter((row) => row != null && typeof row === 'object')
|
|
894
|
+
.map(row => downgradeUnusableSuccess({
|
|
895
|
+
index: readRowIndex(row['index']),
|
|
896
|
+
id: readIdentifier(row['id']),
|
|
897
|
+
status: readBulkRowStatus(row['status']),
|
|
898
|
+
record: readBulkRecord(row['record']),
|
|
899
|
+
error: normalizeBulkRowError(row['error']),
|
|
900
|
+
}));
|
|
901
|
+
return alignedToTheRequest(rows, submittedRowCount);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* @description The position in the request a result answers for, or null when
|
|
905
|
+
* the response does not say.
|
|
906
|
+
*
|
|
907
|
+
* NOTE: `index` is a required field, and the order the rows arrive in is not
|
|
908
|
+
* part of the contract. Filling a missing one from the array position would
|
|
909
|
+
* turn that order into a guarantee the API never gave, and a caller pairing
|
|
910
|
+
* results with its own rows would silently act on the wrong ones. A value that
|
|
911
|
+
* is not a whole position — fractional, negative, NaN, Infinity — says just as
|
|
912
|
+
* little.
|
|
913
|
+
*/
|
|
914
|
+
function readRowIndex(raw) {
|
|
915
|
+
const isWholePosition = typeof raw === 'number' && Number.isInteger(raw) && raw >= 0;
|
|
916
|
+
return isWholePosition ? raw : null;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* @description Puts the results in request order, refusing a response that
|
|
920
|
+
* cannot be paired with the request.
|
|
921
|
+
*
|
|
922
|
+
* NOTE: the outcome for each submitted position is looked up by `index`, so
|
|
923
|
+
* the order the rows arrived in is never relied on. A position no row answers
|
|
924
|
+
* for — an index missing, invalid, repeated, or past the end — is refused for
|
|
925
|
+
* the whole call: that submitted row would have no reported outcome, and
|
|
926
|
+
* handing back the rest would show it as neither written nor retryable.
|
|
927
|
+
*
|
|
928
|
+
* NOTE: the count is checked after parsing, not on the raw array. An entry
|
|
929
|
+
* that is not an object is dropped on the way in, so counting the raw array
|
|
930
|
+
* would pass a response of the right length that answers for fewer rows than
|
|
931
|
+
* it appears to.
|
|
932
|
+
*/
|
|
933
|
+
function alignedToTheRequest(rows, submittedRowCount) {
|
|
934
|
+
if (rows.length !== submittedRowCount) {
|
|
935
|
+
throw new Error(`A bulk write answered with ${rows.length} results for ` +
|
|
936
|
+
`${submittedRowCount} submitted rows. Re-read the records to find ` +
|
|
937
|
+
'out which ones landed.');
|
|
938
|
+
}
|
|
939
|
+
const byPosition = new Map(rows.flatMap(row => (row.index == null ? [] : [[row.index, row]])));
|
|
940
|
+
return Array.from({ length: submittedRowCount }, (_row, position) => {
|
|
941
|
+
const answered = byPosition.get(position);
|
|
942
|
+
if ((answered === null || answered === void 0 ? void 0 : answered.index) == null) {
|
|
943
|
+
throw new Error(`A bulk write answered for rows [${rows
|
|
944
|
+
.map(row => { var _a; return (_a = row.index) !== null && _a !== void 0 ? _a : '?'; })
|
|
945
|
+
.join(', ')}] of ${submittedRowCount} submitted, so a result ` +
|
|
946
|
+
'cannot be paired with the row that produced it. Re-read the ' +
|
|
947
|
+
'records to find out which ones landed.');
|
|
948
|
+
}
|
|
949
|
+
return Object.assign(Object.assign({}, answered), { index: answered.index });
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* @description An identifier, or null when the row does not carry a usable one.
|
|
954
|
+
*
|
|
955
|
+
* NOTE: an empty string is not an id. Reported as one it would reach a caller
|
|
956
|
+
* as a row it believes was written — a delete would list `''` among the ids it
|
|
957
|
+
* removed — so the row counts as not landed instead.
|
|
958
|
+
*/
|
|
959
|
+
function readIdentifier(raw) {
|
|
960
|
+
return typeof raw === 'string' && raw !== '' ? raw : null;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* @description The record a row produced, or null when there is none to use.
|
|
964
|
+
*
|
|
965
|
+
* NOTE: normalisation answers with an empty shell for a body it cannot read,
|
|
966
|
+
* and a shell is not what a written row produces: it has no id and no version.
|
|
967
|
+
* Passing it on would let the row count as written and hand a grid a blank row
|
|
968
|
+
* with nothing to save against, so the row is reported as not landed. The same
|
|
969
|
+
* applies to a conflict, whose record exists only to reconcile against.
|
|
970
|
+
*/
|
|
971
|
+
function readBulkRecord(raw) {
|
|
972
|
+
if (raw == null || typeof raw !== 'object')
|
|
973
|
+
return null;
|
|
974
|
+
const record = normalizeKeystoneRecord(raw);
|
|
975
|
+
return record.record_id === '' ? null : record;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* @description Refuses to report a write that nothing can be built on.
|
|
979
|
+
*
|
|
980
|
+
* NOTE: a created or updated row answers with the record it produced, and the
|
|
981
|
+
* contract has that record carry a `version` — the token the next update or
|
|
982
|
+
* delete locks against. Reported as written, such a row would be handed to a
|
|
983
|
+
* grid as the saved state of that row, and the next save would have nothing to
|
|
984
|
+
* address it by. A row answering with no record at all is the same problem one
|
|
985
|
+
* step earlier, and each says so in its own code: a reader of the log should
|
|
986
|
+
* not have to guess which of the two the server sent.
|
|
987
|
+
*
|
|
988
|
+
* NOTE: it is not reported as failed either. The row did land, and `failed`
|
|
989
|
+
* asks the caller to retry it — for a create that would write a second record.
|
|
990
|
+
* `unknown` says what a retry cannot fix while keeping the id: re-read the
|
|
991
|
+
* record.
|
|
992
|
+
*
|
|
993
|
+
* NOTE: a conflict is left as it is. Its record exists to reconcile against,
|
|
994
|
+
* it already counts as not landed, and calling it `unknown` would throw away
|
|
995
|
+
* the one thing the caller needs to know about it.
|
|
996
|
+
*/
|
|
997
|
+
function downgradeUnusableSuccess(row) {
|
|
998
|
+
const wroteARecord = row.status === 'created' || row.status === 'updated';
|
|
999
|
+
if (!wroteARecord)
|
|
1000
|
+
return row;
|
|
1001
|
+
if (row.record == null) {
|
|
1002
|
+
return unresolved(row, 'missing_record', 'The write reported success but answered with no record, so there is ' +
|
|
1003
|
+
'nothing to write back. Re-read the record to find out what landed.');
|
|
1004
|
+
}
|
|
1005
|
+
if (!isUsableVersion(row.record.version)) {
|
|
1006
|
+
return unresolved(row, 'missing_version', 'The written record carried no version to save against. Re-read it ' +
|
|
1007
|
+
'before writing to it again.');
|
|
1008
|
+
}
|
|
1009
|
+
return row;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* @description Marks a row as one the kit cannot report either way, keeping
|
|
1013
|
+
* whatever the server did say about it.
|
|
1014
|
+
*/
|
|
1015
|
+
function unresolved(row, code, message) {
|
|
1016
|
+
var _a;
|
|
1017
|
+
return Object.assign(Object.assign({}, row), { status: 'unknown', error: (_a = row.error) !== null && _a !== void 0 ? _a : { code, message, fieldErrors: [] } });
|
|
1018
|
+
}
|
|
1019
|
+
function isUsableVersion(version) {
|
|
1020
|
+
return typeof version === 'string' && version !== '';
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* @description Reads a row's outcome, keeping unrecognised ones distinguishable.
|
|
1024
|
+
*
|
|
1025
|
+
* NOTE: a status outside the contract becomes `unknown` rather than being
|
|
1026
|
+
* passed through under the union's type. The row still comes back — a create
|
|
1027
|
+
* that landed carries an id recoverable nowhere else — but no caller can
|
|
1028
|
+
* mistake it for a written row.
|
|
1029
|
+
*/
|
|
1030
|
+
function readBulkRowStatus(raw) {
|
|
1031
|
+
var _a;
|
|
1032
|
+
const known = {
|
|
1033
|
+
created: 'created',
|
|
1034
|
+
updated: 'updated',
|
|
1035
|
+
deleted: 'deleted',
|
|
1036
|
+
conflict: 'conflict',
|
|
1037
|
+
invalid: 'invalid',
|
|
1038
|
+
not_attempted: 'not_attempted',
|
|
1039
|
+
indeterminate: 'indeterminate',
|
|
1040
|
+
};
|
|
1041
|
+
return typeof raw === 'string' ? (_a = known[raw]) !== null && _a !== void 0 ? _a : 'unknown' : 'unknown';
|
|
1042
|
+
}
|
|
1043
|
+
function normalizeBulkRowError(raw) {
|
|
1044
|
+
// NOTE: a plain string is not the Problem shape the contract promises, but
|
|
1045
|
+
// it is still the server's own account of the failure — kept as the message
|
|
1046
|
+
// rather than dropped, or the row would fail with nothing said about why
|
|
1047
|
+
if (typeof raw === 'string') {
|
|
1048
|
+
return { code: '', message: raw, fieldErrors: [] };
|
|
1049
|
+
}
|
|
1050
|
+
if (raw == null || typeof raw !== 'object')
|
|
1051
|
+
return null;
|
|
1052
|
+
const problem = raw;
|
|
1053
|
+
return {
|
|
1054
|
+
code: typeof problem['code'] === 'string' ? problem['code'] : '',
|
|
1055
|
+
// NOTE: Problem carries the human-readable text in `detail`, with `title`
|
|
1056
|
+
// as the fallback when the server sent only a summary
|
|
1057
|
+
message: readProblemText(problem['detail'], problem['title']),
|
|
1058
|
+
fieldErrors: readFieldErrors(problem['errors']),
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/** @description Problem's human-readable text, `detail` first, `title` after. */
|
|
1062
|
+
function readProblemText(detail, title) {
|
|
1063
|
+
if (typeof detail === 'string')
|
|
1064
|
+
return detail;
|
|
1065
|
+
return typeof title === 'string' ? title : '';
|
|
1066
|
+
}
|
|
728
1067
|
function parseFieldErrors(body) {
|
|
729
1068
|
try {
|
|
730
1069
|
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
|
-
}
|
|
1070
|
+
return readFieldErrors(parsed['errors']);
|
|
744
1071
|
}
|
|
745
1072
|
catch (_a) {
|
|
746
1073
|
// Body may not be parseable
|
|
1074
|
+
return [];
|
|
747
1075
|
}
|
|
748
|
-
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* @description Reads Problem's `errors` extension member.
|
|
1079
|
+
*
|
|
1080
|
+
* NOTE: separate from parseFieldErrors so a caller that already has the parsed
|
|
1081
|
+
* Problem — a bulk row, which arrives inside a 200 body — does not serialize it
|
|
1082
|
+
* back to a string just to read it again.
|
|
1083
|
+
*/
|
|
1084
|
+
function readFieldErrors(rawErrors) {
|
|
1085
|
+
if (!Array.isArray(rawErrors))
|
|
1086
|
+
return [];
|
|
1087
|
+
return rawErrors
|
|
1088
|
+
.filter((e) => e != null && typeof e === 'object')
|
|
1089
|
+
.map(e => {
|
|
1090
|
+
var _a, _b, _c;
|
|
1091
|
+
return ({
|
|
1092
|
+
field: (_a = e['field']) !== null && _a !== void 0 ? _a : '',
|
|
1093
|
+
code: (_b = e['code']) !== null && _b !== void 0 ? _b : '',
|
|
1094
|
+
message: (_c = e['message']) !== null && _c !== void 0 ? _c : '',
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
749
1097
|
}
|
|
750
1098
|
// ── Mutation error classes ──
|
|
751
1099
|
/**
|
|
@@ -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
|
}
|
|
@@ -50,6 +50,15 @@ export interface SfaListResponse {
|
|
|
50
50
|
/** Cursor for fetching the previous page (keystone cursor-based pagination). */
|
|
51
51
|
readonly prevCursor?: string | null;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* @description The shape of the client SfaProvider hands out — not an
|
|
55
|
+
* extension point.
|
|
56
|
+
*
|
|
57
|
+
* NOTE: methods are added here as the API surface grows, so this interface is
|
|
58
|
+
* not treated as a breaking change when it gains one. Consumers take the
|
|
59
|
+
* client from `useSfaClient()`; a test that needs a stand-in mocks that hook
|
|
60
|
+
* rather than implementing this.
|
|
61
|
+
*/
|
|
53
62
|
export interface SfaClientInterface {
|
|
54
63
|
get(collection: string, recordId: string): Promise<SfaRecord>;
|
|
55
64
|
list(collection: string, params?: SfaListParams): Promise<SfaListResponse>;
|
|
@@ -59,12 +68,21 @@ export interface SfaClientInterface {
|
|
|
59
68
|
create(collection: string, fields: Record<string, unknown>, options?: SfaCreateOptions): Promise<SfaRecord>;
|
|
60
69
|
update(collection: string, recordId: string, fields: Record<string, unknown>, version: string): Promise<SfaRecord>;
|
|
61
70
|
delete(collection: string, recordId: string, version: string): Promise<void>;
|
|
71
|
+
bulkCreate(collection: string, rows: readonly Record<string, unknown>[]): Promise<readonly SfaBulkRowResult[]>;
|
|
72
|
+
bulkUpdate(collection: string, rows: readonly SfaBulkUpdateRow[]): Promise<readonly SfaBulkRowResult[]>;
|
|
73
|
+
bulkDelete(collection: string, rows: readonly SfaBulkDeleteRow[]): Promise<readonly SfaBulkRowResult[]>;
|
|
62
74
|
}
|
|
63
75
|
/**
|
|
64
|
-
* @description Options for creating
|
|
65
|
-
*
|
|
76
|
+
* @description Options for creating one record. Its only member is
|
|
77
|
+
* deprecated, so the type itself goes at the next breaking release.
|
|
66
78
|
*/
|
|
67
79
|
export interface SfaCreateOptions {
|
|
80
|
+
/**
|
|
81
|
+
* @deprecated Accepted and ignored. Keystone never de-duplicated on
|
|
82
|
+
* `Idempotency-Key`, and SFAJAI-294 drops the header from the contract, so
|
|
83
|
+
* the kit no longer sends it. Kept only because removing it would fail the
|
|
84
|
+
* build of callers that pass it; drop it at the next breaking release.
|
|
85
|
+
*/
|
|
68
86
|
readonly idempotencyKey?: string;
|
|
69
87
|
}
|
|
70
88
|
/**
|
|
@@ -75,6 +93,70 @@ export interface SfaFieldError {
|
|
|
75
93
|
readonly code: string;
|
|
76
94
|
readonly message: string;
|
|
77
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* @description How one row of a bulk write ended.
|
|
98
|
+
*
|
|
99
|
+
* `conflict` means the row's `version` no longer matched, `invalid` that it
|
|
100
|
+
* failed validation. `not_attempted` means the call ran out of budget before
|
|
101
|
+
* this row started, so nothing was written and it can be sent again exactly as
|
|
102
|
+
* it was. `indeterminate` means the server cannot say whether the row applied.
|
|
103
|
+
* All are reported per row with HTTP 200 — a bulk call is best-effort and has
|
|
104
|
+
* no all-or-nothing mode.
|
|
105
|
+
*
|
|
106
|
+
* `unknown` is not sent by the API: it is what the kit reads an unrecognised
|
|
107
|
+
* status as, and what a claimed success it cannot make sense of becomes — no
|
|
108
|
+
* record, or no `version` for the next write to lock against. The row keeps
|
|
109
|
+
* its `id`, and reaches the caller in `unresolved`, never in `failed`:
|
|
110
|
+
* retrying a create that already landed writes a second record.
|
|
111
|
+
*/
|
|
112
|
+
export type SfaBulkRowStatus = 'created' | 'updated' | 'deleted' | 'conflict' | 'invalid' | 'not_attempted' | 'indeterminate' | 'unknown';
|
|
113
|
+
/** @description One row of a bulk update: what to change and from which version. */
|
|
114
|
+
export interface SfaBulkUpdateRow {
|
|
115
|
+
readonly id: string;
|
|
116
|
+
/**
|
|
117
|
+
* The version last read for this record. A mismatch makes this row a
|
|
118
|
+
* `conflict` and leaves it unchanged; the rest of the batch still applies.
|
|
119
|
+
*/
|
|
120
|
+
readonly version: string;
|
|
121
|
+
/** Only the keys present are changed. */
|
|
122
|
+
readonly fields: Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
/** @description One row of a bulk delete: what to remove and from which version. */
|
|
125
|
+
export interface SfaBulkDeleteRow {
|
|
126
|
+
readonly id: string;
|
|
127
|
+
/** As in an update — a stale version makes this row a `conflict`. */
|
|
128
|
+
readonly version: string;
|
|
129
|
+
}
|
|
130
|
+
/** @description The outcome of one submitted row, positioned by `index`. */
|
|
131
|
+
export interface SfaBulkRowResult {
|
|
132
|
+
/** Zero-based position in the request, for mapping the result back to input. */
|
|
133
|
+
readonly index: number;
|
|
134
|
+
/**
|
|
135
|
+
* The record's identifier. Always present for update and delete; on create
|
|
136
|
+
* only once the row succeeded. Null when the response carried none the
|
|
137
|
+
* caller could use, which makes the row count as not landed.
|
|
138
|
+
*/
|
|
139
|
+
readonly id: string | null;
|
|
140
|
+
readonly status: SfaBulkRowStatus;
|
|
141
|
+
/**
|
|
142
|
+
* On a successful create or update, the resulting record. On a conflict, the
|
|
143
|
+
* server's current state, so the caller can show what it lost to. Otherwise
|
|
144
|
+
* null — including when the response carried a record that could not be
|
|
145
|
+
* read, since an unusable record is nothing to write back or reconcile
|
|
146
|
+
* against.
|
|
147
|
+
*/
|
|
148
|
+
readonly record: SfaRecord | null;
|
|
149
|
+
/** Set on `conflict` and `invalid`: the problem detail for this row. */
|
|
150
|
+
readonly error: SfaBulkRowError | null;
|
|
151
|
+
}
|
|
152
|
+
/** @description Why one row failed, in the same shape single writes report. */
|
|
153
|
+
export interface SfaBulkRowError {
|
|
154
|
+
/** Machine-readable problem code, e.g. `version_mismatch`. */
|
|
155
|
+
readonly code: string;
|
|
156
|
+
readonly message: string;
|
|
157
|
+
/** Per-field errors when the row failed validation. */
|
|
158
|
+
readonly fieldErrors: readonly SfaFieldError[];
|
|
159
|
+
}
|
|
78
160
|
export interface SfaListParams {
|
|
79
161
|
readonly limit?: number;
|
|
80
162
|
readonly offset?: number;
|
|
@@ -83,6 +165,8 @@ export interface SfaListParams {
|
|
|
83
165
|
readonly filter?: Record<string, unknown>;
|
|
84
166
|
/** Opaque cursor for keystone cursor-based pagination. Takes precedence over offset. */
|
|
85
167
|
readonly cursor?: string;
|
|
168
|
+
/** List tab pre-filter. Sent as `list_id` in the keystone query body. */
|
|
169
|
+
readonly listId?: string;
|
|
86
170
|
}
|
|
87
171
|
export type SfaObjectOrigin = 'standard' | 'custom';
|
|
88
172
|
export type SfaRelationshipKind = 'association' | 'master_detail';
|
package/src/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SFA_KIT_VERSION = "1.
|
|
1
|
+
export declare const SFA_KIT_VERSION = "1.12.0";
|
package/src/lib/version.js
CHANGED