@object-ui/data-objectstack 5.1.1 → 5.3.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/CHANGELOG.md +47 -0
- package/dist/index.cjs +76 -0
- package/dist/index.d.cts +24 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +76 -0
- package/package.json +3 -3
- package/src/bulkDelete.test.ts +75 -0
- package/src/bulkUpdate.test.ts +93 -0
- package/src/index.ts +98 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @object-ui/data-objectstack
|
|
2
2
|
|
|
3
|
+
## 5.3.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @object-ui/types@5.3.0
|
|
8
|
+
- @object-ui/core@5.3.0
|
|
9
|
+
|
|
10
|
+
## 5.2.1
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- @object-ui/types@5.2.1
|
|
15
|
+
- @object-ui/core@5.2.1
|
|
16
|
+
|
|
17
|
+
## 5.2.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- de0c5e6: Add `DataSource.bulkDelete(resource, ids)` as the symmetric counterpart
|
|
22
|
+
to `bulkUpdate`. Implemented in `data-objectstack` via the client's
|
|
23
|
+
`deleteMany` primitive with a per-id fallback that emulates
|
|
24
|
+
`continueOnError` semantics for older clients.
|
|
25
|
+
|
|
26
|
+
Extract the bulk-vs-per-row decision into a reusable
|
|
27
|
+
`executeBulkBatch(input, ops)` helper in `@object-ui/core`:
|
|
28
|
+
- Single decision tree shared by both update and delete fast paths.
|
|
29
|
+
- Bulk success → no per-row pass.
|
|
30
|
+
- Bulk partial-count → aggregate batch error.
|
|
31
|
+
- Bulk throw → per-row fallback so users still get id-level error detail.
|
|
32
|
+
|
|
33
|
+
`useBulkExecutor` in plugin-grid now uses the helper for both `update`
|
|
34
|
+
and `delete` batches, cutting "delete 500 selected rows" from 500 HTTP
|
|
35
|
+
requests down to ~3.
|
|
36
|
+
|
|
37
|
+
- 9997cae: DataSource: add optional `bulkUpdate(resource, ids, patch)` for "same patch, many rows" interactions (Slack "mark all as read", Linear "archive selected"). The ObjectStack adapter routes to `POST /api/v1/data/:object/updateMany` so the client pays one HTTP/auth/RLS round-trip instead of N parallel PATCHes, eliminating mark-all-read jank on inboxes with 50+ unread.
|
|
38
|
+
|
|
39
|
+
AppHeader's `markAllRead` now prefers `bulkUpdate`, with a transparent fallback to the per-id loop for adapters that don't implement the helper.
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [de0c5e6]
|
|
44
|
+
- Updated dependencies [9997cae]
|
|
45
|
+
- Updated dependencies [70b5570]
|
|
46
|
+
- Updated dependencies [d1442e3]
|
|
47
|
+
- @object-ui/types@5.2.0
|
|
48
|
+
- @object-ui/core@5.2.0
|
|
49
|
+
|
|
3
50
|
## 5.1.1
|
|
4
51
|
|
|
5
52
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -1368,6 +1368,82 @@ var ObjectStackAdapter = class {
|
|
|
1368
1368
|
throw normaliseClientError(err);
|
|
1369
1369
|
}
|
|
1370
1370
|
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Apply the same patch to many records in a single round-trip.
|
|
1373
|
+
*
|
|
1374
|
+
* Sends one `POST /api/v1/data/:object/updateMany` request whose body
|
|
1375
|
+
* is `{ records: ids.map(id => ({id, data: patch})), options: { continueOnError: true }}`.
|
|
1376
|
+
* The server iterates server-side (still N engine writes) but the
|
|
1377
|
+
* client only pays for ONE HTTP/auth/RLS round-trip — the relevant
|
|
1378
|
+
* perf win for inbox / list-toolbar "mark all read" / "archive
|
|
1379
|
+
* selected" interactions where N can easily be in the hundreds.
|
|
1380
|
+
*
|
|
1381
|
+
* Falls back to a sequential per-id loop when the connected client
|
|
1382
|
+
* does not expose `updateMany` (older clients / offline adapters).
|
|
1383
|
+
* In that case `continueOnError` semantics are emulated locally so
|
|
1384
|
+
* callers see the same return shape.
|
|
1385
|
+
*/
|
|
1386
|
+
async bulkUpdate(resource, ids, patch) {
|
|
1387
|
+
await this.connect();
|
|
1388
|
+
if (!ids || ids.length === 0) return 0;
|
|
1389
|
+
const records = ids.map((id) => ({ id: String(id), data: patch }));
|
|
1390
|
+
const updateMany = this.client.data.updateMany;
|
|
1391
|
+
if (typeof updateMany === "function") {
|
|
1392
|
+
try {
|
|
1393
|
+
const res = await updateMany(resource, records, { continueOnError: true });
|
|
1394
|
+
if (res && typeof res === "object" && typeof res.succeeded === "number") {
|
|
1395
|
+
return res.succeeded;
|
|
1396
|
+
}
|
|
1397
|
+
if (Array.isArray(res)) return res.length;
|
|
1398
|
+
return ids.length;
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
throw normaliseClientError(err);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
let succeeded = 0;
|
|
1404
|
+
for (const id of ids) {
|
|
1405
|
+
try {
|
|
1406
|
+
await this.client.data.update(resource, String(id), patch);
|
|
1407
|
+
succeeded++;
|
|
1408
|
+
} catch {
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return succeeded;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Single-call bulk delete. Mirrors the bulkUpdate contract: prefers
|
|
1415
|
+
* the server's `deleteMany` primitive when the client supports it;
|
|
1416
|
+
* otherwise emulates `continueOnError` by looping `delete` per id and
|
|
1417
|
+
* swallowing per-row failures. Returns the count of rows reported
|
|
1418
|
+
* deleted by the server (or successfully deleted in fallback mode).
|
|
1419
|
+
*/
|
|
1420
|
+
async bulkDelete(resource, ids) {
|
|
1421
|
+
await this.connect();
|
|
1422
|
+
if (!ids || ids.length === 0) return 0;
|
|
1423
|
+
const strIds = ids.map((id) => String(id));
|
|
1424
|
+
const deleteMany = this.client.data.deleteMany;
|
|
1425
|
+
if (typeof deleteMany === "function") {
|
|
1426
|
+
try {
|
|
1427
|
+
const res = await deleteMany(resource, strIds, { continueOnError: true });
|
|
1428
|
+
if (res && typeof res === "object" && typeof res.succeeded === "number") {
|
|
1429
|
+
return res.succeeded;
|
|
1430
|
+
}
|
|
1431
|
+
if (Array.isArray(res)) return res.length;
|
|
1432
|
+
return strIds.length;
|
|
1433
|
+
} catch (err) {
|
|
1434
|
+
throw normaliseClientError(err);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
let succeeded = 0;
|
|
1438
|
+
for (const id of strIds) {
|
|
1439
|
+
try {
|
|
1440
|
+
await this.client.data.delete(resource, id);
|
|
1441
|
+
succeeded++;
|
|
1442
|
+
} catch {
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return succeeded;
|
|
1446
|
+
}
|
|
1371
1447
|
/**
|
|
1372
1448
|
* Bulk operations with optimized batch processing and error handling.
|
|
1373
1449
|
* Emits progress events for tracking operation status.
|
package/dist/index.d.cts
CHANGED
|
@@ -938,6 +938,30 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
938
938
|
delete(resource: string, id: string | number, opts?: {
|
|
939
939
|
ifMatch?: string;
|
|
940
940
|
}): Promise<boolean>;
|
|
941
|
+
/**
|
|
942
|
+
* Apply the same patch to many records in a single round-trip.
|
|
943
|
+
*
|
|
944
|
+
* Sends one `POST /api/v1/data/:object/updateMany` request whose body
|
|
945
|
+
* is `{ records: ids.map(id => ({id, data: patch})), options: { continueOnError: true }}`.
|
|
946
|
+
* The server iterates server-side (still N engine writes) but the
|
|
947
|
+
* client only pays for ONE HTTP/auth/RLS round-trip — the relevant
|
|
948
|
+
* perf win for inbox / list-toolbar "mark all read" / "archive
|
|
949
|
+
* selected" interactions where N can easily be in the hundreds.
|
|
950
|
+
*
|
|
951
|
+
* Falls back to a sequential per-id loop when the connected client
|
|
952
|
+
* does not expose `updateMany` (older clients / offline adapters).
|
|
953
|
+
* In that case `continueOnError` semantics are emulated locally so
|
|
954
|
+
* callers see the same return shape.
|
|
955
|
+
*/
|
|
956
|
+
bulkUpdate(resource: string, ids: ReadonlyArray<string | number>, patch: Partial<T>): Promise<number>;
|
|
957
|
+
/**
|
|
958
|
+
* Single-call bulk delete. Mirrors the bulkUpdate contract: prefers
|
|
959
|
+
* the server's `deleteMany` primitive when the client supports it;
|
|
960
|
+
* otherwise emulates `continueOnError` by looping `delete` per id and
|
|
961
|
+
* swallowing per-row failures. Returns the count of rows reported
|
|
962
|
+
* deleted by the server (or successfully deleted in fallback mode).
|
|
963
|
+
*/
|
|
964
|
+
bulkDelete(resource: string, ids: ReadonlyArray<string | number>): Promise<number>;
|
|
941
965
|
/**
|
|
942
966
|
* Bulk operations with optimized batch processing and error handling.
|
|
943
967
|
* Emits progress events for tracking operation status.
|
package/dist/index.d.ts
CHANGED
|
@@ -938,6 +938,30 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
938
938
|
delete(resource: string, id: string | number, opts?: {
|
|
939
939
|
ifMatch?: string;
|
|
940
940
|
}): Promise<boolean>;
|
|
941
|
+
/**
|
|
942
|
+
* Apply the same patch to many records in a single round-trip.
|
|
943
|
+
*
|
|
944
|
+
* Sends one `POST /api/v1/data/:object/updateMany` request whose body
|
|
945
|
+
* is `{ records: ids.map(id => ({id, data: patch})), options: { continueOnError: true }}`.
|
|
946
|
+
* The server iterates server-side (still N engine writes) but the
|
|
947
|
+
* client only pays for ONE HTTP/auth/RLS round-trip — the relevant
|
|
948
|
+
* perf win for inbox / list-toolbar "mark all read" / "archive
|
|
949
|
+
* selected" interactions where N can easily be in the hundreds.
|
|
950
|
+
*
|
|
951
|
+
* Falls back to a sequential per-id loop when the connected client
|
|
952
|
+
* does not expose `updateMany` (older clients / offline adapters).
|
|
953
|
+
* In that case `continueOnError` semantics are emulated locally so
|
|
954
|
+
* callers see the same return shape.
|
|
955
|
+
*/
|
|
956
|
+
bulkUpdate(resource: string, ids: ReadonlyArray<string | number>, patch: Partial<T>): Promise<number>;
|
|
957
|
+
/**
|
|
958
|
+
* Single-call bulk delete. Mirrors the bulkUpdate contract: prefers
|
|
959
|
+
* the server's `deleteMany` primitive when the client supports it;
|
|
960
|
+
* otherwise emulates `continueOnError` by looping `delete` per id and
|
|
961
|
+
* swallowing per-row failures. Returns the count of rows reported
|
|
962
|
+
* deleted by the server (or successfully deleted in fallback mode).
|
|
963
|
+
*/
|
|
964
|
+
bulkDelete(resource: string, ids: ReadonlyArray<string | number>): Promise<number>;
|
|
941
965
|
/**
|
|
942
966
|
* Bulk operations with optimized batch processing and error handling.
|
|
943
967
|
* Emits progress events for tracking operation status.
|
package/dist/index.js
CHANGED
|
@@ -1321,6 +1321,82 @@ var ObjectStackAdapter = class {
|
|
|
1321
1321
|
throw normaliseClientError(err);
|
|
1322
1322
|
}
|
|
1323
1323
|
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Apply the same patch to many records in a single round-trip.
|
|
1326
|
+
*
|
|
1327
|
+
* Sends one `POST /api/v1/data/:object/updateMany` request whose body
|
|
1328
|
+
* is `{ records: ids.map(id => ({id, data: patch})), options: { continueOnError: true }}`.
|
|
1329
|
+
* The server iterates server-side (still N engine writes) but the
|
|
1330
|
+
* client only pays for ONE HTTP/auth/RLS round-trip — the relevant
|
|
1331
|
+
* perf win for inbox / list-toolbar "mark all read" / "archive
|
|
1332
|
+
* selected" interactions where N can easily be in the hundreds.
|
|
1333
|
+
*
|
|
1334
|
+
* Falls back to a sequential per-id loop when the connected client
|
|
1335
|
+
* does not expose `updateMany` (older clients / offline adapters).
|
|
1336
|
+
* In that case `continueOnError` semantics are emulated locally so
|
|
1337
|
+
* callers see the same return shape.
|
|
1338
|
+
*/
|
|
1339
|
+
async bulkUpdate(resource, ids, patch) {
|
|
1340
|
+
await this.connect();
|
|
1341
|
+
if (!ids || ids.length === 0) return 0;
|
|
1342
|
+
const records = ids.map((id) => ({ id: String(id), data: patch }));
|
|
1343
|
+
const updateMany = this.client.data.updateMany;
|
|
1344
|
+
if (typeof updateMany === "function") {
|
|
1345
|
+
try {
|
|
1346
|
+
const res = await updateMany(resource, records, { continueOnError: true });
|
|
1347
|
+
if (res && typeof res === "object" && typeof res.succeeded === "number") {
|
|
1348
|
+
return res.succeeded;
|
|
1349
|
+
}
|
|
1350
|
+
if (Array.isArray(res)) return res.length;
|
|
1351
|
+
return ids.length;
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
throw normaliseClientError(err);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
let succeeded = 0;
|
|
1357
|
+
for (const id of ids) {
|
|
1358
|
+
try {
|
|
1359
|
+
await this.client.data.update(resource, String(id), patch);
|
|
1360
|
+
succeeded++;
|
|
1361
|
+
} catch {
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return succeeded;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Single-call bulk delete. Mirrors the bulkUpdate contract: prefers
|
|
1368
|
+
* the server's `deleteMany` primitive when the client supports it;
|
|
1369
|
+
* otherwise emulates `continueOnError` by looping `delete` per id and
|
|
1370
|
+
* swallowing per-row failures. Returns the count of rows reported
|
|
1371
|
+
* deleted by the server (or successfully deleted in fallback mode).
|
|
1372
|
+
*/
|
|
1373
|
+
async bulkDelete(resource, ids) {
|
|
1374
|
+
await this.connect();
|
|
1375
|
+
if (!ids || ids.length === 0) return 0;
|
|
1376
|
+
const strIds = ids.map((id) => String(id));
|
|
1377
|
+
const deleteMany = this.client.data.deleteMany;
|
|
1378
|
+
if (typeof deleteMany === "function") {
|
|
1379
|
+
try {
|
|
1380
|
+
const res = await deleteMany(resource, strIds, { continueOnError: true });
|
|
1381
|
+
if (res && typeof res === "object" && typeof res.succeeded === "number") {
|
|
1382
|
+
return res.succeeded;
|
|
1383
|
+
}
|
|
1384
|
+
if (Array.isArray(res)) return res.length;
|
|
1385
|
+
return strIds.length;
|
|
1386
|
+
} catch (err) {
|
|
1387
|
+
throw normaliseClientError(err);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
let succeeded = 0;
|
|
1391
|
+
for (const id of strIds) {
|
|
1392
|
+
try {
|
|
1393
|
+
await this.client.data.delete(resource, id);
|
|
1394
|
+
succeeded++;
|
|
1395
|
+
} catch {
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return succeeded;
|
|
1399
|
+
}
|
|
1324
1400
|
/**
|
|
1325
1401
|
* Bulk operations with optimized batch processing and error handling.
|
|
1326
1402
|
* Emits progress events for tracking operation status.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "ObjectStack Data Adapter for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@objectstack/client": "^4.2.0",
|
|
26
|
-
"@object-ui/core": "5.
|
|
27
|
-
"@object-ui/types": "5.
|
|
26
|
+
"@object-ui/core": "5.3.0",
|
|
27
|
+
"@object-ui/types": "5.3.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tsup": "^8.5.1",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { ObjectStackAdapter } from './index';
|
|
11
|
+
|
|
12
|
+
function makeDS(stub: { deleteMany?: any; delete?: any }) {
|
|
13
|
+
const ds: any = new ObjectStackAdapter({
|
|
14
|
+
baseUrl: 'http://test.local',
|
|
15
|
+
fetch: vi.fn(async () => {
|
|
16
|
+
const body = { success: true, data: { capabilities: {}, routes: {} } };
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
});
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
ds.connected = true;
|
|
24
|
+
ds.connectionState = 'connected';
|
|
25
|
+
ds.client = { data: stub };
|
|
26
|
+
return ds;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('ObjectStackDataSource.bulkDelete', () => {
|
|
30
|
+
it('returns 0 and skips network when ids is empty', async () => {
|
|
31
|
+
const deleteMany = vi.fn();
|
|
32
|
+
const ds = makeDS({ deleteMany });
|
|
33
|
+
const n = await ds.bulkDelete('sys_notification', []);
|
|
34
|
+
expect(n).toBe(0);
|
|
35
|
+
expect(deleteMany).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('issues a single deleteMany call with the id list', async () => {
|
|
39
|
+
const deleteMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0 });
|
|
40
|
+
const ds = makeDS({ deleteMany });
|
|
41
|
+
const n = await ds.bulkDelete('task', ['a', 'b', 'c']);
|
|
42
|
+
expect(n).toBe(3);
|
|
43
|
+
expect(deleteMany).toHaveBeenCalledTimes(1);
|
|
44
|
+
const [resource, ids, opts] = deleteMany.mock.calls[0];
|
|
45
|
+
expect(resource).toBe('task');
|
|
46
|
+
expect(ids).toEqual(['a', 'b', 'c']);
|
|
47
|
+
expect(opts).toEqual({ continueOnError: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns ids.length when deleteMany returns void (older clients)', async () => {
|
|
51
|
+
const deleteMany = vi.fn().mockResolvedValue(undefined);
|
|
52
|
+
const ds = makeDS({ deleteMany });
|
|
53
|
+
const n = await ds.bulkDelete('task', ['a', 'b']);
|
|
54
|
+
expect(n).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('falls back to per-id delete loop when deleteMany is missing', async () => {
|
|
58
|
+
const del = vi.fn(async (_: string, id: string) => {
|
|
59
|
+
if (id === 'b') throw new Error('FK violation');
|
|
60
|
+
});
|
|
61
|
+
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);
|
|
65
|
+
expect(del).toHaveBeenCalledTimes(3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('coerces non-string ids before sending', async () => {
|
|
69
|
+
const deleteMany = vi.fn().mockResolvedValue({ succeeded: 2 });
|
|
70
|
+
const ds = makeDS({ deleteMany });
|
|
71
|
+
await ds.bulkDelete('task', [1, 2]);
|
|
72
|
+
const [, ids] = deleteMany.mock.calls[0];
|
|
73
|
+
expect(ids).toEqual(['1', '2']);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { ObjectStackAdapter } from './index';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a DataSource instance with a stubbed `fetch` that satisfies the
|
|
14
|
+
* one-shot discovery probe inside `connect()`. The client object is then
|
|
15
|
+
* swapped out for a lightweight mock so we can assert directly against
|
|
16
|
+
* the `updateMany` / `update` calls without spinning real HTTP.
|
|
17
|
+
*/
|
|
18
|
+
function makeDS(stub: { updateMany?: any; update?: any }) {
|
|
19
|
+
const ds: any = new ObjectStackAdapter({
|
|
20
|
+
baseUrl: 'http://test.local',
|
|
21
|
+
fetch: vi.fn(async (_url: any, _init: any) => {
|
|
22
|
+
const body = { success: true, data: { capabilities: {}, routes: {} } };
|
|
23
|
+
return new Response(JSON.stringify(body), {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
});
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
// Pre-mark connected so connect() short-circuits without hitting fetch.
|
|
30
|
+
ds.connected = true;
|
|
31
|
+
ds.connectionState = 'connected';
|
|
32
|
+
ds.client = { data: stub };
|
|
33
|
+
return ds;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('ObjectStackDataSource.bulkUpdate', () => {
|
|
37
|
+
it('returns 0 and skips network when ids is empty', async () => {
|
|
38
|
+
const updateMany = vi.fn();
|
|
39
|
+
const ds = makeDS({ updateMany });
|
|
40
|
+
const n = await ds.bulkUpdate('sys_notification', [], { is_read: true });
|
|
41
|
+
expect(n).toBe(0);
|
|
42
|
+
expect(updateMany).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('issues a single updateMany call with the per-id record shape', async () => {
|
|
46
|
+
const updateMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0, results: [] });
|
|
47
|
+
const ds = makeDS({ updateMany });
|
|
48
|
+
const n = await ds.bulkUpdate('sys_notification', ['a', 'b', 'c'], {
|
|
49
|
+
is_read: true,
|
|
50
|
+
read_at: '2026-05-23T05:00:00.000Z',
|
|
51
|
+
});
|
|
52
|
+
expect(n).toBe(3);
|
|
53
|
+
expect(updateMany).toHaveBeenCalledTimes(1);
|
|
54
|
+
const [resource, records, opts] = updateMany.mock.calls[0];
|
|
55
|
+
expect(resource).toBe('sys_notification');
|
|
56
|
+
expect(opts).toEqual({ continueOnError: true });
|
|
57
|
+
expect(records).toEqual([
|
|
58
|
+
{ id: 'a', data: { is_read: true, read_at: '2026-05-23T05:00:00.000Z' } },
|
|
59
|
+
{ id: 'b', data: { is_read: true, read_at: '2026-05-23T05:00:00.000Z' } },
|
|
60
|
+
{ id: 'c', data: { is_read: true, read_at: '2026-05-23T05:00:00.000Z' } },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('honors server-reported partial success counts', async () => {
|
|
65
|
+
const updateMany = vi.fn().mockResolvedValue({ succeeded: 2, failed: 1, results: [] });
|
|
66
|
+
const ds = makeDS({ updateMany });
|
|
67
|
+
const n = await ds.bulkUpdate('sys_notification', ['a', 'b', 'c'], { is_read: true });
|
|
68
|
+
expect(n).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('falls back to per-id updates when client.data.updateMany is missing', async () => {
|
|
72
|
+
const update = vi.fn().mockResolvedValue({ record: {} });
|
|
73
|
+
const ds = makeDS({ update });
|
|
74
|
+
const n = await ds.bulkUpdate('sys_notification', ['a', 'b'], { is_read: true });
|
|
75
|
+
expect(n).toBe(2);
|
|
76
|
+
expect(update).toHaveBeenCalledTimes(2);
|
|
77
|
+
expect(update.mock.calls[0][0]).toBe('sys_notification');
|
|
78
|
+
expect(update.mock.calls[0][1]).toBe('a');
|
|
79
|
+
expect(update.mock.calls[0][2]).toEqual({ is_read: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('fallback path tolerates per-row failures (continueOnError semantics)', async () => {
|
|
83
|
+
const update = vi
|
|
84
|
+
.fn()
|
|
85
|
+
.mockResolvedValueOnce({ record: {} })
|
|
86
|
+
.mockRejectedValueOnce(new Error('row 2 failed'))
|
|
87
|
+
.mockResolvedValueOnce({ record: {} });
|
|
88
|
+
const ds = makeDS({ update });
|
|
89
|
+
const n = await ds.bulkUpdate('sys_notification', ['a', 'b', 'c'], { is_read: true });
|
|
90
|
+
expect(n).toBe(2);
|
|
91
|
+
expect(update).toHaveBeenCalledTimes(3);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -750,6 +750,104 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
750
750
|
}
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Apply the same patch to many records in a single round-trip.
|
|
755
|
+
*
|
|
756
|
+
* Sends one `POST /api/v1/data/:object/updateMany` request whose body
|
|
757
|
+
* is `{ records: ids.map(id => ({id, data: patch})), options: { continueOnError: true }}`.
|
|
758
|
+
* The server iterates server-side (still N engine writes) but the
|
|
759
|
+
* client only pays for ONE HTTP/auth/RLS round-trip — the relevant
|
|
760
|
+
* perf win for inbox / list-toolbar "mark all read" / "archive
|
|
761
|
+
* selected" interactions where N can easily be in the hundreds.
|
|
762
|
+
*
|
|
763
|
+
* Falls back to a sequential per-id loop when the connected client
|
|
764
|
+
* does not expose `updateMany` (older clients / offline adapters).
|
|
765
|
+
* In that case `continueOnError` semantics are emulated locally so
|
|
766
|
+
* callers see the same return shape.
|
|
767
|
+
*/
|
|
768
|
+
async bulkUpdate(
|
|
769
|
+
resource: string,
|
|
770
|
+
ids: ReadonlyArray<string | number>,
|
|
771
|
+
patch: Partial<T>,
|
|
772
|
+
): Promise<number> {
|
|
773
|
+
await this.connect();
|
|
774
|
+
if (!ids || ids.length === 0) return 0;
|
|
775
|
+
const records = ids.map((id) => ({ id: String(id), data: patch as any }));
|
|
776
|
+
|
|
777
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
778
|
+
const updateMany = (this.client.data as any).updateMany;
|
|
779
|
+
if (typeof updateMany === 'function') {
|
|
780
|
+
try {
|
|
781
|
+
const res = await updateMany(resource, records, { continueOnError: true });
|
|
782
|
+
// The server returns BatchUpdateResponse { succeeded, failed, ... };
|
|
783
|
+
// fall back to ids.length on adapters that return a bare array.
|
|
784
|
+
if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
|
|
785
|
+
return (res as any).succeeded as number;
|
|
786
|
+
}
|
|
787
|
+
if (Array.isArray(res)) return (res as any[]).length;
|
|
788
|
+
return ids.length;
|
|
789
|
+
} catch (err) {
|
|
790
|
+
throw normaliseClientError(err);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Fallback: sequential per-id updates, tolerating failures.
|
|
795
|
+
let succeeded = 0;
|
|
796
|
+
for (const id of ids) {
|
|
797
|
+
try {
|
|
798
|
+
await this.client.data.update<T>(resource, String(id), patch);
|
|
799
|
+
succeeded++;
|
|
800
|
+
} catch {
|
|
801
|
+
// continueOnError semantics — swallow per-row errors
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return succeeded;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Single-call bulk delete. Mirrors the bulkUpdate contract: prefers
|
|
809
|
+
* the server's `deleteMany` primitive when the client supports it;
|
|
810
|
+
* otherwise emulates `continueOnError` by looping `delete` per id and
|
|
811
|
+
* swallowing per-row failures. Returns the count of rows reported
|
|
812
|
+
* deleted by the server (or successfully deleted in fallback mode).
|
|
813
|
+
*/
|
|
814
|
+
async bulkDelete(
|
|
815
|
+
resource: string,
|
|
816
|
+
ids: ReadonlyArray<string | number>,
|
|
817
|
+
): Promise<number> {
|
|
818
|
+
await this.connect();
|
|
819
|
+
if (!ids || ids.length === 0) return 0;
|
|
820
|
+
const strIds = ids.map((id) => String(id));
|
|
821
|
+
|
|
822
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
823
|
+
const deleteMany = (this.client.data as any).deleteMany;
|
|
824
|
+
if (typeof deleteMany === 'function') {
|
|
825
|
+
try {
|
|
826
|
+
const res = await deleteMany(resource, strIds, { continueOnError: true });
|
|
827
|
+
if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
|
|
828
|
+
return (res as any).succeeded as number;
|
|
829
|
+
}
|
|
830
|
+
if (Array.isArray(res)) return (res as any[]).length;
|
|
831
|
+
// deleteMany historically returns void on success — assume all hit.
|
|
832
|
+
return strIds.length;
|
|
833
|
+
} catch (err) {
|
|
834
|
+
throw normaliseClientError(err);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Fallback: sequential per-id deletes, tolerating failures.
|
|
839
|
+
let succeeded = 0;
|
|
840
|
+
for (const id of strIds) {
|
|
841
|
+
try {
|
|
842
|
+
await this.client.data.delete(resource, id);
|
|
843
|
+
succeeded++;
|
|
844
|
+
} catch {
|
|
845
|
+
// continueOnError semantics — swallow per-row errors
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return succeeded;
|
|
849
|
+
}
|
|
850
|
+
|
|
753
851
|
/**
|
|
754
852
|
* Bulk operations with optimized batch processing and error handling.
|
|
755
853
|
* Emits progress events for tracking operation status.
|