@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.
@@ -0,0 +1,41 @@
1
+ import { useMemo } from 'react';
2
+ import { splitBulkRows } from './splitBulkRows';
3
+ import { useBulkWrite } from './useBulkWrite';
4
+ /** Module level so its identity never changes: `run` stays stable. */
5
+ const sendBulkCreate = (client, collection, rows) => client.bulkCreate(collection, rows);
6
+ /**
7
+ * @description Imperative hook for creating many records of one object in a
8
+ * single call. Like the single-record hooks it does not fetch on mount.
9
+ *
10
+ * A bulk call is best-effort per row: the API answers 200 and reports each
11
+ * row's outcome, with no all-or-nothing mode. So a partly failed batch still
12
+ * created records, and their ids are only available here — which is why
13
+ * per-row failures are returned as data rather than thrown. `error` is for
14
+ * the call as a whole.
15
+ *
16
+ * Retry only the rows in `failed`: those did not apply, which is what makes
17
+ * resending them safe. Re-read the rows in `unresolved` instead — nothing
18
+ * protects against writing them twice.
19
+ */
20
+ export function useBulkCreateRecords(collection) {
21
+ const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkCreate);
22
+ /**
23
+ * Split here rather than in the caller so a grid does not have to know which
24
+ * statuses count as failure. A create cannot come back as `conflict`, so
25
+ * only the two lists are exposed.
26
+ *
27
+ * NOTE: memoised because a grid passes these into effect and callback
28
+ * dependency lists; fresh arrays every render would re-run that work.
29
+ */
30
+ const split = useMemo(() => splitBulkRows(results, 'created', row => row.record), [results]);
31
+ return {
32
+ bulkCreate: run,
33
+ results,
34
+ created: split.landed,
35
+ failed: split.failed,
36
+ unresolved: split.unresolved,
37
+ isLoading,
38
+ error,
39
+ reset,
40
+ };
41
+ }
@@ -0,0 +1,50 @@
1
+ import type { SfaBulkDeleteRow, SfaBulkRowResult } from '../provider/types';
2
+ export interface UseBulkDeleteRecordsResult {
3
+ /**
4
+ * Call to delete many records at once. Each row carries the `version` last
5
+ * read for it. Resolves with one result per submitted row.
6
+ */
7
+ readonly bulkDelete: (rows: readonly SfaBulkDeleteRow[]) => Promise<readonly SfaBulkRowResult[]>;
8
+ /**
9
+ * Every row's outcome from the last call, aligned to input by `index`. Empty
10
+ * again once `collection` or the provider's client changes, and it stays
11
+ * that way if they change back: these outcomes describe the object they were
12
+ * written for, on the visit they were written on.
13
+ */
14
+ readonly results: readonly SfaBulkRowResult[];
15
+ /** The ids that were removed, in submission order. */
16
+ readonly deletedIds: readonly string[];
17
+ /**
18
+ * The rows a concurrent write got to first. Each carries the server's
19
+ * current `record`, so the reader can see what changed before deciding
20
+ * whether it still wants the record gone.
21
+ */
22
+ readonly conflicts: readonly SfaBulkRowResult[];
23
+ /** The rows still in place that can be sent again, conflicts included. */
24
+ readonly failed: readonly SfaBulkRowResult[];
25
+ /**
26
+ * The rows that may already be removed but cannot be reported either way.
27
+ * Re-read these rather than sending them again.
28
+ */
29
+ readonly unresolved: readonly SfaBulkRowResult[];
30
+ readonly isLoading: boolean;
31
+ /**
32
+ * Set when the whole call was rejected — row count out of range, a response
33
+ * that does not answer for every row, auth, a 4xx/5xx. Rows that failed
34
+ * individually are in `failed`, and read-only mode throws synchronously
35
+ * instead: it is a provider setting, not an outcome of this call.
36
+ */
37
+ readonly error: Error | null;
38
+ /** Reset results and error state to initial values. */
39
+ readonly reset: () => void;
40
+ }
41
+ /**
42
+ * @description Imperative hook for deleting many records of one object in a
43
+ * single call.
44
+ *
45
+ * Best-effort per row: a stale row comes back as `conflict` and is left in
46
+ * place while the rest of the batch applies. There is no rollback, so a
47
+ * partly failed batch has already removed records — which is why per-row
48
+ * outcomes are returned as data rather than thrown.
49
+ */
50
+ export declare function useBulkDeleteRecords(collection: string): UseBulkDeleteRecordsResult;
@@ -0,0 +1,36 @@
1
+ import { useMemo } from 'react';
2
+ import { splitBulkRows } from './splitBulkRows';
3
+ import { useBulkWrite } from './useBulkWrite';
4
+ /** Module level so its identity never changes: `run` stays stable. */
5
+ const sendBulkDelete = (client, collection, rows) => client.bulkDelete(collection, rows);
6
+ /**
7
+ * @description Imperative hook for deleting many records of one object in a
8
+ * single call.
9
+ *
10
+ * Best-effort per row: a stale row comes back as `conflict` and is left in
11
+ * place while the rest of the batch applies. There is no rollback, so a
12
+ * partly failed batch has already removed records — which is why per-row
13
+ * outcomes are returned as data rather than thrown.
14
+ */
15
+ export function useBulkDeleteRecords(collection) {
16
+ const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkDelete);
17
+ /**
18
+ * NOTE: a delete answers with no record, so the id comes from the row
19
+ * itself — the contract guarantees `id` is present for delete.
20
+ *
21
+ * NOTE: memoised because a grid passes these into effect and callback
22
+ * dependency lists; fresh arrays every render would re-run that work.
23
+ */
24
+ const split = useMemo(() => splitBulkRows(results, 'deleted', row => row.id), [results]);
25
+ return {
26
+ bulkDelete: run,
27
+ results,
28
+ deletedIds: split.landed,
29
+ conflicts: split.conflicts,
30
+ failed: split.failed,
31
+ unresolved: split.unresolved,
32
+ isLoading,
33
+ error,
34
+ reset,
35
+ };
36
+ }
@@ -0,0 +1,55 @@
1
+ import type { SfaBulkRowResult, SfaBulkUpdateRow, SfaRecord } from '../provider/types';
2
+ export interface UseBulkUpdateRecordsResult {
3
+ /**
4
+ * Call to update many records at once. Each row carries its own `version`
5
+ * for optimistic concurrency. Resolves with one result per submitted row.
6
+ */
7
+ readonly bulkUpdate: (rows: readonly SfaBulkUpdateRow[]) => Promise<readonly SfaBulkRowResult[]>;
8
+ /**
9
+ * Every row's outcome from the last call, aligned to input by `index`. Empty
10
+ * again once `collection` or the provider's client changes, and it stays
11
+ * that way if they change back: these outcomes describe the object they were
12
+ * written for, on the visit they were written on.
13
+ */
14
+ readonly results: readonly SfaBulkRowResult[];
15
+ /** The records as they now stand on the server, in submission order. */
16
+ readonly updated: readonly SfaRecord[];
17
+ /**
18
+ * The rows a concurrent write got to first. Each carries the server's
19
+ * current `record`, which is what the reader has to reconcile against
20
+ * before retrying with the version it reports. If that record arrives
21
+ * without a `version`, re-read it first — there is nothing to lock against.
22
+ */
23
+ readonly conflicts: readonly SfaBulkRowResult[];
24
+ /** The rows that did not land and can be sent again, conflicts included. */
25
+ readonly failed: readonly SfaBulkRowResult[];
26
+ /**
27
+ * The rows that may already be applied but cannot be reported either way.
28
+ * Re-read these rather than sending them again.
29
+ */
30
+ readonly unresolved: readonly SfaBulkRowResult[];
31
+ readonly isLoading: boolean;
32
+ /**
33
+ * Set when the whole call was rejected — row count out of range, a response
34
+ * that does not answer for every row, auth, a 4xx/5xx. Rows that failed
35
+ * individually are in `failed`, and read-only mode throws synchronously
36
+ * instead: it is a provider setting, not an outcome of this call.
37
+ */
38
+ readonly error: Error | null;
39
+ /** Reset results and error state to initial values. */
40
+ readonly reset: () => void;
41
+ }
42
+ /**
43
+ * @description Imperative hook for updating many records of one object in a
44
+ * single call — the save action of a grid edit.
45
+ *
46
+ * Best-effort per row: the API answers 200 and reports each row's outcome,
47
+ * with no all-or-nothing mode. A stale row is reported as `conflict` and left
48
+ * unchanged while the rest of the batch applies, so per-row outcomes come back
49
+ * as data rather than thrown errors.
50
+ *
51
+ * Retry only the rows in `failed`. A conflict is retried with the version from
52
+ * its `record`, never by re-sending the stale one. Rows in `unresolved` are
53
+ * re-read, not retried.
54
+ */
55
+ export declare function useBulkUpdateRecords(collection: string): UseBulkUpdateRecordsResult;
@@ -0,0 +1,37 @@
1
+ import { useMemo } from 'react';
2
+ import { splitBulkRows } from './splitBulkRows';
3
+ import { useBulkWrite } from './useBulkWrite';
4
+ /** Module level so its identity never changes: `run` stays stable. */
5
+ const sendBulkUpdate = (client, collection, rows) => client.bulkUpdate(collection, rows);
6
+ /**
7
+ * @description Imperative hook for updating many records of one object in a
8
+ * single call — the save action of a grid edit.
9
+ *
10
+ * Best-effort per row: the API answers 200 and reports each row's outcome,
11
+ * with no all-or-nothing mode. A stale row is reported as `conflict` and left
12
+ * unchanged while the rest of the batch applies, so per-row outcomes come back
13
+ * as data rather than thrown errors.
14
+ *
15
+ * Retry only the rows in `failed`. A conflict is retried with the version from
16
+ * its `record`, never by re-sending the stale one. Rows in `unresolved` are
17
+ * re-read, not retried.
18
+ */
19
+ export function useBulkUpdateRecords(collection) {
20
+ const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkUpdate);
21
+ /**
22
+ * NOTE: memoised because a grid passes these into effect and callback
23
+ * dependency lists; fresh arrays every render would re-run that work.
24
+ */
25
+ const split = useMemo(() => splitBulkRows(results, 'updated', row => row.record), [results]);
26
+ return {
27
+ bulkUpdate: run,
28
+ results,
29
+ updated: split.landed,
30
+ conflicts: split.conflicts,
31
+ failed: split.failed,
32
+ unresolved: split.unresolved,
33
+ isLoading,
34
+ error,
35
+ reset,
36
+ };
37
+ }
@@ -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,24 @@
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 size its batches or its own warning
18
+ * without it, and kept next to the check that enforces it so the two cannot
19
+ * disagree.
20
+ */
21
+ export declare const SFA_BULK_MAX_ROWS = 25;
2
22
  /**
3
23
  * @description API client for SFA/CRM data operations via keystone.
4
24
  * Authenticates with a JWT Bearer token.
@@ -30,10 +50,57 @@ export declare class SfaClient implements SfaClientInterface {
30
50
  /** @description Throws if recordId is empty to prevent malformed API requests. */
