@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.
package/GUIDE.md CHANGED
@@ -263,6 +263,163 @@ Check `status` for **every** hook on the page, not just the first one. Each rela
263
263
 
264
264
  Returns `{ items, totalCount, hasMore, pageIndex, status, goToPage, isLoading, error, refetch, relationship }`.
265
265
 
266
+ ### Bulk writes
267
+
268
+ `useBulkCreateRecords`, `useBulkUpdateRecords` and `useBulkDeleteRecords` write
269
+ many records of one object in a single call — for grid edits and other
270
+ multi-row saves, instead of looping the single-record hooks. They share the
271
+ contract below; the three sections after it cover only what differs.
272
+
273
+ **A bulk call is best-effort per row.** The API answers `200` and reports every
274
+ row's outcome — there is no all-or-nothing mode, so a batch can be partly
275
+ successful and nothing is rolled back. That is why per-row failures come back
276
+ as data rather than thrown errors: throwing would discard the ids of the rows
277
+ that did land, and those are not recoverable anywhere else.
278
+
279
+ - `results` — one entry per submitted row, carrying `index` (its position in
280
+ the request), `status`, `record` and `error`. Each entry is placed by its
281
+ `index`, never by the order it arrived in, so `results[n]` is the outcome of
282
+ the row that was sent at position `n`. A response that cannot be paired with
283
+ the request — fewer entries than rows, an index missing, not a whole
284
+ position, repeated, or past the end — is refused rather than returned: some
285
+ submitted row would have no reported outcome, and showing the rest as the
286
+ whole picture leaves it looking neither written nor retryable. Re-read the
287
+ records to find out what landed
288
+ - `failed` — the rows that did not land **and can be sent again**: `conflict`,
289
+ `invalid` and `not_attempted`. Retry exactly these. What makes that safe is
290
+ that they did not apply; there is no server-side de-duplication to fall back
291
+ on
292
+ - `unresolved` — the rows that may already be written but cannot be reported
293
+ either way. They keep whatever `id` arrived. **Re-read these; never retry
294
+ them**: a create that already landed would be written twice
295
+ - `isLoading` — a call is in flight. Starting a second call supersedes the
296
+ first: whichever was started last owns the state, so a slower earlier batch
297
+ cannot publish outcomes for rows the caller has moved on from
298
+ - `error` — set only when the whole call was rejected: row count out of range,
299
+ a response that does not answer for every row, auth, a 4xx/5xx. Individual
300
+ row failures are never reported here. The call **also rejects**, the same as
301
+ the single-record write hooks, so catch it to keep the rejection out of the
302
+ console; what to show the reader comes from `error`
303
+ - `reset` — clears results and error
304
+
305
+ A `501` inside that error is not a hiccup: the object does not offer bulk
306
+ writes at all. Waiting or retrying will never make it work, so tell the reader
307
+ the operation is unavailable for this object instead of offering a retry. Read
308
+ it from `SfaApiError.status`.
309
+
310
+ Everything the hook reports describes the object it was written for, on the
311
+ visit it was written on. Pass a different collection, or let the provider
312
+ rebuild its client — a token refresh or an org switch does — and results,
313
+ loading and error read empty again rather than carrying the previous object's
314
+ rows over. Coming back to that object does not bring them back either: they
315
+ belong to the visit that ended. A batch that was already in flight answers into
316
+ nothing, then and later.
317
+
318
+ Read-only mode is not reported through `error`: the call throws instead,
319
+ because it is a provider setting rather than an outcome of the request.
320
+
321
+ A call takes 1..`SFA_BULK_MAX_ROWS` (25) rows. The API itself accepts up to
322
+ 100; 25 is the kit's own limit, set by how long a call takes — the server
323
+ writes one row at a time, under a budget of its own, and a larger batch would
324
+ outlast it and then the load balancer. A larger selection is refused before a
325
+ request goes out rather than being cut off mid-write. Split bigger selections
326
+ into batches; each batch is its own call.
327
+
328
+ A bulk write is also given a longer fetch timeout than other calls, so the
329
+ browser waits for the server to finish rather than dropping an answer that
330
+ names the rows already written. Should a call time out anyway, treat the whole
331
+ batch the way you treat `unresolved`: the server may have written some of it
332
+ and the answer is gone, so re-read the records rather than sending them
333
+ again.
334
+
335
+ A row counts as written only when it carries what a write produces: the record
336
+ for a create or an update, the id for a delete. Anything short of that is
337
+ `failed` or `unresolved`, and which one is decided by what the answer says,
338
+ not by elimination. Three statuses mean the row did not apply, and only those
339
+ may be sent again: `conflict`, `invalid`, and `not_attempted` — the last for a
340
+ row the call ran out of budget before starting, which is resent exactly as it
341
+ was. A `conflict` carries the server's current record: retry with the
342
+ `version` it reports, or re-read first if none arrived.
343
+
344
+ Everything else is `unresolved`: `indeterminate`, the server saying it cannot
345
+ tell whether the row applied; a claimed success missing what it should carry —
346
+ no record, an unreadable one, no `version`, an empty id; and any answer the kit
347
+ cannot place, such as a status outside the contract.
348
+
349
+ Every submitted row is therefore in exactly one of the success list
350
+ (`created` / `updated` / `deletedIds`), `failed`, or `unresolved`.
351
+
352
+ ### useBulkCreateRecords
353
+
354
+ ```tsx
355
+ import { useBulkCreateRecords, SFA_BULK_MAX_ROWS } from '@geniee-inc/sfa-kit';
356
+
357
+ const { bulkCreate, created, failed, isLoading, error } =
358
+ useBulkCreateRecords('opportunity');
359
+
360
+ try {
361
+ await bulkCreate([
362
+ { company_id: 'cmp_88', stage: '3', amount: 5300000 },
363
+ { company_id: 'cmp_90', stage: '1', amount: 120000 },
364
+ ]);
365
+ } catch {
366
+ // the whole call was rejected; `error` holds it for rendering
367
+ }
368
+ ```
369
+
370
+ Each row is a field map, like a single create.
371
+
372
+ - `created` — the records that were written, in submission order
373
+
374
+ Returns `{ bulkCreate, results, created, failed, unresolved, isLoading, error, reset }`.
375
+
376
+ ### useBulkUpdateRecords
377
+
378
+ The save action of a grid edit: each row carries its own version.
379
+
380
+ ```tsx
381
+ const { bulkUpdate, updated, conflicts, failed } =
382
+ useBulkUpdateRecords('opportunity');
383
+
384
+ await bulkUpdate([
385
+ { id: 'opp_1', version: 'v1', fields: { stage: '4' } },
386
+ { id: 'opp_2', version: 'v7', fields: { amount: 90000 } },
387
+ ]);
388
+ ```
389
+
390
+ A row whose `version` is stale comes back as `conflict` and is **left
391
+ unchanged**, while the rest of the batch applies. Its result carries the
392
+ server's current `record`, which is what the reader reconciles against — retry
393
+ with the version that record reports, never by re-sending the stale one.
394
+
395
+ - `updated` — the records as they now stand on the server, in submission order
396
+ - `conflicts` — the rows a concurrent write got to first, each with the current
397
+ `record`. Also present in `failed`
398
+
399
+ Returns the same shape, with `updated` and `conflicts` in place of `created`.
400
+
401
+ ### useBulkDeleteRecords
402
+
403
+ ```tsx
404
+ const { bulkDelete, deletedIds, conflicts, failed } =
405
+ useBulkDeleteRecords('opportunity');
406
+
407
+ await bulkDelete([
408
+ { id: 'opp_1', version: 'v1' },
409
+ { id: 'opp_2', version: 'v7' },
410
+ ]);
411
+ ```
412
+
413
+ The same optimistic concurrency: a stale row is reported as `conflict` and
414
+ stays in place. A partly failed batch has already removed records, so reconcile
415
+ from `deletedIds` rather than assume the whole selection is gone.
416
+
417
+ - `deletedIds` — what was actually removed, in submission order. A delete
418
+ answers with no record, so the id comes from the row itself
419
+
420
+ Returns the same shape, with `deletedIds` and `conflicts` in place of
421
+ `created`.
422
+
266
423
  ### useDescribe
