@geniee-inc/sfa-kit 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +244 -1
- package/package.json +1 -1
- package/src/index.d.ts +10 -2
- package/src/index.js +6 -1
- package/src/lib/auth/StandalonePreviewContext.d.ts +1 -0
- package/src/lib/auth/embed-protocol.d.ts +1 -0
- package/src/lib/auth/embed-protocol.js +2 -1
- package/src/lib/components/ErrorSnackbar.d.ts +53 -0
- package/src/lib/components/ErrorSnackbar.js +112 -0
- package/src/lib/hooks/splitBulkRows.d.ts +30 -0
- package/src/lib/hooks/splitBulkRows.js +42 -0
- package/src/lib/hooks/useBulkCreateRecords.d.ts +56 -0
- package/src/lib/hooks/useBulkCreateRecords.js +41 -0
- package/src/lib/hooks/useBulkDeleteRecords.d.ts +50 -0
- package/src/lib/hooks/useBulkDeleteRecords.js +36 -0
- package/src/lib/hooks/useBulkUpdateRecords.d.ts +55 -0
- package/src/lib/hooks/useBulkUpdateRecords.js +37 -0
- package/src/lib/hooks/useBulkWrite.d.ts +55 -0
- package/src/lib/hooks/useBulkWrite.js +160 -0
- package/src/lib/hooks/useRecordList.js +12 -6
- package/src/lib/provider/SfaClient.d.ts +111 -3
- package/src/lib/provider/SfaClient.js +440 -29
- package/src/lib/provider/SfaProvider.js +24 -17
- package/src/lib/provider/types.d.ts +86 -2
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
|
@@ -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
|
|
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 = (
|
|
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 = (
|
|
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.
|
|
34
|
-
*
|
|
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.
|