Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit ee4f1ef

Browse files
fix(bulk): keep per-row bulk results and refresh bound views once per run (#13474)
Fixes #13442 Clause-②: yes Metadata-protocol: none A 100-row list bulk update took about 100 s in the console for about 19 s of server work. Two defects compounded, and this PR carries both halves (the seat ruled one PR): 1. **Per-row results.** `ObjectStackAdapter.bulkUpdate` / `bulkDelete` read only the server's `succeeded`, so a partial failure reached the result dialog and the error CSV as one `batch_0: N records failed` entry. They now resolve to a `BulkWriteResult` (`{ succeeded, errors: [{ id, error }] }`) read off the `results[].errors[]` that `updateMany` / `deleteMany` already answer with `continueOnError: true`. `executeBulkBatch` reports each named failure under its own id and reason, so the dialog, the error CSV and Retry work per record whatever the `batchSize`, and Undo now skips the failed rows. Only failures the call does not name still fold into one `batch_N` entry. No objectstack change was needed. 2. **One refresh per run.** New optional `DataSource.batchMutationEvents(work)`: the adapter holds the `onMutation` events of the writes `work` makes and delivers them when it settles, one per (type, resource): the original event if only one was held, else one resource-level event. Scopes nest, and a rejection still delivers. `useBulkExecutor` runs every bulk run and every undo inside it. ## Clause-② surface (what the built declarations add or change) - `@object-ui/types`: `DataSource.bulkUpdate` / `bulkDelete` return `Promise` of `number | BulkWriteResult` (was `number`). New exported types `BulkWriteResult` and `BulkWriteRowError`. New optional member `DataSource.batchMutationEvents`. - `@object-ui/data-objectstack`: `ObjectStackAdapter.bulkUpdate` / `bulkDelete` resolve to `BulkWriteResult` (was a number). This is breaking for code that calls these two methods directly and reads a count: it must read `.succeeded`. In-tree there is no such caller (`git grep` finds only the executor). New method `batchMutationEvents`. - `@object-ui/core`: `BulkBatchOps.bulkCall` may resolve to `number | BulkWriteResult`. - `@object-ui/plugin-grid`: the executor's `dataSource` face gains the optional `batchMutationEvents`, derived from `DataSource` like the two bulk members. Backward compatibility is a union, not a replaced type. An adapter returning a plain number still satisfies `DataSource`. Reverse check against the REBUILT `packages/types/dist`: `const n: number = await ds.bulkUpdate(...)` is refused with TS2322 (`Type 'number | BulkWriteResult' is not assignable to type 'number'`), and in the same program a `bulkUpdate: async () => 3` implementor compiles. The changeset is `minor` on the four packages and states the breaking adapter return. ## Mechanism checks (dispatch hypotheses) - H1 holds: `bulkUpdate` returned `emitBulk((res as any).succeeded as number)` and its per-id fallback swallowed each error. `bulkDelete` had the same shape. - H2 holds, and it is wider than the two views. Six paths subscribe to `onMutation`: ListView, ObjectView (plus its `notifyDataChanged`), ObjectKanban, ObjectCalendar, `useDataRefresh`, and the console's `useMutationInvalidationBridge`, which puts every event on the data-invalidation bus for every bus reader (ObjectGrid's own query, related lists, and others). - H3 holds. Inside one batch, `executeBulkBatch`'s per-row pass is concurrent (`Promise.allSettled`). Batches run one after another. - H4, decided by measurement: hold at the source, not coalesce in the views. Holding at the source reaches all six subscriber paths. Coalescing in ListView / ObjectView reaches two of them, and a timer-based coalescer cannot bound a run whose rows are separated by network round-trips. Measured end to end (real adapter, real client, real `useBulkExecutor`, real `ObjectGrid` behind the real bridge, 25 rows, `batchSize: 1`): **1 list refetch per run with the scope, 22 with it removed.** `ListView.tsx` and `ObjectView.tsx` are therefore not changed. ## Tests - `packages/data-objectstack/src/bulkPerRowResults-13442.test.ts`: real adapter and `@objectstack/client`, only `fetch` stubbed. A 2-failure `updateMany` answer gives 2 per-record errors with ids and messages. A failed row is attributed by its request `index` when it has no `id` (the spec's own example shape). Several reasons on one row are joined. For `deleteMany`, a refused row keeps its reason, and a kept row with no `errors[]` gets a generic one. Six holding cases: N updates give 0 events while the scope is open and then 1; the unscoped control gives N; a single held event keeps its id and record; coalescing is per (type, resource); the result passes through and a rejection still delivers; nested scopes deliver once. - `packages/plugin-grid/src/__tests__/bulkRunPerRowResults-13442.test.tsx`: a 100-row run with 2 server rejections sends one `updateMany` (no PATCH) and ends with `errors` equal to the two records' ids and messages, with no `batch_` entry. The refetch count is asserted exactly as `1`. ⚠️ That run is outside `act()`, on React's real scheduler, with each PATCH answering after a timer. Inside `act`, or with a microtask-only stub, the pin stayed green with the scope removed (measured), because React rendered once at the end. - `useBulkExecutor.test.ts` (4 new cases) and `bulkFastPath.test.ts` (5 new cases). The existing `bulkUpdate.test.ts` / `bulkDelete.test.ts` move to the new return shape. One-off ablations (WRAP mode of `ablation-replace.mjs`, each restore proven: blob equal to HEAD and `git diff HEAD` empty; no build is needed because vitest aliases these specifiers to `src`): - A. Executor scope off (`false &&` on the `batchMutationEvents` check): refetch pin red, `expected 22 to be 1`; scope unit pin red. - B. Adapter attribution off (`if (row || !row) continue;`): 5 red (4 adapter cases plus the end-to-end 2-failure pin). - C. Core attribution off (iterate `[]` instead of `res.errors`): 7 red (core, executor and end-to-end). - D. Adapter holding off (`mutationHoldDepth < 0`): 4 holding cases red, plus the refetch pin, `expected 22 to be 1`. ## Local verification at head `90d28e7` - `pnpm exec vitest run packages/types/ packages/core/ packages/data-objectstack/ packages/plugin-grid/` under the lock: VERDICT command-exit 0, `Test Files 946 passed (946)`, `Tests 18065 passed | 27 skipped (18092)`. - `type-check` of types, core and data-objectstack: Done (at `677b254`; none of their sources changed after, only the adapter README). plugin-grid: `type-check` green at `7609f9d`, then `tsc -p tsconfig.test.json` green at head for the reworked test. That last run was a declared narrowing, unlocked, because a sibling's verify run held the shared lock for about 20 minutes. - `lint` of the four packages: exit 0 (0 errors; the repository sets no warning ceiling). - Exit 0: `check-changeset-presence`, `check-changeset-no-major`, `check:new-line-citations` (0 new), `check:control-bytes`, `check:test-path-roots`, `check:pending-changeset-literals`, `check:changeset-claims`, `check:doc-fences`, `check:doc-json`. `check-governed-queue-guard --test` over the 14 paths: NOT GOVERNED. - NOT MEASURED locally: `check:readme-exports` and `check:doc-snippets`. Both need the whole package set built (`check:readme-exports` stopped on 23 unbuilt packages, a precondition, not a verdict). In their place, the two new guide snippets were compiled `--strict` against the built `@object-ui/types` dist: exit 0. CI builds and runs both. - `origin/main` was still at the branch point `a3c0da9` when this opened, so the instructed merge was a no-op. objectui#13450 (same batch) touches `WidgetRegistry.ts` / `widget.ts`, which are disjoint files. ## Acceptance notes - Landing beyond the claimed file surface: `content/docs/guide/data-source.md` and `packages/data-objectstack/README.md`. These are docs only, and AGENTS.md commandment #2 requires them for a public API change. No other claim holds them. - `ValueDataSource` (`packages/core/src/adapters`) implements `onMutation` but not the new optional `batchMutationEvents`. A loop of writes against it still notifies once per write. This is outside this card's file surface. Successor: none. - The `bulkDelete` fallback loop, used only when the client has no `deleteMany`, counts a `{ success: false }` delete answer as deleted. The pinned client has `deleteMany`, so this does not run today. Successor: none. - In the console a bulk run now costs at most two list refetches: the held event's release, and the dialog's Done handler that ObjectGrid already had. Session: `https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ` --- _Generated by [Claude Code](https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1627fd commit ee4f1ef

14 files changed

Lines changed: 1214 additions & 204 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/core': minor
4+
'@object-ui/data-objectstack': minor
5+
'@object-ui/plugin-grid': minor
6+
---
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
A list bulk action keeps each failed record's id and the server's reason, and a bulk run refreshes the bound list once instead of once per row (objectui#13442).
12+
13+
A 100-row bulk update took about 100 s in the console for about 19 s of server work. `bulkUpdate` read only the server's `succeeded` count, so a partial failure reached the result dialog and the error CSV as one `batch_0: N records failed` entry with no record id or reason. Apps that needed the reason ran `batchSize: 1`, and on that per-row path every write refetched and re-rendered the whole list.
14+
15+
- `DataSource.bulkUpdate` / `bulkDelete` (`@object-ui/types`) may now resolve to a `BulkWriteResult`, `{ succeeded, errors: [{ id, error }] }`, as well as to a plain count. Both new types are exported. An adapter that returns a number keeps working unchanged. A caller that reads the result must now handle both shapes; the bulk executor does.
16+
- `ObjectStackAdapter.bulkUpdate` / `bulkDelete` (`@object-ui/data-objectstack`) resolve to a `BulkWriteResult` built from the `results[].errors[]` that `updateMany` / `deleteMany` already answer, and the per-id fallback loop keeps each row's reason instead of discarding it. Breaking for direct callers: these two methods used to resolve to a number. Code that calls them directly and reads the result as a count must read `.succeeded`.
17+
- `executeBulkBatch` (`@object-ui/core`) reports each failure the bulk call names under its own id and reason. Only failures it does not name still fold into one `batch_${offset}` entry. So the result dialog, the error CSV and Retry work per record whatever the `batchSize`, and Undo skips the failed rows.
18+
- New optional `DataSource.batchMutationEvents(work)`: runs `work` with `onMutation` events held, then delivers them when it settles, one per resource and mutation type. `ObjectStackAdapter` implements it. `useBulkExecutor` (`@object-ui/plugin-grid`) runs each bulk run and each undo inside it, so ListView, ObjectView, kanban, calendar and the console's invalidation bridge refetch once per run, not once per row. The executor's `dataSource` face gains the matching optional member.

‎content/docs/guide/data-source.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,40 @@ await dataSource.find('users', {
178178

179179
Data-aware plugins may also use optional methods such as `batchTransaction`, `bulkUpdate`, `bulkDelete`, `getView`, or `listViewOverrides` when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them.
180180

181+
### Bulk writes: per-record results and one refresh per run
182+
183+
`bulkUpdate` and `bulkDelete` resolve to either a plain count or a `BulkWriteResult`: the count plus one `{ id, error }` entry per record the server refused, with the server's reason. An adapter whose server reports per-row outcomes should return the `BulkWriteResult` (the `@object-ui/data-objectstack` adapter does, from the `results[].errors[]` of `updateMany` / `deleteMany`). An adapter that has only a count keeps returning the number. A caller handles both:
184+
185+
```typescript
186+
import type { DataSource } from '@object-ui/types';
187+
188+
declare const dataSource: DataSource;
189+
190+
const res = await dataSource.bulkUpdate?.('order', ['o1', 'o2', 'o3'], { status: 'closed' });
191+
if (typeof res === 'number') {
192+
console.log(`${res} updated`);
193+
} else if (res) {
194+
console.log(`${res.succeeded} updated`);
195+
for (const { id, error } of res.errors) console.warn(`${id}: ${error}`);
196+
}
197+
```
198+
199+
The grid's bulk actions read both shapes. A refused record shows up in the result dialog and the error CSV under its own id and reason, and **Retry** works on it, whatever the action's `batchSize`.
200+
201+
Every `create` / `update` / `delete` normally fires one `onMutation` event, and every bound view refetches once per event. A loop that writes many rows (a bulk action on its per-row path, an undo) would refetch the list once per row. Wrap such a loop in the optional `batchMutationEvents(work)`: the events its writes fire are held and delivered when `work` settles, one per touched resource and mutation type, so bound views refresh once. The grid's bulk executor does this when the adapter has the method.
202+
203+
```typescript
204+
import type { DataSource } from '@object-ui/types';
205+
206+
declare const dataSource: DataSource;
207+
208+
const ids = ['o1', 'o2', 'o3'];
209+
const write = async () => {
210+
for (const id of ids) await dataSource.update('order', id, { status: 'closed' });
211+
};
212+
await (dataSource.batchMutationEvents ? dataSource.batchMutationEvents(write) : write());
213+
```
214+
181215
`listMetadataItems(type)` lists one metadata registry type as `{ name, label }` headers, for the `position-ref` / `permission-ref` field widgets (see the [Fields guide](./fields.md)). An empty array means the registry has no item of that type, so a failed load must **reject**, never resolve `[]`.
182216

183217
### Cross-object atomic writes (`batchTransaction`)

‎packages/core/src/actions/__tests__/bulkFastPath.test.ts‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,93 @@ describe('executeBulkBatch', () => {
9494
expect(out.errors[0].id).toBe('batch_200');
9595
});
9696
});
97+
98+
/**
99+
* objectui#13442 — a bulk call that names its failed rows (`BulkWriteResult`,
100+
* what `DataSource.bulkUpdate` / `bulkDelete` may resolve to) keeps each one
101+
* under its own id and the server's reason. Before, every shortfall became one
102+
* `batch_${offset}` entry, so Retry and the error CSV had no record to act on.
103+
*/
104+
describe('executeBulkBatch — per-record results from the bulk call (objectui#13442)', () => {
105+
const five = { ids: ['r1', 'r2', 'r3', 'r4', 'r5'], originalSize: 5, offset: 0, allowBulk: true, label: 'bulk update' };
106+
107+
it('reports each failure the call named under its own id, with no aggregate entry', async () => {
108+
const bulkCall = vi.fn(async () => ({
109+
succeeded: 3,
110+
errors: [
111+
{ id: 'r2', error: 'Discharge date is required' },
112+
{ id: 'r4', error: 'Record is locked' },
113+
],
114+
}));
115+
const perRow = vi.fn();
116+
const out = await executeBulkBatch(five, { bulkCall, perRow });
117+
expect(out).toEqual({
118+
succeeded: 3,
119+
failed: 2,
120+
errors: [
121+
{ id: 'r2', error: 'Discharge date is required' },
122+
{ id: 'r4', error: 'Record is locked' },
123+
],
124+
});
125+
// The server already applied the request: no per-row re-run.
126+
expect(perRow).not.toHaveBeenCalled();
127+
});
128+
129+
it('folds only the failures the call did NOT name into the aggregate entry', async () => {
130+
const out = await executeBulkBatch(five, {
131+
bulkCall: async () => ({ succeeded: 2, errors: [{ id: 'r5', error: 'Denied by sharing rule' }] }),
132+
perRow: vi.fn(),
133+
});
134+
expect(out.succeeded).toBe(2);
135+
expect(out.failed).toBe(3);
136+
expect(out.errors).toEqual([
137+
{ id: 'r5', error: 'Denied by sharing rule' },
138+
{ id: 'batch_0', error: '2 records failed in bulk update' },
139+
]);
140+
});
141+
142+
it('ignores an entry for an id outside the batch, and a repeated id', async () => {
143+
const out = await executeBulkBatch(five, {
144+
bulkCall: async () => ({
145+
succeeded: 3,
146+
errors: [
147+
{ id: 'not_in_batch', error: 'x' },
148+
{ id: 'r1', error: 'first' },
149+
{ id: 'r1', error: 'again' },
150+
],
151+
}),
152+
perRow: vi.fn(),
153+
});
154+
expect(out.failed).toBe(2);
155+
expect(out.errors).toEqual([
156+
{ id: 'r1', error: 'first' },
157+
{ id: 'batch_0', error: '1 record failed in bulk update' },
158+
]);
159+
});
160+
161+
it('never lists more entries than the reported shortfall', async () => {
162+
const out = await executeBulkBatch(five, {
163+
bulkCall: async () => ({
164+
succeeded: 4,
165+
errors: [
166+
{ id: 'r2', error: 'a' },
167+
{ id: 'r3', error: 'b' },
168+
],
169+
}),
170+
perRow: vi.fn(),
171+
});
172+
expect(out.failed).toBe(1);
173+
expect(out.errors).toEqual([{ id: 'r2', error: 'a' }]);
174+
});
175+
176+
it('a full success reports no entries, and a plain count still works as before', async () => {
177+
const full = await executeBulkBatch(five, {
178+
bulkCall: async () => ({ succeeded: 5, errors: [] }),
179+
perRow: vi.fn(),
180+
});
181+
expect(full).toEqual({ succeeded: 5, failed: 0, errors: [] });
182+
183+
const count = await executeBulkBatch(five, { bulkCall: async () => 4, perRow: vi.fn() });
184+
expect(count.errors).toEqual([{ id: 'batch_0', error: '1 record failed in bulk update' }]);
185+
});
186+
});

‎packages/core/src/actions/bulkFastPath.ts‎

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9+
import type { BulkWriteResult } from '@object-ui/types';
10+
911
/**
1012
* Outcome of a single batch execution. Mirrors the fields callers care
1113
* about so they can fold these into their own aggregate result.
@@ -25,11 +27,13 @@ export interface BulkBatchOutcome {
2527
export interface BulkBatchOps {
2628
/**
2729
* One server call that mutates every id in the batch with the same
28-
* shape. Returns the count of rows the server reports as succeeded.
29-
* Throws when the entire request fails (network, 500, auth) — that
30-
* signals "fall back to per-row" upstream.
30+
* shape. Resolves to the count of rows the server reports as succeeded,
31+
* or to a {@link BulkWriteResult} that also names each failed id and the
32+
* server's reason (objectui#13442) — what `DataSource.bulkUpdate` /
33+
* `bulkDelete` resolve to. Throws when the entire request fails (network,
34+
* 500, auth) — that signals "fall back to per-row" upstream.
3135
*/
32-
bulkCall?: (ids: string[]) => Promise<number>;
36+
bulkCall?: (ids: string[]) => Promise<number | BulkWriteResult>;
3337
/**
3438
* Per-row mutation. Called once per id in the batch when bulk is
3539
* unavailable, when bulk throws, or when the batch is too small for
@@ -73,10 +77,14 @@ export interface BulkBatchInput {
7377
*
7478
* Decision tree:
7579
* 1. `allowBulk` AND `ops.bulkCall` provided → call it.
76-
* - returned `n === ids.length` → all good, no per-row pass.
77-
* - returned `n < ids.length` → aggregate error for the shortfall,
78-
* no per-row pass (the server already accepted the request; calling
79-
* per-row would re-mutate the rows that already succeeded).
80+
* - succeeded `=== ids.length` → all good, no per-row pass.
81+
* - succeeded `< ids.length` → the shortfall is reported, with no
82+
* per-row pass (the server already accepted the request; calling
83+
* per-row would re-mutate the rows that already succeeded). Each
84+
* failed row the call named in its `errors` gets its own entry, under
85+
* its own id and the server's reason (objectui#13442), so Retry and the
86+
* error CSV work per record. Only failures it did NOT name — a plain
87+
* count names none — fold into one `batch_${offset}` aggregate entry.
8088
* - threw → fall through to per-row to recover
8189
* actionable per-id error detail for the user.
8290
* 2. Otherwise → per-row via Promise.allSettled.
@@ -91,25 +99,51 @@ export async function executeBulkBatch(
9199
const outcome: BulkBatchOutcome = { succeeded: 0, failed: 0, errors: [] };
92100

93101
if (allowBulk && typeof ops.bulkCall === 'function') {
102+
let res: number | BulkWriteResult;
94103
try {
95-
const n = await ops.bulkCall(ids);
96-
const bulkSucceeded = Math.max(0, Math.min(n, ids.length));
97-
outcome.succeeded += bulkSucceeded;
98-
const bulkFailed = ids.length - bulkSucceeded;
99-
if (bulkFailed > 0) {
100-
outcome.failed += bulkFailed;
104+
res = await ops.bulkCall(ids);
105+
} catch {
106+
// Fall through to per-row — gives the user actionable detail.
107+
return runPerRow(ids, ops.perRow, outcome);
108+
}
109+
const reported = typeof res === 'number' ? res : res.succeeded;
110+
const bulkSucceeded = Math.max(0, Math.min(reported, ids.length));
111+
outcome.succeeded += bulkSucceeded;
112+
const bulkFailed = ids.length - bulkSucceeded;
113+
if (bulkFailed > 0) {
114+
outcome.failed += bulkFailed;
115+
// Per-record entries for the failures the call named — one per id,
116+
// only ids of THIS batch, and never more than the shortfall, so the
117+
// entries always add up to `failed`.
118+
const inBatch = new Set(ids);
119+
const attributed = new Set<string>();
120+
for (const rowError of typeof res === 'number' ? [] : res.errors) {
121+
if (attributed.size >= bulkFailed) break;
122+
if (!inBatch.has(rowError.id) || attributed.has(rowError.id)) continue;
123+
attributed.add(rowError.id);
124+
outcome.errors.push({ id: rowError.id, error: rowError.error });
125+
}
126+
const unattributed = bulkFailed - attributed.size;
127+
if (unattributed > 0) {
101128
outcome.errors.push({
102129
id: `batch_${offset}`,
103-
error: `${bulkFailed} record${bulkFailed === 1 ? '' : 's'} failed in ${label}`,
130+
error: `${unattributed} record${unattributed === 1 ? '' : 's'} failed in ${label}`,
104131
});
105132
}
106-
return outcome;
107-
} catch {
108-
// Fall through to per-row — gives the user actionable detail.
109133
}
134+
return outcome;
110135
}
111136

112-
const settled = await Promise.allSettled(ids.map((id) => ops.perRow(id)));
137+
return runPerRow(ids, ops.perRow, outcome);
138+
}
139+
140+
/** The per-row pass: every id through `perRow`, each failure under its own id. */
141+
async function runPerRow(
142+
ids: string[],
143+
perRow: BulkBatchOps['perRow'],
144+
outcome: BulkBatchOutcome,
145+
): Promise<BulkBatchOutcome> {
146+
const settled = await Promise.allSettled(ids.map((id) => perRow(id)));
113147
settled.forEach((res, idx) => {
114148
if (res.status === 'fulfilled') {
115149
outcome.succeeded += 1;

‎packages/data-objectstack/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,9 @@ new ObjectStackAdapter(config: {
11231123
- `update(resource, id, data)` - Update an existing record
11241124
- `delete(resource, id)` - Delete a record
11251125
- `bulk(resource, operation, data)` - Batch operations (create/update/delete)
1126+
- `bulkUpdate(resource, ids, patch)` / `bulkDelete(resource, ids)` - One `updateMany` / `deleteMany` request for many ids (`continueOnError: true`). Resolves to `{ succeeded, errors }`: the server's count plus one `{ id, error }` per refused record, with the server's reason
1127+
- `onMutation(listener)` - Subscribe to create/update/delete events (returns unsubscribe function)
1128+
- `batchMutationEvents(work)` - Run `work` with mutation events held, then deliver them coalesced (one per resource and type) when it settles, so bound views refresh once for a loop of writes
11261129
- `batchTransaction(operations)` - Cross-object atomic batch (master-detail); atomic when the backend advertises `transactionalBatch`, else non-atomic client-side fallback
11271130
- `getObjectSchema(objectName)` - Get schema metadata (cached)
11281131
- `getCacheStats()` - Get cache statistics

‎packages/data-objectstack/src/bulkDelete.test.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,41 +27,41 @@ function makeDS(stub: { deleteMany?: any; delete?: any }) {
2727
}
2828

2929
describe('ObjectStackDataSource.bulkDelete', () => {
30-
it('returns 0 and skips network when ids is empty', async () => {
30+
it('returns a zero result and skips network when ids is empty', async () => {
3131
const deleteMany = vi.fn();
3232
const ds = makeDS({ deleteMany });
33-
const n = await ds.bulkDelete('sys_notification', []);
34-
expect(n).toBe(0);
33+
const res = await ds.bulkDelete('sys_notification', []);
34+
expect(res).toEqual({ succeeded: 0, errors: [] });
3535
expect(deleteMany).not.toHaveBeenCalled();
3636
});
3737

3838
it('issues a single deleteMany call with the id list', async () => {
3939
const deleteMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0 });
4040
const ds = makeDS({ deleteMany });
41-
const n = await ds.bulkDelete('task', ['a', 'b', 'c']);
42-
expect(n).toBe(3);
41+
const res = await ds.bulkDelete('task', ['a', 'b', 'c']);
42+
expect(res).toEqual({ succeeded: 3, errors: [] });
4343
expect(deleteMany).toHaveBeenCalledTimes(1);
4444
const [resource, ids, opts] = deleteMany.mock.calls[0];
4545
expect(resource).toBe('task');
4646
expect(ids).toEqual(['a', 'b', 'c']);
4747
expect(opts).toEqual({ continueOnError: true });
4848
});
4949

50-
it('returns ids.length when deleteMany returns void (older clients)', async () => {
50+
it('reads every id as deleted when deleteMany returns void (older clients)', async () => {
5151
const deleteMany = vi.fn().mockResolvedValue(undefined);
5252
const ds = makeDS({ deleteMany });
53-
const n = await ds.bulkDelete('task', ['a', 'b']);
54-
expect(n).toBe(2);
53+
const res = await ds.bulkDelete('task', ['a', 'b']);
54+
expect(res).toEqual({ succeeded: 2, errors: [] });
5555
});
5656

5757
it('falls back to per-id delete loop when deleteMany is missing', async () => {
5858
const del = vi.fn(async (_: string, id: string) => {
5959
if (id === 'b') throw new Error('FK violation');
6060
});
6161
const ds = makeDS({ delete: del });
62-
const n = await ds.bulkDelete('task', ['a', 'b', 'c']);
63-
// continueOnError-emulated: b throws, a + c succeed.
64-
expect(n).toBe(2);
62+
const res = await ds.bulkDelete('task', ['a', 'b', 'c']);
63+
// continueOnError-emulated: b throws, a + c succeed, b keeps its reason.
64+
expect(res).toEqual({ succeeded: 2, errors: [{ id: 'b', error: 'FK violation' }] });
6565
expect(del).toHaveBeenCalledTimes(3);
6666
});
6767

0 commit comments

Comments
 (0)