@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.
@@ -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(REQUEST_TIMEOUT_MS);
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. Optionally accepts an idempotency key
169
- * to prevent duplicate creates on retries.
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, options) {
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 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);
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, extraHeaders) {
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' }, extraHeaders);
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
- 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
- }
1070
+ return readFieldErrors(parsed['errors']);
744
1071
  }
745
1072
  catch (_a) {
746
1073
  // Body may not be parseable
1074
+ return [];
747
1075
  }
748
- return [];
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 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
  }
@@ -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 a record. The idempotency key prevents
65
- * duplicate creates if the same request is retried.
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';
@@ -1 +1 @@
1
- export declare const SFA_KIT_VERSION = "1.10.0";
1
+ export declare const SFA_KIT_VERSION = "1.12.0";
@@ -1,3 +1,3 @@
1
1
  // このファイルは libs/sfa-kit/scripts/sync-version.cjs が生成する。
2
2
  // 手で編集しないこと。バージョンを上げるときは libs/sfa-kit/package.json を変更する。
3
- export const SFA_KIT_VERSION = '1.10.0';
3
+ export const SFA_KIT_VERSION = '1.12.0';