@geniee-inc/sfa-kit 1.11.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.
@@ -0,0 +1,55 @@
1
+ import type { SfaBulkRowResult, SfaClientInterface } from '../provider/types';
2
+ export interface BulkWriteState<TRow> {
3
+ readonly run: (rows: readonly TRow[]) => Promise<readonly SfaBulkRowResult[]>;
4
+ readonly results: readonly SfaBulkRowResult[];
5
+ readonly isLoading: boolean;
6
+ readonly error: Error | null;
7
+ readonly reset: () => void;
8
+ }
9
+ /**
10
+ * @description The state the three bulk write hooks share: one call in flight,
11
+ * its per-row results, and whether the call as a whole was rejected.
12
+ *
13
+ * `send` takes the client and collection as arguments so it can be defined
14
+ * once per hook at module level. Its identity never changes, which is what
15
+ * keeps `run` stable across renders.
16
+ *
17
+ * A hook instance outlives its arguments: a grid switches object, and the
18
+ * provider builds a new client on a token refresh or an org switch. Outcomes
19
+ * must never be reported as the current object's, and this is settled in two
20
+ * places that answer two different questions.
21
+ *
22
+ * NOTE: what to show is settled during render, by clearing the state as the
23
+ * arguments change. Clearing in an effect instead would leave one render
24
+ * showing the previous object's rows. React discards a render it does not
25
+ * commit — an interrupted transition — along with the state set during it, so
26
+ * a screen that never appears cannot blank the one the user is looking at.
27
+ *
28
+ * NOTE: whether an answer may still be published is settled against the
29
+ * arguments as last **committed**, tracked in a ref written from a commit
30
+ * effect. Effects run only for renders that commit, so a transition React
31
+ * starts and abandons cannot orphan the request the visible screen is waiting
32
+ * for. A ref written during render would: the write survives the abandoned
33
+ * render, and the committed screen's own answer would be taken for a stale
34
+ * one.
35
+ *
36
+ * NOTE: the number itself is assigned during render and kept in state, so a
37
+ * callback knows which arguments it was made for without reading the ref, and
38
+ * the ref is recorded in the insertion phase — before any child's layout
39
+ * effect can call back into this hook. A callback from an earlier render is
40
+ * therefore recognised as stale even inside the commit that superseded it,
41
+ * and the current one is never mistaken for stale.
42
+ *
43
+ * The number advances on every committed change, so returning to an object
44
+ * does not adopt the earlier visit's request: a batch started before the
45
+ * reader left carries a number the ref has moved past.
46
+ *
47
+ * NOTE: read-only is read from that same committed ref, not from the
48
+ * callback's own value. The ref is current from the insertion phase onwards,
49
+ * so it answers for the commit that turns the setting on as well as for a
50
+ * `run` the caller kept from before it — a callback's own copy would be
51
+ * whatever its render saw. Read-only throws before anything is sent, the same
52
+ * as the single-record write hooks: it is a provider setting, not an outcome
53
+ * of the call, so it never reaches `error`.
54
+ */
55
+ export declare function useBulkWrite<TRow>(collection: string, send: (client: SfaClientInterface, collection: string, rows: readonly TRow[]) => Promise<readonly SfaBulkRowResult[]>): BulkWriteState<TRow>;
@@ -0,0 +1,160 @@
1
+ import { __awaiter } from "tslib";
2
+ import { useCallback, useEffect, useInsertionEffect, useRef, useState, } from 'react';
3
+ import { ERR_READ_ONLY } from '../errors';
4
+ import { useSfaContext } from '../provider/useSfaClient';
5
+ /** Stable identity so an unfinished batch never re-renders on a fresh array. */
6
+ const NO_RESULTS = [];
7
+ /**
8
+ * Runs in the insertion phase of the commit, which React finishes for the
9
+ * whole tree before any layout effect runs. A child's layout effect can
10
+ * therefore call this hook during the very commit that changed its arguments
11
+ * and still see them recorded — the ordering is child-insertion,
12
+ * parent-insertion, child-layout, parent-layout. A layout effect here would
13
+ * be too late, since the child's runs first.
14
+ *
15
+ * Insertion effects run only for renders that commit, so an abandoned
16
+ * transition still leaves nothing behind. There is no commit to observe on
17
+ * the server, where it would only warn, so the plain effect stands in.
18
+ */
19
+ const useCommitEffect = typeof window === 'undefined' ? useEffect : useInsertionEffect;
20
+ const NOTHING_WRITTEN = {
21
+ results: NO_RESULTS,
22
+ isLoading: false,
23
+ error: null,
24
+ };
25
+ /**
26
+ * @description The state the three bulk write hooks share: one call in flight,
27
+ * its per-row results, and whether the call as a whole was rejected.
28
+ *
29
+ * `send` takes the client and collection as arguments so it can be defined
30
+ * once per hook at module level. Its identity never changes, which is what
31
+ * keeps `run` stable across renders.
32
+ *
33
+ * A hook instance outlives its arguments: a grid switches object, and the
34
+ * provider builds a new client on a token refresh or an org switch. Outcomes
35
+ * must never be reported as the current object's, and this is settled in two
36
+ * places that answer two different questions.
37
+ *
38
+ * NOTE: what to show is settled during render, by clearing the state as the
39
+ * arguments change. Clearing in an effect instead would leave one render
40
+ * showing the previous object's rows. React discards a render it does not
41
+ * commit — an interrupted transition — along with the state set during it, so
42
+ * a screen that never appears cannot blank the one the user is looking at.
43
+ *
44
+ * NOTE: whether an answer may still be published is settled against the
45
+ * arguments as last **committed**, tracked in a ref written from a commit
46
+ * effect. Effects run only for renders that commit, so a transition React
47
+ * starts and abandons cannot orphan the request the visible screen is waiting
48
+ * for. A ref written during render would: the write survives the abandoned
49
+ * render, and the committed screen's own answer would be taken for a stale
50
+ * one.
51
+ *
52
+ * NOTE: the number itself is assigned during render and kept in state, so a
53
+ * callback knows which arguments it was made for without reading the ref, and
54
+ * the ref is recorded in the insertion phase — before any child's layout
55
+ * effect can call back into this hook. A callback from an earlier render is
56
+ * therefore recognised as stale even inside the commit that superseded it,
57
+ * and the current one is never mistaken for stale.
58
+ *
59
+ * The number advances on every committed change, so returning to an object
60
+ * does not adopt the earlier visit's request: a batch started before the
61
+ * reader left carries a number the ref has moved past.
62
+ *
63
+ * NOTE: read-only is read from that same committed ref, not from the
64
+ * callback's own value. The ref is current from the insertion phase onwards,
65
+ * so it answers for the commit that turns the setting on as well as for a
66
+ * `run` the caller kept from before it — a callback's own copy would be
67
+ * whatever its render saw. Read-only throws before anything is sent, the same
68
+ * as the single-record write hooks: it is a provider setting, not an outcome
69
+ * of the call, so it never reaches `error`.
70
+ */
71
+ export function useBulkWrite(collection, send) {
72
+ const { client, readOnly } = useSfaContext();
73
+ const [state, setState] = useState(NOTHING_WRITTEN);
74
+ const [numbered, setNumbered] = useState({
75
+ client,
76
+ collection,
77
+ sequence: 0,
78
+ });
79
+ const callId = useRef(0);
80
+ const committed = useRef({
81
+ readOnly,
82
+ sequence: numbered.sequence,
83
+ });
84
+ // NOTE: the state adjustment React documents for props changes — set during
85
+ // render, so this render already reports nothing, and thrown away with the
86
+ // render if it never commits
87
+ if (numbered.client !== client || numbered.collection !== collection) {
88
+ setNumbered({ client, collection, sequence: numbered.sequence + 1 });
89
+ setState(NOTHING_WRITTEN);
90
+ }
91
+ const sequence = numbered.sequence;
92
+ useCommitEffect(() => {
93
+ committed.current = { readOnly, sequence };
94
+ }, [readOnly, sequence]);
95
+ const run = useCallback((rows) => __awaiter(this, void 0, void 0, function* () {
96
+ // NOTE: both refusals come before anything is claimed. Bumping the call
97
+ // id first would let a refusal disown a batch that is still running, and
98
+ // its answer — the only record of the ids that landed — would be dropped
99
+ if (committed.current.readOnly) {
100
+ throw new Error(ERR_READ_ONLY);
101
+ }
102
+ // NOTE: this callback sends to the client and collection it was made
103
+ // with. Once a later set of arguments has been committed it would write
104
+ // to an object the reader has left, so it refuses. Sending to wherever
105
+ // the hook now points would be worse: the rows were meant for the object
106
+ // the caller asked for.
107
+ //
108
+ // NOTE: strictly later, not merely different. Only a callback the
109
+ // commit record has moved past is stale; one carrying a number the
110
+ // record has not reached could only come from a render that never
111
+ // committed, and no such callback can be invoked.
112
+ if (committed.current.sequence > sequence) {
113
+ throw new Error(`This bulk write was prepared for ${collection} under arguments ` +
114
+ 'the hook has moved past — a different object, or a client the ' +
115
+ 'provider has replaced. Use the callback it returns now.');
116
+ }
117
+ callId.current += 1;
118
+ const currentCallId = callId.current;
119
+ const stillOwnsTheState = () => currentCallId === callId.current &&
120
+ committed.current.sequence === sequence;
121
+ // NOTE: the previous results stay visible while the next batch runs, so
122
+ // a grid does not blank the outcomes the reader is still acting on
123
+ setState(previous => ({
124
+ results: previous.results,
125
+ isLoading: true,
126
+ error: null,
127
+ }));
128
+ try {
129
+ const rowResults = yield send(client, collection, rows);
130
+ if (stillOwnsTheState()) {
131
+ setState({ results: rowResults, isLoading: false, error: null });
132
+ }
133
+ return rowResults;
134
+ }
135
+ catch (e) {
136
+ // NOTE: the results of the previous call are kept. They are still true
137
+ // about the rows that landed then, and a rejected retry must not be
138
+ // what makes those ids disappear — nothing else records them
139
+ if (stillOwnsTheState()) {
140
+ setState(previous => ({
141
+ results: previous.results,
142
+ isLoading: false,
143
+ error: e instanceof Error ? e : new Error(String(e)),
144
+ }));
145
+ }
146
+ throw e;
147
+ }
148
+ }), [client, collection, send, sequence]);
149
+ const reset = useCallback(() => {
150
+ callId.current += 1;
151
+ setState(NOTHING_WRITTEN);
152
+ }, []);
153
+ return {
154
+ run,
155
+ results: state.results,
156
+ isLoading: state.isLoading,
157
+ error: state.error,
158
+ reset,
159
+ };
160
+ }
@@ -1,5 +1,6 @@
1
1
  import { __awaiter } from "tslib";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useEmbedContext } from '../auth/useEmbedContext';
