You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Repository navigation
Commit ee4f1ef
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
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.
180
180
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
+
importtype { DataSource } from'@object-ui/types';
187
+
188
+
declareconst dataSource:DataSource;
189
+
190
+
const res =awaitdataSource.bulkUpdate?.('order', ['o1', 'o2', 'o3'], { status: 'closed' });
191
+
if (typeofres==='number') {
192
+
console.log(`${res} updated`);
193
+
} elseif (res) {
194
+
console.log(`${res.succeeded} updated`);
195
+
for (const { id, error } ofres.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
+
importtype { DataSource } from'@object-ui/types';
205
+
206
+
declareconst dataSource:DataSource;
207
+
208
+
const ids = ['o1', 'o2', 'o3'];
209
+
const write =async () => {
210
+
for (const id ofids) awaitdataSource.update('order', id, { status: 'closed' });
`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 `[]`.
- `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
1126
1129
- `batchTransaction(operations)`- Cross-object atomic batch (master-detail); atomic when the backend advertises `transactionalBatch`, else non-atomic client-side fallback
1127
1130
- `getObjectSchema(objectName)`- Get schema metadata (cached)
0 commit comments