267
424
 
268
425
  Discovers object schema — fields, types, relationships, permissions. Use this to build dynamic UIs that adapt to any object.
@@ -302,6 +459,8 @@ const record = await client.get('companies', 'abc123');
302
459
  const list = await client.list('companies', { limit: 10 });
303
460
  ```
304
461
 
462
+ `SfaClientInterface` describes the shape of the client the provider hands out — it is not an extension point. Methods are added to it as the API grows, so implementing it yourself would break on any release that covers a new endpoint. Take the client from `useSfaClient()`; to stand one in for a test, mock the hook rather than the interface.
463
+
305
464
  ## Record shape
306
465
 
307
466
  All hooks return records in a normalized shape:
@@ -728,6 +887,7 @@ Returns `null` for unsupported collections (e.g. `schedules`).
728
887
  - Do NOT hardcode `defaultOrganizationId` as a number literal in JSX. Use `Number(process.env.NEXT_PUBLIC_DEFAULT_ORG_ID)` and set the value in `.env`.
729
888
  - Do NOT embed service account credentials in generated page source. Discovery credentials (used to query the schema at build time) must never appear in deployed code.
730
889
  - Do NOT call the API directly with fetch. Use the provided hooks or `useSfaClient()`.
890
+ - Do NOT loop single writes to save many rows. Use the bulk hooks: a loop sends one request per row, and a failure part-way through leaves the caller with no report of what landed.
731
891
  - Do NOT add Route Handlers (app/api/) for SFA data. The kit calls the API from the browser.
732
892
  - Do NOT hardcode API base URLs, API keys, or credentials anywhere.
733
893
  - Do NOT add record detail links automatically. They are opt-in: generate them only when the user asks (e.g. "make the names clickable", "add links to records"), or ask the user for confirmation before adding them. In standalone preview mode, links are unavailable (`useRecordDetailUrl` returns `null`). The `schedules` collection does not have a standard record detail page; if asked, tell the user that schedule links are not yet supported.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geniee-inc/sfa-kit",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "React hooks and components for building pages that connect to GENIEE SFA/CRM data.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.d.ts CHANGED
@@ -15,6 +15,12 @@ export { useObjectCatalog } from './lib/hooks/useObjectCatalog';
15
15
  export { useFieldTypes } from './lib/hooks/useFieldTypes';
16
16
  export { useCreateRecord } from './lib/hooks/useCreateRecord';
17
17
  export type { UseCreateRecordResult } from './lib/hooks/useCreateRecord';
18
+ export { useBulkCreateRecords } from './lib/hooks/useBulkCreateRecords';
19
+ export type { UseBulkCreateRecordsResult } from './lib/hooks/useBulkCreateRecords';
20
+ export { useBulkUpdateRecords } from './lib/hooks/useBulkUpdateRecords';
21
+ export type { UseBulkUpdateRecordsResult } from './lib/hooks/useBulkUpdateRecords';
22
+ export { useBulkDeleteRecords } from './lib/hooks/useBulkDeleteRecords';
23
+ export type { UseBulkDeleteRecordsResult } from './lib/hooks/useBulkDeleteRecords';
18
24
  export { useUpdateRecord } from './lib/hooks/useUpdateRecord';
19
25
  export type { UseUpdateRecordResult, SfaConflictDetail, } from './lib/hooks/useUpdateRecord';
20
26
  export { useDeleteRecord } from './lib/hooks/useDeleteRecord';
@@ -27,7 +33,8 @@ export { RecordList } from './lib/components/RecordList';
27
33
  export { ErrorBoundary } from './lib/components/ErrorBoundary';
28
34
  export { EmptyState } from './lib/components/EmptyState';
29
35
  export { FetchErrorState } from './lib/components/FetchErrorState';
30
- export type { SfaAuth, SfaApiMode, SfaClientConfig, SfaRecord, SfaListResponse, SfaListParams, SfaFieldValue, SfaFieldKind, SfaFieldValueMapping, FieldGroupSpec, FieldGroupLayout, SfaFieldDescriptor, SfaFieldOption, SfaRelationDescriptor, SfaObjectDetail, SfaObjectSummary, SfaObjectCatalog, SfaObjectPermissions, SfaObjectOrigin, SfaRelationshipKind, SfaCardinality, SfaDescribeResult, SfaFieldTypeDescriptor, SfaFieldTypeCatalog, SfaFilterOperator, SfaCreateOptions, SfaFieldError, } from './lib/provider/types';
36
+ export type { SfaAuth, SfaApiMode, SfaClientConfig, SfaRecord, SfaListResponse, SfaListParams, SfaFieldValue, SfaFieldKind, SfaFieldValueMapping, FieldGroupSpec, FieldGroupLayout, SfaFieldDescriptor, SfaFieldOption, SfaRelationDescriptor, SfaObjectDetail, SfaObjectSummary, SfaObjectCatalog, SfaObjectPermissions, SfaObjectOrigin, SfaRelationshipKind, SfaCardinality, SfaDescribeResult, SfaFieldTypeDescriptor, SfaFieldTypeCatalog, SfaFilterOperator, SfaCreateOptions, SfaFieldError, SfaBulkRowStatus, SfaBulkRowResult, SfaBulkRowError, SfaBulkUpdateRow, SfaBulkDeleteRow, } from './lib/provider/types';
37
+ export { SFA_BULK_MAX_ROWS } from './lib/provider/SfaClient';
31
38
  export type { SfaSession, AuthResult, OrganizationChoice, } from './lib/auth/types';
32
39
  export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
33
40
  export type { RecordDetailUrlParams, RecordDetailLink, } from './lib/navigation/record-detail-url';
package/src/index.js CHANGED
@@ -11,6 +11,9 @@ export { useDescribe } from './lib/hooks/useDescribe';
11
11
  export { useObjectCatalog } from './lib/hooks/useObjectCatalog';
12
12
  export { useFieldTypes } from './lib/hooks/useFieldTypes';
13
13
  export { useCreateRecord } from './lib/hooks/useCreateRecord';
14
+ export { useBulkCreateRecords } from './lib/hooks/useBulkCreateRecords';
15
+ export { useBulkUpdateRecords } from './lib/hooks/useBulkUpdateRecords';
16
+ export { useBulkDeleteRecords } from './lib/hooks/useBulkDeleteRecords';
14
17
  export { useUpdateRecord } from './lib/hooks/useUpdateRecord';
15
18
  export { useDeleteRecord } from './lib/hooks/useDeleteRecord';
16
19
  export { useRelatedRecords, RelationNotFoundError, resolveRelation, buildRelationFilter, } from './lib/hooks/useRelatedRecords';
@@ -20,5 +23,6 @@ export { RecordList } from './lib/components/RecordList';
20
23
  export { ErrorBoundary } from './lib/components/ErrorBoundary';
21
24
  export { EmptyState } from './lib/components/EmptyState';
22
25
  export { FetchErrorState } from './lib/components/FetchErrorState';
26
+ export { SFA_BULK_MAX_ROWS } from './lib/provider/SfaClient';
23
27
  export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
24
28
  export { useRecordDetailUrl } from './lib/hooks/useRecordDetailUrl';
@@ -5,6 +5,7 @@
5
5
  export interface StandalonePreviewState {
6
6
  readonly object_key: string;
7
7
  readonly record_id?: string;
8
+ readonly list_id?: string;
8
9
  }
9
10
  export interface StandalonePreviewControls {
10
11
  readonly state: StandalonePreviewState | null;
@@ -15,6 +15,7 @@ export interface EmbedReadyMessage {
15
15
  export interface EmbedContext {
16
16
  readonly object_key: string;
17
17
  readonly record_id?: string;
18
+ readonly list_id?: string;
18
19
  }
19
20
  /** Sent by the host to deliver a single-use exchange code. */
20
21
  export interface EmbedCodeMessage {
@@ -33,7 +33,8 @@ export function parseInboundMessage(event, trustedOrigin) {
33
33
  const ctx = rawContext;
34
34
  if (typeof ctx['object_key'] === 'string' &&
35
35
  ctx['object_key'].length > 0) {
36
- const context = { object_key: ctx['object_key'] };
36
+ const context = Object.assign({ object_key: ctx['object_key'] }, (typeof ctx['list_id'] === 'string' &&
37
+ ctx['list_id'].length > 0 && { list_id: ctx['list_id'] }));
37
38
  if (typeof ctx['record_id'] === 'string' &&
38
39
  ctx['record_id'].length > 0) {
39
40
  return Object.assign(Object.assign({}, result), { context: Object.assign(Object.assign({}, context), { record_id: ctx['record_id'] }) });
@@ -0,0 +1,30 @@
1
+ import type { SfaBulkRowResult, SfaBulkRowStatus } from '../provider/types';
2
+ export interface BulkRowSplit<T> {
3
+ /** What each row that landed produced, in submission order. */
4
+ readonly landed: readonly T[];
5
+ /** May already be applied. Re-read; retrying is what writes a duplicate. */
6
+ readonly unresolved: readonly SfaBulkRowResult[];
7
+ /** The rows a concurrent write got to first. Also present in `failed`. */
8
+ readonly conflicts: readonly SfaBulkRowResult[];
9
+ /**
10
+ * The rows that did not land and can be sent again. A `conflict` is one of
11
+ * them, and also appears in `conflicts`.
12
+ */
13
+ readonly failed: readonly SfaBulkRowResult[];
14
+ }
15
+ /**
16
+ * @description Splits per-row outcomes by what the caller may do with them.
17
+ *
18
+ * NOTE: `failed` is decided by what the answer says, never by exclusion.
19
+ * `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
20
+ * those are the ones safe to send again — a row the call never started is as
21
+ * unwritten as one the server rejected. Anything else the kit cannot place —
22
+ * `indeterminate`, no record, no version, a status outside the contract or
23
+ * belonging to another operation — may already be written, and a retry would
24
+ * write it twice.
25
+ *
26
+ * NOTE: one pass, so a row that claims success without carrying it falls into
27
+ * `unresolved` rather than out of every list. Order follows `results`, which
28
+ * the client has already aligned with the request.
29
+ */
30
+ export declare function splitBulkRows<T>(results: readonly SfaBulkRowResult[], landedStatus: SfaBulkRowStatus, read: (row: SfaBulkRowResult) => T | null): BulkRowSplit<T>;
@@ -0,0 +1,42 @@
1
+ /** The outcomes that mean the row did not apply, so sending it again is safe. */
2
+ const RETRYABLE_STATUSES = new Set([
3
+ 'conflict',
4
+ 'invalid',
5
+ 'not_attempted',
6
+ ]);
7
+ /**
8
+ * @description Splits per-row outcomes by what the caller may do with them.
9
+ *
10
+ * NOTE: `failed` is decided by what the answer says, never by exclusion.
11
+ * `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
12
+ * those are the ones safe to send again — a row the call never started is as
13
+ * unwritten as one the server rejected. Anything else the kit cannot place —
14
+ * `indeterminate`, no record, no version, a status outside the contract or
15
+ * belonging to another operation — may already be written, and a retry would
16
+ * write it twice.
17
+ *
18
+ * NOTE: one pass, so a row that claims success without carrying it falls into
19
+ * `unresolved` rather than out of every list. Order follows `results`, which
20
+ * the client has already aligned with the request.
21
+ */
22
+ export function splitBulkRows(results, landedStatus, read) {
23
+ const landed = [];
24
+ const unresolved = [];
25
+ const conflicts = [];
26
+ const failed = [];
27
+ for (const row of results) {
28
+ const value = row.status === landedStatus ? read(row) : null;
29
+ if (value != null) {
30
+ landed.push(value);
31
+ continue;
32
+ }
33
+ if (RETRYABLE_STATUSES.has(row.status)) {
34
+ failed.push(row);
35
+ if (row.status === 'conflict')
36
+ conflicts.push(row);
37
+ continue;
38
+ }
39
+ unresolved.push(row);
40
+ }
41
+ return { landed, unresolved, conflicts, failed };
42
+ }
@@ -0,0 +1,56 @@
1
+ import type { SfaBulkRowResult, SfaRecord } from '../provider/types';
2
+ export interface UseBulkCreateRecordsResult {
3
+ /**
4
+ * Call to create many records at once. Each entry is a field map, like a
5
+ * single create. Resolves with one result per submitted row.
6
+ */
7
+ readonly bulkCreate: (rows: readonly Record<string, unknown>[]) => Promise<readonly SfaBulkRowResult[]>;
8
+ /**
9
+ * Every row's outcome from the last call, aligned to the submitted rows by
10
+ * `index`. Empty before the first call, and empty again once `collection`
11
+ * or the provider's client changes — and it stays that way if they change
12
+ * back: these outcomes describe the object they were written for, on the
13
+ * visit they were written on.
14
+ */
15
+ readonly results: readonly SfaBulkRowResult[];
16
+ /** The records that were created, in submission order. */
17
+ readonly created: readonly SfaRecord[];
18
+ /**
19
+ * The rows that did not land and can be sent again, with their `index` and
20
+ * `error`. A batch can be partly successful, so this being non-empty does
21
+ * not mean nothing was written — retry exactly these rows.
22
+ */
23
+ readonly failed: readonly SfaBulkRowResult[];
24
+ /**
25
+ * The rows that may already be created but cannot be reported either way.
26
+ * They keep whatever `id` arrived. Re-read these; retrying one that landed
27
+ * writes a second record.
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, or a 4xx/5xx from the API. Rows
34
+ * that failed individually are reported in `failed`, not here, and read-only
35
+ * mode throws synchronously instead: it is a provider setting, not an
36
+ * 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 creating many records of one object in a
44
+ * single call. Like the single-record hooks it does not fetch on mount.
45
+ *
46
+ * A bulk call is best-effort per row: the API answers 200 and reports each
47
+ * row's outcome, with no all-or-nothing mode. So a partly failed batch still
48
+ * created records, and their ids are only available here — which is why
49
+ * per-row failures are returned as data rather than thrown. `error` is for
50
+ * the call as a whole.
51
+ *
52
+ * Retry only the rows in `failed`: those did not apply, which is what makes
53
+ * resending them safe. Re-read the rows in `unresolved` instead — nothing
54
+ * protects against writing them twice.
55
+ */
56
+ export declare function useBulkCreateRecords(collection: string): UseBulkCreateRecordsResult;
@@ -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
+ }