31
51
  private assertRecordId;
32
52
  /**
33
- * @description Creates a new record. Optionally accepts an idempotency key
34
- * to prevent duplicate creates on retries.
53
+ * @description Creates a new record.
54
+ *
55
+ * NOTE: a retried create writes a second record — nothing de-duplicates it.
35
56
  */
36
57
  create(collection: string, fields: Record<string, unknown>, options?: SfaCreateOptions): Promise<SfaRecord>;
58
+ /**
59
+ * @description Creates many records of one object in a single call.
60
+ *
61
+ * The call is best-effort per row: the API answers 200 and reports every
62
+ * row's outcome, so a failed row does not undo the ones that worked. Per-row
63
+ * failures come back as results, not as thrown errors — throwing would
64
+ * discard the ids of the rows that succeeded, which are not recoverable
65
+ * anywhere else. SfaValidationError and the rest still surface when the
66
+ * whole call is rejected.
67
+ *
68
+ * Retry only the failed rows. What makes that safe is that those rows did
69
+ * not apply: nothing on the server de-duplicates a repeated write.
70
+ */
71
+ bulkCreate(collection: string, rows: readonly Record<string, unknown>[]): Promise<readonly SfaBulkRowResult[]>;
72
+ /**
73
+ * @description Updates many records of one object in a single call.
74
+ *
75
+ * Each row carries its own `version`, so a stale row comes back as
76
+ * `conflict` with the server's current record to reconcile from — the rest
77
+ * of the batch still applies, and nothing is rolled back. Per-row outcomes
78
+ * are results, not thrown errors: a throw would hide which rows landed.
79
+ */
80
+ bulkUpdate(collection: string, rows: readonly SfaBulkUpdateRow[]): Promise<readonly SfaBulkRowResult[]>;
81
+ /**
82
+ * @description Deletes many records of one object in a single call.
83
+ *
84
+ * Each row carries the `version` last read for it; a stale row comes back as
85
+ * `conflict` and is left in place while the rest of the batch applies.
86
+ */
87
+ bulkDelete(collection: string, rows: readonly SfaBulkDeleteRow[]): Promise<readonly SfaBulkRowResult[]>;
88
+ /**
89
+ * @description Prepares one row's fields for a bulk write.
90
+ *
91
+ * NOTE: shared by bulk-create and bulk-update so the two steps stay in this
92
+ * order for both. Schema-aware normalisation reads the object's field types
93
+ * — a select written by label, a date written loosely — and the write
94
+ * normalisation that follows only shapes what it produced.
95
+ */
96
+ private normalizeRowFields;
97
+ /**
98
+ * @description Sends one bulk request and reads its per-row outcomes.
99
+ *
100
+ * NOTE: shared by the three bulk methods so the envelope and the result
101
+ * parsing cannot drift apart between them.
102
+ */
103
+ private postBulk;
37
104
  /**
38
105
  * @description Updates a record with optimistic locking. The version token
39
106
  * (from a prior GET) is sent as the If-Match header; a 412 response means
@@ -100,6 +167,47 @@ export declare class SfaConflictError extends SfaApiError {
100
167
  constructor(status: number, statusText: string, body: string, url: string, currentRecord: SfaRecord | null);
101
168
  static fromBody(status: number, statusText: string, body: string, url: string): SfaConflictError;
102
169
  }
170
+ /**
171
+ * @description A bulk write refused before a request went out: nothing was
172
+ * selected, or more rows than the kit sends in one call.
173
+ *
174
+ * NOTE: `message` is for the log, like the rest of the kit's errors, and
175
+ * `userMessage` is what a page shows the reader — the same split the kit
176
+ * already makes between an error and the text in `FetchErrorState`. The page
177
+ * decides where to show it; the kit has no UI of its own.
178
+ *
179
+ * NOTE: a distinct class so a page recognises this without matching on the
180
+ * message. It is the one write failure a reader causes and can fix.
181
+ */
182
+ export declare class SfaBulkRowCountError extends Error {
183
+ readonly submitted: number;
184
+ /** What a page shows the reader. `message` is the English text for the log. */
185
+ readonly userMessage: string;
186
+ /** The most rows one call sends, so a page can say it without importing it. */
187
+ readonly limit = 25;
188
+ /** @param submitted how many rows the caller tried to send */
189
+ constructor(submitted: number);
190
+ }
191
+ /**
192
+ * @description Thrown when the object offers no bulk writes at all: the
193
+ * endpoint answers 501.
194
+ *
195
+ * NOTE: told apart from other failures because waiting changes nothing. A
196
+ * page with no wording of its own says to try again later, which is the one
197
+ * thing that cannot work here — the operation is not implemented for this
198
+ * object, today or in an hour.
199
+ */
200
+ export declare class SfaBulkUnsupportedError extends SfaApiError {
201
+ /** What a page shows the reader. `message` is the English text for the log. */
202
+ readonly userMessage: string;
203
+ /**
204
+ * NOTE: takes the response as it arrived, like every other error here, so
205
+ * the server's `detail` and `code` are read from the whole body. Building
206
+ * it from an `SfaApiError` instead would re-read a body already cut to
207
+ * `ERROR_BODY_MAX_LENGTH`, and a cut inside the JSON leaves nothing to read.
208
+ */
209
+ constructor(status: number, statusText: string, body: string, url: string);
210
+ }
103
211
  /**
104
212
  * @description Thrown on 422 with code VALIDATION_FAILED. Exposes per-field
105
213
  * errors so the UI can highlight which fields need correction.