3
4
  import { useSfaClient } from '../provider/useSfaClient';
4
5
  /** Stable identity so an unanswered query never re-renders on a fresh array. */
5
6
  const NO_ITEMS = [];
@@ -7,9 +8,11 @@ const NO_ITEMS = [];
7
8
  * @description Fetches a paginated list of records from the SFA/CRM API.
8
9
  */
9
10
  export function useRecordList(collection, params, options) {
10
- var _a, _b, _c;
11
+ var _a, _b, _c, _d;
11
12
  const client = useSfaClient();
12
- const limit = (_a = params === null || params === void 0 ? void 0 : params.limit) !== null && _a !== void 0 ? _a : 20;
13
+ const embedCtx = useEmbedContext();
14
+ const listId = (_a = params === null || params === void 0 ? void 0 : params.listId) !== null && _a !== void 0 ? _a : embedCtx === null || embedCtx === void 0 ? void 0 : embedCtx.list_id;
15
+ const limit = (_b = params === null || params === void 0 ? void 0 : params.limit) !== null && _b !== void 0 ? _b : 20;
13
16
  const [items, setItems] = useState([]);
14
17
  const [totalCount, setTotalCount] = useState(0);
15
18
  const [hasMore, setHasMore] = useState(false);
@@ -22,11 +25,11 @@ export function useRecordList(collection, params, options) {
22
25
  * about the *first* page: from there the hook pages on its own, computing
23
26
  * offsets from the page number and reusing cursors the server handed back.
24
27
  */
25
- const initialOffset = (_b = params === null || params === void 0 ? void 0 : params.offset) !== null && _b !== void 0 ? _b : 0;
28
+ const initialOffset = (_c = params === null || params === void 0 ? void 0 : params.offset) !== null && _c !== void 0 ? _c : 0;
26
29
  const initialCursor = params === null || params === void 0 ? void 0 : params.cursor;
27
30
  const initialPage = limit > 0 ? Math.floor(initialOffset / limit) : 0;
28
31
  const [pageIndex, setPageIndex] = useState(initialPage);
29
- const enabled = (_c = options === null || options === void 0 ? void 0 : options.enabled) !== null && _c !== void 0 ? _c : true;
32
+ const enabled = (_d = options === null || options === void 0 ? void 0 : options.enabled) !== null && _d !== void 0 ? _d : true;
30
33
  // NOTE: the seed is never published. Until a fetch settles for the current
31
34
  // query the reported status is derived below, so this only matters after the
32
35
  // first settle — by which time a fetch has set it.
@@ -50,6 +53,7 @@ export function useRecordList(collection, params, options) {
50
53
  filterKey,
51
54
  initialOffset,
52
55
  initialCursor,
56
+ listId,
53
57
  }), [
54
58
  collection,
55
59
  limit,
@@ -58,6 +62,7 @@ export function useRecordList(collection, params, options) {
58
62
  filterKey,
59
63
  initialOffset,
60
64
  initialCursor,
65
+ listId,
61
66
  ]);
62
67
  /**
63
68
  * Which client, query and page the records in state came from, so a failure
@@ -95,14 +100,14 @@ export function useRecordList(collection, params, options) {
95
100
  // Every other page is reached with what the server handed back, or with
96
101
  // an offset computed from the page number.
97
102
  const cursor = (_a = cursorMapRef.current.get(page)) !== null && _a !== void 0 ? _a : (page === initialPage ? initialCursor : undefined);
98
- const result = yield client.list(collection, Object.assign({ limit,
103
+ const result = yield client.list(collection, Object.assign(Object.assign({ limit,
99
104
  // NOTE: offset is a record position, not a page number. Sending the
100
105
  // page number asks for records 1..limit for page 1: it overlaps
101
106
  // page 0 and comes back reporting pageIndex 0, so the list never
102
107
  // advances. SfaClient derives pageIndex from this offset even when
103
108
  // the server paginates by cursor, so the two have to agree.
104
109
  offset: page === initialPage ? initialOffset : page * limit, sortBy,
105
- sortOrder, filter: filterRef.current }, (cursor ? { cursor } : {})));
110
+ sortOrder, filter: filterRef.current }, (cursor ? { cursor } : {})), (listId ? { listId } : {})));
106
111
  if (currentFetchId === fetchId.current) {
107
112
  // NOTE: the refs are written before the setState calls. Outside an
108
113
  // event handler React 18 in legacy mode re-renders on every setState
@@ -179,6 +184,7 @@ export function useRecordList(collection, params, options) {
179
184
  initialOffset,
180
185
  initialCursor,
181
186
  initialPage,
187
+ listId,
182
188
  ]);
183
189
  useEffect(() => {
184
190
  // Invalidate anything still in flight before deciding what to do next.
@@ -1,4 +1,23 @@
1
- import type { SfaApiMode, SfaClientConfig, SfaClientInterface, SfaCreateOptions, SfaDescribeResult, SfaFieldError, SfaFieldTypeCatalog, SfaListParams, SfaListResponse, SfaObjectCatalog, SfaRecord } from './types';
1
+ import type { SfaApiMode, SfaBulkDeleteRow, SfaBulkRowResult, SfaBulkUpdateRow, SfaClientConfig, SfaClientInterface, SfaCreateOptions, SfaDescribeResult, SfaFieldError, SfaFieldTypeCatalog, SfaListParams, SfaListResponse, SfaObjectCatalog, SfaRecord } from './types';
2
+ /**
3
+ * @description The most rows one bulk call sends.
4
+ *
5
+ * NOTE: the API's own contract is `minItems: 1, maxItems: 100`. 25 is the
6
+ * kit's stricter limit, and it comes from how long the call takes rather than
7
+ * from what the API accepts: Keystone delegates one row at a time, measured at
8
+ * about 1.4 s per row on staging (2026-09-04). 25 rows fits inside the
9
+ * server's own 45 s budget for a bulk write, which in turn fits inside the
10
+ * 60 s ALB idle timeout. Agreed with Kumada and Takeuchi.
11
+ *
12
+ * Do not raise this to match `maxItems` — a call of 100 rows would outlast
13
+ * the server's budget and then the load balancer, and the caller would learn
14
+ * nothing about which rows had been written. It can go up only once a row
15
+ * costs less, or those limits change.
16
+ *
17
+ * NOTE: public because a caller cannot batch without it, and kept next to the
18
+ * check that enforces it so the two cannot disagree.
19
+ */
20
+ export declare const SFA_BULK_MAX_ROWS = 25;
2
21
  /**
3
22
  * @description API client for SFA/CRM data operations via keystone.
4
23
  * Authenticates with a JWT Bearer token.
@@ -30,10 +49,57 @@ export declare class SfaClient implements SfaClientInterface {
30
49
  /** @description Throws if recordId is empty to prevent malformed API requests. */
31
50
  private assertRecordId;
32
51
  /**
33
- * @description Creates a new record. Optionally accepts an idempotency key
34
- * to prevent duplicate creates on retries.
52
+ * @description Creates a new record.
53
+ *
54
+ * NOTE: a retried create writes a second record — nothing de-duplicates it.
35
55
  */
36
56
  create(collection: string, fields: Record<string, unknown>, options?: SfaCreateOptions): Promise<SfaRecord>;
57
+ /**
58
+ * @description Creates many records of one object in a single call.
59
+ *
60
+ * The call is best-effort per row: the API answers 200 and reports every
61
+ * row's outcome, so a failed row does not undo the ones that worked. Per-row
62
+ * failures come back as results, not as thrown errors — throwing would
63
+ * discard the ids of the rows that succeeded, which are not recoverable
64
+ * anywhere else. SfaValidationError and the rest still surface when the
65
+ * whole call is rejected.
66
+ *
67
+ * Retry only the failed rows. What makes that safe is that those rows did
68
+ * not apply: nothing on the server de-duplicates a repeated write.
69
+ */
70
+ bulkCreate(collection: string, rows: readonly Record<string, unknown>[]): Promise<readonly SfaBulkRowResult[]>;
71
+ /**
72
+ * @description Updates many records of one object in a single call.
73
+ *
74
+ * Each row carries its own `version`, so a stale row comes back as
75
+ * `conflict` with the server's current record to reconcile from — the rest
76
+ * of the batch still applies, and nothing is rolled back. Per-row outcomes
77
+ * are results, not thrown errors: a throw would hide which rows landed.
78
+ */
79
+ bulkUpdate(collection: string, rows: readonly SfaBulkUpdateRow[]): Promise<readonly SfaBulkRowResult[]>;
80
+ /**
81
+ * @description Deletes many records of one object in a single call.
82
+ *
83
+ * Each row carries the `version` last read for it; a stale row comes back as
84
+ * `conflict` and is left in place while the rest of the batch applies.
85
+ */
86
+ bulkDelete(collection: string, rows: readonly SfaBulkDeleteRow[]): Promise<readonly SfaBulkRowResult[]>;
87
+ /**
88
+ * @description Prepares one row's fields for a bulk write.
89
+ *
90
+ * NOTE: shared by bulk-create and bulk-update so the two steps stay in this
91
+ * order for both. Schema-aware normalisation reads the object's field types
92
+ * — a select written by label, a date written loosely — and the write
93
+ * normalisation that follows only shapes what it produced.
94
+ */
95
+ private normalizeRowFields;
96
+ /**
97
+ * @description Sends one bulk request and reads its per-row outcomes.
98
+ *
99
+ * NOTE: shared by the three bulk methods so the envelope and the result
100
+ * parsing cannot drift apart between them.
101
+ */
102
+ private postBulk;
37
103
  /**
38
104
  * @description Updates a record with optimistic locking. The version token
39
105
  * (from a prior GET) is sent as the If-Match header; a 412 response means