@object-ui/data-objectstack 5.0.2 → 5.2.1
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 +87 -0
- package/dist/index.cjs +146 -6
- package/dist/index.d.cts +85 -3
- package/dist/index.d.ts +85 -3
- package/dist/index.js +143 -6
- package/package.json +4 -4
- package/src/bulkDelete.test.ts +75 -0
- package/src/bulkUpdate.test.ts +93 -0
- package/src/index.ts +203 -6
- package/src/occ.test.ts +101 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# @object-ui/data-objectstack
|
|
2
2
|
|
|
3
|
+
## 5.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @object-ui/types@5.2.1
|
|
8
|
+
- @object-ui/core@5.2.1
|
|
9
|
+
|
|
10
|
+
## 5.2.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- de0c5e6: Add `DataSource.bulkDelete(resource, ids)` as the symmetric counterpart
|
|
15
|
+
to `bulkUpdate`. Implemented in `data-objectstack` via the client's
|
|
16
|
+
`deleteMany` primitive with a per-id fallback that emulates
|
|
17
|
+
`continueOnError` semantics for older clients.
|
|
18
|
+
|
|
19
|
+
Extract the bulk-vs-per-row decision into a reusable
|
|
20
|
+
`executeBulkBatch(input, ops)` helper in `@object-ui/core`:
|
|
21
|
+
- Single decision tree shared by both update and delete fast paths.
|
|
22
|
+
- Bulk success → no per-row pass.
|
|
23
|
+
- Bulk partial-count → aggregate batch error.
|
|
24
|
+
- Bulk throw → per-row fallback so users still get id-level error detail.
|
|
25
|
+
|
|
26
|
+
`useBulkExecutor` in plugin-grid now uses the helper for both `update`
|
|
27
|
+
and `delete` batches, cutting "delete 500 selected rows" from 500 HTTP
|
|
28
|
+
requests down to ~3.
|
|
29
|
+
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
32
|
+
AppHeader's `markAllRead` now prefers `bulkUpdate`, with a transparent fallback to the per-id loop for adapters that don't implement the helper.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- Updated dependencies [de0c5e6]
|
|
37
|
+
- Updated dependencies [9997cae]
|
|
38
|
+
- Updated dependencies [70b5570]
|
|
39
|
+
- Updated dependencies [d1442e3]
|
|
40
|
+
- @object-ui/types@5.2.0
|
|
41
|
+
- @object-ui/core@5.2.0
|
|
42
|
+
|
|
43
|
+
## 5.1.1
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- @object-ui/types@5.1.1
|
|
48
|
+
- @object-ui/core@5.1.1
|
|
49
|
+
|
|
50
|
+
## 5.1.0
|
|
51
|
+
|
|
52
|
+
### Minor Changes
|
|
53
|
+
|
|
54
|
+
- 5b80cfd: feat: Optimistic Concurrency Control (OCC) on DataSource writes
|
|
55
|
+
|
|
56
|
+
`DataSource.update()` and `DataSource.delete()` now accept an optional fourth /
|
|
57
|
+
third argument `opts?: { ifMatch?: string }`. When supplied, adapters forward
|
|
58
|
+
the token to the backend; servers that implement OCC (e.g. ObjectStack
|
|
59
|
+
`>=4.2.0`) compare it against the record's current `updated_at` and reject
|
|
60
|
+
with `409 CONCURRENT_UPDATE` on mismatch, preventing silent overwrites in
|
|
61
|
+
multi-user editing scenarios.
|
|
62
|
+
|
|
63
|
+
**`@object-ui/data-objectstack`**
|
|
64
|
+
- Exports `ConcurrentUpdateError` (carries `currentVersion` and
|
|
65
|
+
`currentRecord`) and `isConcurrentUpdateError()` type guard.
|
|
66
|
+
- `update()` / `delete()` accept `opts.ifMatch` and forward it via the
|
|
67
|
+
`@objectstack/client` data API (header: `If-Match`). Requires
|
|
68
|
+
`@objectstack/client@>=4.1.2` for the header to reach the server;
|
|
69
|
+
older clients silently drop the option and fall back to today's
|
|
70
|
+
"last writer wins" behaviour.
|
|
71
|
+
- Adapter-level error handling maps a 409 with `code === 'CONCURRENT_UPDATE'`
|
|
72
|
+
into a typed `ConcurrentUpdateError` so callers can detect and recover
|
|
73
|
+
from conflicts without parsing the wire format.
|
|
74
|
+
|
|
75
|
+
**`@object-ui/core`**
|
|
76
|
+
- `ApiDataSource.update()` and `.delete()` accept `opts.ifMatch` and emit
|
|
77
|
+
the `If-Match` HTTP header.
|
|
78
|
+
|
|
79
|
+
UI consumers (Detail view, inline cell-edit) will be wired in a follow-up
|
|
80
|
+
patch to capture `updated_at` at load time, pass it as `ifMatch` on save,
|
|
81
|
+
and present a Reload / Overwrite / Cancel dialog on conflict.
|
|
82
|
+
|
|
83
|
+
### Patch Changes
|
|
84
|
+
|
|
85
|
+
- Updated dependencies [cf30cc2]
|
|
86
|
+
- Updated dependencies [5b80cfd]
|
|
87
|
+
- @object-ui/types@5.1.0
|
|
88
|
+
- @object-ui/core@5.1.0
|
|
89
|
+
|
|
3
90
|
## 5.0.2
|
|
4
91
|
|
|
5
92
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -25,6 +25,7 @@ __export(index_exports, {
|
|
|
25
25
|
AuthenticationError: () => AuthenticationError,
|
|
26
26
|
BulkOperationError: () => BulkOperationError,
|
|
27
27
|
CloudOperations: () => CloudOperations,
|
|
28
|
+
ConcurrentUpdateError: () => ConcurrentUpdateError,
|
|
28
29
|
ConnectionError: () => ConnectionError,
|
|
29
30
|
IntegrationManager: () => IntegrationManager,
|
|
30
31
|
MetadataNotFoundError: () => MetadataNotFoundError,
|
|
@@ -41,8 +42,10 @@ __export(index_exports, {
|
|
|
41
42
|
generateContractManifest: () => generateContractManifest,
|
|
42
43
|
getSharedDiscovery: () => getSharedDiscovery,
|
|
43
44
|
is404Error: () => is404Error,
|
|
45
|
+
isConcurrentUpdateError: () => isConcurrentUpdateError,
|
|
44
46
|
isErrorType: () => isErrorType,
|
|
45
47
|
isObjectStackError: () => isObjectStackError,
|
|
48
|
+
normaliseClientError: () => normaliseClientError,
|
|
46
49
|
snapToGrid: () => snapToGrid,
|
|
47
50
|
validatePluginContract: () => validatePluginContract
|
|
48
51
|
});
|
|
@@ -1016,6 +1019,35 @@ function is404Error(error) {
|
|
|
1016
1019
|
const code = typeof err.code === "string" ? err.code : "";
|
|
1017
1020
|
return code === "object_not_found" || code === "record_not_found";
|
|
1018
1021
|
}
|
|
1022
|
+
var ConcurrentUpdateError = class extends Error {
|
|
1023
|
+
constructor(opts) {
|
|
1024
|
+
super(opts.message ?? "Record was modified by another user");
|
|
1025
|
+
__publicField(this, "code", "CONCURRENT_UPDATE");
|
|
1026
|
+
__publicField(this, "httpStatus", 409);
|
|
1027
|
+
__publicField(this, "currentVersion");
|
|
1028
|
+
__publicField(this, "currentRecord");
|
|
1029
|
+
this.name = "ConcurrentUpdateError";
|
|
1030
|
+
this.currentVersion = opts.currentVersion;
|
|
1031
|
+
this.currentRecord = opts.currentRecord;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
function isConcurrentUpdateError(error) {
|
|
1035
|
+
if (!error || typeof error !== "object") return false;
|
|
1036
|
+
const e = error;
|
|
1037
|
+
return e.code === "CONCURRENT_UPDATE" || e.name === "ConcurrentUpdateError";
|
|
1038
|
+
}
|
|
1039
|
+
function normaliseClientError(error) {
|
|
1040
|
+
if (!error || typeof error !== "object") return error;
|
|
1041
|
+
const e = error;
|
|
1042
|
+
if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
|
|
1043
|
+
if (e.code !== "CONCURRENT_UPDATE") return error;
|
|
1044
|
+
const details = e.details ?? {};
|
|
1045
|
+
return new ConcurrentUpdateError({
|
|
1046
|
+
currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
|
|
1047
|
+
currentRecord: details.currentRecord ?? null,
|
|
1048
|
+
message: typeof e.message === "string" ? e.message : void 0
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1019
1051
|
function createQuietHttpLogger() {
|
|
1020
1052
|
const isExpected404 = (meta) => {
|
|
1021
1053
|
if (!meta || typeof meta !== "object") return false;
|
|
@@ -1293,19 +1325,124 @@ var ObjectStackAdapter = class {
|
|
|
1293
1325
|
}
|
|
1294
1326
|
/**
|
|
1295
1327
|
* Update an existing record.
|
|
1328
|
+
*
|
|
1329
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control: the
|
|
1330
|
+
* server compares the supplied token (typically the `updated_at` value
|
|
1331
|
+
* the caller previously read) against the record's current version
|
|
1332
|
+
* and throws a {@link ConcurrentUpdateError} on mismatch (HTTP 409).
|
|
1333
|
+
*
|
|
1334
|
+
* Requires `@objectstack/client@>=4.2.0`, which forwards `opts.ifMatch`
|
|
1335
|
+
* as an `If-Match` HTTP header.
|
|
1296
1336
|
*/
|
|
1297
|
-
async update(resource, id, data) {
|
|
1337
|
+
async update(resource, id, data, opts) {
|
|
1298
1338
|
await this.connect();
|
|
1299
|
-
|
|
1300
|
-
|
|
1339
|
+
try {
|
|
1340
|
+
const result = await this.client.data.update(
|
|
1341
|
+
resource,
|
|
1342
|
+
String(id),
|
|
1343
|
+
data,
|
|
1344
|
+
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
1345
|
+
);
|
|
1346
|
+
return result.record;
|
|
1347
|
+
} catch (err) {
|
|
1348
|
+
throw normaliseClientError(err);
|
|
1349
|
+
}
|
|
1301
1350
|
}
|
|
1302
1351
|
/**
|
|
1303
1352
|
* Delete a record.
|
|
1353
|
+
*
|
|
1354
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control —
|
|
1355
|
+
* see {@link update} for details. On 409 the call rejects with
|
|
1356
|
+
* a {@link ConcurrentUpdateError}.
|
|
1304
1357
|
*/
|
|
1305
|
-
async delete(resource, id) {
|
|
1358
|
+
async delete(resource, id, opts) {
|
|
1306
1359
|
await this.connect();
|
|
1307
|
-
|
|
1308
|
-
|
|
1360
|
+
try {
|
|
1361
|
+
const result = await this.client.data.delete(
|
|
1362
|
+
resource,
|
|
1363
|
+
String(id),
|
|
1364
|
+
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
1365
|
+
);
|
|
1366
|
+
return result.deleted;
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
throw normaliseClientError(err);
|
|
1369
|
+
}
|
|
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;
|
|
1309
1446
|
}
|
|
1310
1447
|
/**
|
|
1311
1448
|
* Bulk operations with optimized batch processing and error handling.
|
|
@@ -2202,6 +2339,7 @@ function createObjectStackAdapter(config) {
|
|
|
2202
2339
|
AuthenticationError,
|
|
2203
2340
|
BulkOperationError,
|
|
2204
2341
|
CloudOperations,
|
|
2342
|
+
ConcurrentUpdateError,
|
|
2205
2343
|
ConnectionError,
|
|
2206
2344
|
IntegrationManager,
|
|
2207
2345
|
MetadataNotFoundError,
|
|
@@ -2218,8 +2356,10 @@ function createObjectStackAdapter(config) {
|
|
|
2218
2356
|
generateContractManifest,
|
|
2219
2357
|
getSharedDiscovery,
|
|
2220
2358
|
is404Error,
|
|
2359
|
+
isConcurrentUpdateError,
|
|
2221
2360
|
isErrorType,
|
|
2222
2361
|
isObjectStackError,
|
|
2362
|
+
normaliseClientError,
|
|
2223
2363
|
snapToGrid,
|
|
2224
2364
|
validatePluginContract
|
|
2225
2365
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -737,6 +737,48 @@ declare function clearSharedDiscoveryCache(): void;
|
|
|
737
737
|
* tripping on the property-name mismatch.
|
|
738
738
|
*/
|
|
739
739
|
declare function is404Error(error: unknown): boolean;
|
|
740
|
+
/**
|
|
741
|
+
* Thrown by `update()` / `delete()` when the server returns
|
|
742
|
+
* `409 CONCURRENT_UPDATE` — i.e. the record was modified by someone else
|
|
743
|
+
* between when the caller last read it and when they attempted to write.
|
|
744
|
+
*
|
|
745
|
+
* The error carries the current server-side `updated_at` version and the
|
|
746
|
+
* full latest record so the UI can render an informed conflict-resolution
|
|
747
|
+
* dialog (typically "Reload latest" / "Overwrite anyway" / "Cancel").
|
|
748
|
+
*
|
|
749
|
+
* Mirrors the {@link ConcurrentUpdateError} thrown by
|
|
750
|
+
* `@objectstack/objectql`'s protocol; the wire shape is:
|
|
751
|
+
* ```json
|
|
752
|
+
* { "code": "CONCURRENT_UPDATE",
|
|
753
|
+
* "error": "<message>",
|
|
754
|
+
* "currentVersion": "<updated_at>",
|
|
755
|
+
* "currentRecord": { ...latest... } }
|
|
756
|
+
* ```
|
|
757
|
+
*/
|
|
758
|
+
declare class ConcurrentUpdateError extends Error {
|
|
759
|
+
readonly code = "CONCURRENT_UPDATE";
|
|
760
|
+
readonly httpStatus = 409;
|
|
761
|
+
readonly currentVersion: string | null;
|
|
762
|
+
readonly currentRecord: unknown;
|
|
763
|
+
constructor(opts: {
|
|
764
|
+
currentVersion: string | null;
|
|
765
|
+
currentRecord: unknown;
|
|
766
|
+
message?: string;
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Detect "concurrent update" errors raised by the platform. The wire
|
|
771
|
+
* shape is `409` + `code: 'CONCURRENT_UPDATE'`. The client surfaces
|
|
772
|
+
* extra details on `error.details` (full response body).
|
|
773
|
+
*/
|
|
774
|
+
declare function isConcurrentUpdateError(error: unknown): error is ConcurrentUpdateError;
|
|
775
|
+
/**
|
|
776
|
+
* Convert any error thrown by the upstream client into a typed
|
|
777
|
+
* `ConcurrentUpdateError` when it represents a 409 CONCURRENT_UPDATE.
|
|
778
|
+
* Returns the original error untouched otherwise. Callers can simply
|
|
779
|
+
* `throw normaliseClientError(err)` from their catch blocks.
|
|
780
|
+
*/
|
|
781
|
+
declare function normaliseClientError(error: unknown): unknown;
|
|
740
782
|
/**
|
|
741
783
|
* Connection state for monitoring
|
|
742
784
|
*/
|
|
@@ -874,12 +916,52 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
874
916
|
create(resource: string, data: Partial<T>): Promise<T>;
|
|
875
917
|
/**
|
|
876
918
|
* Update an existing record.
|
|
919
|
+
*
|
|
920
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control: the
|
|
921
|
+
* server compares the supplied token (typically the `updated_at` value
|
|
922
|
+
* the caller previously read) against the record's current version
|
|
923
|
+
* and throws a {@link ConcurrentUpdateError} on mismatch (HTTP 409).
|
|
924
|
+
*
|
|
925
|
+
* Requires `@objectstack/client@>=4.2.0`, which forwards `opts.ifMatch`
|
|
926
|
+
* as an `If-Match` HTTP header.
|
|
877
927
|
*/
|
|
878
|
-
update(resource: string, id: string | number, data: Partial<T
|
|
928
|
+
update(resource: string, id: string | number, data: Partial<T>, opts?: {
|
|
929
|
+
ifMatch?: string;
|
|
930
|
+
}): Promise<T>;
|
|
879
931
|
/**
|
|
880
932
|
* Delete a record.
|
|
933
|
+
*
|
|
934
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control —
|
|
935
|
+
* see {@link update} for details. On 409 the call rejects with
|
|
936
|
+
* a {@link ConcurrentUpdateError}.
|
|
937
|
+
*/
|
|
938
|
+
delete(resource: string, id: string | number, opts?: {
|
|
939
|
+
ifMatch?: string;
|
|
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).
|
|
881
963
|
*/
|
|
882
|
-
|
|
964
|
+
bulkDelete(resource: string, ids: ReadonlyArray<string | number>): Promise<number>;
|
|
883
965
|
/**
|
|
884
966
|
* Bulk operations with optimized batch processing and error handling.
|
|
885
967
|
* Emits progress events for tracking operation status.
|
|
@@ -1151,4 +1233,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
|
|
|
1151
1233
|
reconnectDelay?: number;
|
|
1152
1234
|
}): DataSource<T>;
|
|
1153
1235
|
|
|
1154
|
-
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
|
|
1236
|
+
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConcurrentUpdateError, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isConcurrentUpdateError, isErrorType, isObjectStackError, normaliseClientError, snapToGrid, validatePluginContract };
|
package/dist/index.d.ts
CHANGED
|
@@ -737,6 +737,48 @@ declare function clearSharedDiscoveryCache(): void;
|
|
|
737
737
|
* tripping on the property-name mismatch.
|
|
738
738
|
*/
|
|
739
739
|
declare function is404Error(error: unknown): boolean;
|
|
740
|
+
/**
|
|
741
|
+
* Thrown by `update()` / `delete()` when the server returns
|
|
742
|
+
* `409 CONCURRENT_UPDATE` — i.e. the record was modified by someone else
|
|
743
|
+
* between when the caller last read it and when they attempted to write.
|
|
744
|
+
*
|
|
745
|
+
* The error carries the current server-side `updated_at` version and the
|
|
746
|
+
* full latest record so the UI can render an informed conflict-resolution
|
|
747
|
+
* dialog (typically "Reload latest" / "Overwrite anyway" / "Cancel").
|
|
748
|
+
*
|
|
749
|
+
* Mirrors the {@link ConcurrentUpdateError} thrown by
|
|
750
|
+
* `@objectstack/objectql`'s protocol; the wire shape is:
|
|
751
|
+
* ```json
|
|
752
|
+
* { "code": "CONCURRENT_UPDATE",
|
|
753
|
+
* "error": "<message>",
|
|
754
|
+
* "currentVersion": "<updated_at>",
|
|
755
|
+
* "currentRecord": { ...latest... } }
|
|
756
|
+
* ```
|
|
757
|
+
*/
|
|
758
|
+
declare class ConcurrentUpdateError extends Error {
|
|
759
|
+
readonly code = "CONCURRENT_UPDATE";
|
|
760
|
+
readonly httpStatus = 409;
|
|
761
|
+
readonly currentVersion: string | null;
|
|
762
|
+
readonly currentRecord: unknown;
|
|
763
|
+
constructor(opts: {
|
|
764
|
+
currentVersion: string | null;
|
|
765
|
+
currentRecord: unknown;
|
|
766
|
+
message?: string;
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Detect "concurrent update" errors raised by the platform. The wire
|
|
771
|
+
* shape is `409` + `code: 'CONCURRENT_UPDATE'`. The client surfaces
|
|
772
|
+
* extra details on `error.details` (full response body).
|
|
773
|
+
*/
|
|
774
|
+
declare function isConcurrentUpdateError(error: unknown): error is ConcurrentUpdateError;
|
|
775
|
+
/**
|
|
776
|
+
* Convert any error thrown by the upstream client into a typed
|
|
777
|
+
* `ConcurrentUpdateError` when it represents a 409 CONCURRENT_UPDATE.
|
|
778
|
+
* Returns the original error untouched otherwise. Callers can simply
|
|
779
|
+
* `throw normaliseClientError(err)` from their catch blocks.
|
|
780
|
+
*/
|
|
781
|
+
declare function normaliseClientError(error: unknown): unknown;
|
|
740
782
|
/**
|
|
741
783
|
* Connection state for monitoring
|
|
742
784
|
*/
|
|
@@ -874,12 +916,52 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
874
916
|
create(resource: string, data: Partial<T>): Promise<T>;
|
|
875
917
|
/**
|
|
876
918
|
* Update an existing record.
|
|
919
|
+
*
|
|
920
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control: the
|
|
921
|
+
* server compares the supplied token (typically the `updated_at` value
|
|
922
|
+
* the caller previously read) against the record's current version
|
|
923
|
+
* and throws a {@link ConcurrentUpdateError} on mismatch (HTTP 409).
|
|
924
|
+
*
|
|
925
|
+
* Requires `@objectstack/client@>=4.2.0`, which forwards `opts.ifMatch`
|
|
926
|
+
* as an `If-Match` HTTP header.
|
|
877
927
|
*/
|
|
878
|
-
update(resource: string, id: string | number, data: Partial<T
|
|
928
|
+
update(resource: string, id: string | number, data: Partial<T>, opts?: {
|
|
929
|
+
ifMatch?: string;
|
|
930
|
+
}): Promise<T>;
|
|
879
931
|
/**
|
|
880
932
|
* Delete a record.
|
|
933
|
+
*
|
|
934
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control —
|
|
935
|
+
* see {@link update} for details. On 409 the call rejects with
|
|
936
|
+
* a {@link ConcurrentUpdateError}.
|
|
937
|
+
*/
|
|
938
|
+
delete(resource: string, id: string | number, opts?: {
|
|
939
|
+
ifMatch?: string;
|
|
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).
|
|
881
963
|
*/
|
|
882
|
-
|
|
964
|
+
bulkDelete(resource: string, ids: ReadonlyArray<string | number>): Promise<number>;
|
|
883
965
|
/**
|
|
884
966
|
* Bulk operations with optimized batch processing and error handling.
|
|
885
967
|
* Emits progress events for tracking operation status.
|
|
@@ -1151,4 +1233,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
|
|
|
1151
1233
|
reconnectDelay?: number;
|
|
1152
1234
|
}): DataSource<T>;
|
|
1153
1235
|
|
|
1154
|
-
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
|
|
1236
|
+
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConcurrentUpdateError, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isConcurrentUpdateError, isErrorType, isObjectStackError, normaliseClientError, snapToGrid, validatePluginContract };
|
package/dist/index.js
CHANGED
|
@@ -972,6 +972,35 @@ function is404Error(error) {
|
|
|
972
972
|
const code = typeof err.code === "string" ? err.code : "";
|
|
973
973
|
return code === "object_not_found" || code === "record_not_found";
|
|
974
974
|
}
|
|
975
|
+
var ConcurrentUpdateError = class extends Error {
|
|
976
|
+
constructor(opts) {
|
|
977
|
+
super(opts.message ?? "Record was modified by another user");
|
|
978
|
+
__publicField(this, "code", "CONCURRENT_UPDATE");
|
|
979
|
+
__publicField(this, "httpStatus", 409);
|
|
980
|
+
__publicField(this, "currentVersion");
|
|
981
|
+
__publicField(this, "currentRecord");
|
|
982
|
+
this.name = "ConcurrentUpdateError";
|
|
983
|
+
this.currentVersion = opts.currentVersion;
|
|
984
|
+
this.currentRecord = opts.currentRecord;
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
function isConcurrentUpdateError(error) {
|
|
988
|
+
if (!error || typeof error !== "object") return false;
|
|
989
|
+
const e = error;
|
|
990
|
+
return e.code === "CONCURRENT_UPDATE" || e.name === "ConcurrentUpdateError";
|
|
991
|
+
}
|
|
992
|
+
function normaliseClientError(error) {
|
|
993
|
+
if (!error || typeof error !== "object") return error;
|
|
994
|
+
const e = error;
|
|
995
|
+
if (e.code !== "CONCURRENT_UPDATE" && e.httpStatus !== 409) return error;
|
|
996
|
+
if (e.code !== "CONCURRENT_UPDATE") return error;
|
|
997
|
+
const details = e.details ?? {};
|
|
998
|
+
return new ConcurrentUpdateError({
|
|
999
|
+
currentVersion: typeof details.currentVersion === "string" ? details.currentVersion : null,
|
|
1000
|
+
currentRecord: details.currentRecord ?? null,
|
|
1001
|
+
message: typeof e.message === "string" ? e.message : void 0
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
975
1004
|
function createQuietHttpLogger() {
|
|
976
1005
|
const isExpected404 = (meta) => {
|
|
977
1006
|
if (!meta || typeof meta !== "object") return false;
|
|
@@ -1249,19 +1278,124 @@ var ObjectStackAdapter = class {
|
|
|
1249
1278
|
}
|
|
1250
1279
|
/**
|
|
1251
1280
|
* Update an existing record.
|
|
1281
|
+
*
|
|
1282
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control: the
|
|
1283
|
+
* server compares the supplied token (typically the `updated_at` value
|
|
1284
|
+
* the caller previously read) against the record's current version
|
|
1285
|
+
* and throws a {@link ConcurrentUpdateError} on mismatch (HTTP 409).
|
|
1286
|
+
*
|
|
1287
|
+
* Requires `@objectstack/client@>=4.2.0`, which forwards `opts.ifMatch`
|
|
1288
|
+
* as an `If-Match` HTTP header.
|
|
1252
1289
|
*/
|
|
1253
|
-
async update(resource, id, data) {
|
|
1290
|
+
async update(resource, id, data, opts) {
|
|
1254
1291
|
await this.connect();
|
|
1255
|
-
|
|
1256
|
-
|
|
1292
|
+
try {
|
|
1293
|
+
const result = await this.client.data.update(
|
|
1294
|
+
resource,
|
|
1295
|
+
String(id),
|
|
1296
|
+
data,
|
|
1297
|
+
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
1298
|
+
);
|
|
1299
|
+
return result.record;
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
throw normaliseClientError(err);
|
|
1302
|
+
}
|
|
1257
1303
|
}
|
|
1258
1304
|
/**
|
|
1259
1305
|
* Delete a record.
|
|
1306
|
+
*
|
|
1307
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control —
|
|
1308
|
+
* see {@link update} for details. On 409 the call rejects with
|
|
1309
|
+
* a {@link ConcurrentUpdateError}.
|
|
1260
1310
|
*/
|
|
1261
|
-
async delete(resource, id) {
|
|
1311
|
+
async delete(resource, id, opts) {
|
|
1262
1312
|
await this.connect();
|
|
1263
|
-
|
|
1264
|
-
|
|
1313
|
+
try {
|
|
1314
|
+
const result = await this.client.data.delete(
|
|
1315
|
+
resource,
|
|
1316
|
+
String(id),
|
|
1317
|
+
opts?.ifMatch ? { ifMatch: opts.ifMatch } : void 0
|
|
1318
|
+
);
|
|
1319
|
+
return result.deleted;
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
throw normaliseClientError(err);
|
|
1322
|
+
}
|
|
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;
|
|
1265
1399
|
}
|
|
1266
1400
|
/**
|
|
1267
1401
|
* Bulk operations with optimized batch processing and error handling.
|
|
@@ -2157,6 +2291,7 @@ export {
|
|
|
2157
2291
|
AuthenticationError,
|
|
2158
2292
|
BulkOperationError,
|
|
2159
2293
|
CloudOperations,
|
|
2294
|
+
ConcurrentUpdateError,
|
|
2160
2295
|
ConnectionError,
|
|
2161
2296
|
IntegrationManager,
|
|
2162
2297
|
MetadataNotFoundError,
|
|
@@ -2173,8 +2308,10 @@ export {
|
|
|
2173
2308
|
generateContractManifest,
|
|
2174
2309
|
getSharedDiscovery,
|
|
2175
2310
|
is404Error,
|
|
2311
|
+
isConcurrentUpdateError,
|
|
2176
2312
|
isErrorType,
|
|
2177
2313
|
isObjectStackError,
|
|
2314
|
+
normaliseClientError,
|
|
2178
2315
|
snapToGrid,
|
|
2179
2316
|
validatePluginContract
|
|
2180
2317
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"description": "ObjectStack Data Adapter for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@objectstack/client": "^4.
|
|
26
|
-
"@object-ui/core": "5.
|
|
27
|
-
"@object-ui/types": "5.
|
|
25
|
+
"@objectstack/client": "^4.2.0",
|
|
26
|
+
"@object-ui/core": "5.2.1",
|
|
27
|
+
"@object-ui/types": "5.2.1"
|
|
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
|
@@ -177,6 +177,67 @@ export function is404Error(error: unknown): boolean {
|
|
|
177
177
|
return code === 'object_not_found' || code === 'record_not_found';
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Thrown by `update()` / `delete()` when the server returns
|
|
182
|
+
* `409 CONCURRENT_UPDATE` — i.e. the record was modified by someone else
|
|
183
|
+
* between when the caller last read it and when they attempted to write.
|
|
184
|
+
*
|
|
185
|
+
* The error carries the current server-side `updated_at` version and the
|
|
186
|
+
* full latest record so the UI can render an informed conflict-resolution
|
|
187
|
+
* dialog (typically "Reload latest" / "Overwrite anyway" / "Cancel").
|
|
188
|
+
*
|
|
189
|
+
* Mirrors the {@link ConcurrentUpdateError} thrown by
|
|
190
|
+
* `@objectstack/objectql`'s protocol; the wire shape is:
|
|
191
|
+
* ```json
|
|
192
|
+
* { "code": "CONCURRENT_UPDATE",
|
|
193
|
+
* "error": "<message>",
|
|
194
|
+
* "currentVersion": "<updated_at>",
|
|
195
|
+
* "currentRecord": { ...latest... } }
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export class ConcurrentUpdateError extends Error {
|
|
199
|
+
readonly code = 'CONCURRENT_UPDATE';
|
|
200
|
+
readonly httpStatus = 409;
|
|
201
|
+
readonly currentVersion: string | null;
|
|
202
|
+
readonly currentRecord: unknown;
|
|
203
|
+
constructor(opts: { currentVersion: string | null; currentRecord: unknown; message?: string }) {
|
|
204
|
+
super(opts.message ?? 'Record was modified by another user');
|
|
205
|
+
this.name = 'ConcurrentUpdateError';
|
|
206
|
+
this.currentVersion = opts.currentVersion;
|
|
207
|
+
this.currentRecord = opts.currentRecord;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Detect "concurrent update" errors raised by the platform. The wire
|
|
213
|
+
* shape is `409` + `code: 'CONCURRENT_UPDATE'`. The client surfaces
|
|
214
|
+
* extra details on `error.details` (full response body).
|
|
215
|
+
*/
|
|
216
|
+
export function isConcurrentUpdateError(error: unknown): error is ConcurrentUpdateError {
|
|
217
|
+
if (!error || typeof error !== 'object') return false;
|
|
218
|
+
const e = error as Record<string, unknown>;
|
|
219
|
+
return e.code === 'CONCURRENT_UPDATE' || e.name === 'ConcurrentUpdateError';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Convert any error thrown by the upstream client into a typed
|
|
224
|
+
* `ConcurrentUpdateError` when it represents a 409 CONCURRENT_UPDATE.
|
|
225
|
+
* Returns the original error untouched otherwise. Callers can simply
|
|
226
|
+
* `throw normaliseClientError(err)` from their catch blocks.
|
|
227
|
+
*/
|
|
228
|
+
export function normaliseClientError(error: unknown): unknown {
|
|
229
|
+
if (!error || typeof error !== 'object') return error;
|
|
230
|
+
const e = error as Record<string, unknown>;
|
|
231
|
+
if (e.code !== 'CONCURRENT_UPDATE' && e.httpStatus !== 409) return error;
|
|
232
|
+
if (e.code !== 'CONCURRENT_UPDATE') return error;
|
|
233
|
+
const details = (e.details ?? {}) as Record<string, unknown>;
|
|
234
|
+
return new ConcurrentUpdateError({
|
|
235
|
+
currentVersion: typeof details.currentVersion === 'string' ? details.currentVersion : null,
|
|
236
|
+
currentRecord: details.currentRecord ?? null,
|
|
237
|
+
message: typeof e.message === 'string' ? e.message : undefined,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
180
241
|
/**
|
|
181
242
|
* Build a Logger compatible with @objectstack/client that demotes expected
|
|
182
243
|
* 404 noise to console.debug. The client logs every non-2xx response with
|
|
@@ -635,20 +696,156 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
635
696
|
|
|
636
697
|
/**
|
|
637
698
|
* Update an existing record.
|
|
699
|
+
*
|
|
700
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control: the
|
|
701
|
+
* server compares the supplied token (typically the `updated_at` value
|
|
702
|
+
* the caller previously read) against the record's current version
|
|
703
|
+
* and throws a {@link ConcurrentUpdateError} on mismatch (HTTP 409).
|
|
704
|
+
*
|
|
705
|
+
* Requires `@objectstack/client@>=4.2.0`, which forwards `opts.ifMatch`
|
|
706
|
+
* as an `If-Match` HTTP header.
|
|
638
707
|
*/
|
|
639
|
-
async update(
|
|
708
|
+
async update(
|
|
709
|
+
resource: string,
|
|
710
|
+
id: string | number,
|
|
711
|
+
data: Partial<T>,
|
|
712
|
+
opts?: { ifMatch?: string },
|
|
713
|
+
): Promise<T> {
|
|
640
714
|
await this.connect();
|
|
641
|
-
|
|
642
|
-
|
|
715
|
+
try {
|
|
716
|
+
const result = await this.client.data.update<T>(
|
|
717
|
+
resource,
|
|
718
|
+
String(id),
|
|
719
|
+
data,
|
|
720
|
+
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
721
|
+
);
|
|
722
|
+
return result.record;
|
|
723
|
+
} catch (err) {
|
|
724
|
+
throw normaliseClientError(err);
|
|
725
|
+
}
|
|
643
726
|
}
|
|
644
727
|
|
|
645
728
|
/**
|
|
646
729
|
* Delete a record.
|
|
730
|
+
*
|
|
731
|
+
* Optional `opts.ifMatch` enables Optimistic Concurrency Control —
|
|
732
|
+
* see {@link update} for details. On 409 the call rejects with
|
|
733
|
+
* a {@link ConcurrentUpdateError}.
|
|
647
734
|
*/
|
|
648
|
-
async delete(
|
|
735
|
+
async delete(
|
|
736
|
+
resource: string,
|
|
737
|
+
id: string | number,
|
|
738
|
+
opts?: { ifMatch?: string },
|
|
739
|
+
): Promise<boolean> {
|
|
649
740
|
await this.connect();
|
|
650
|
-
|
|
651
|
-
|
|
741
|
+
try {
|
|
742
|
+
const result = await this.client.data.delete(
|
|
743
|
+
resource,
|
|
744
|
+
String(id),
|
|
745
|
+
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
746
|
+
);
|
|
747
|
+
return result.deleted;
|
|
748
|
+
} catch (err) {
|
|
749
|
+
throw normaliseClientError(err);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
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;
|
|
652
849
|
}
|
|
653
850
|
|
|
654
851
|
/**
|
package/src/occ.test.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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 } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
ConcurrentUpdateError,
|
|
12
|
+
isConcurrentUpdateError,
|
|
13
|
+
normaliseClientError,
|
|
14
|
+
} from './index';
|
|
15
|
+
|
|
16
|
+
describe('Optimistic Concurrency Control errors', () => {
|
|
17
|
+
describe('ConcurrentUpdateError', () => {
|
|
18
|
+
it('carries currentVersion + currentRecord and a stable shape', () => {
|
|
19
|
+
const e = new ConcurrentUpdateError({
|
|
20
|
+
currentVersion: '2026-05-22T07:14:00.000Z',
|
|
21
|
+
currentRecord: { id: 'rec_1', name: 'Acme', updated_at: '2026-05-22T07:14:00.000Z' },
|
|
22
|
+
message: 'stale write',
|
|
23
|
+
});
|
|
24
|
+
expect(e).toBeInstanceOf(Error);
|
|
25
|
+
expect(e.name).toBe('ConcurrentUpdateError');
|
|
26
|
+
expect(e.code).toBe('CONCURRENT_UPDATE');
|
|
27
|
+
expect(e.httpStatus).toBe(409);
|
|
28
|
+
expect(e.currentVersion).toBe('2026-05-22T07:14:00.000Z');
|
|
29
|
+
expect((e.currentRecord as any).name).toBe('Acme');
|
|
30
|
+
expect(e.message).toBe('stale write');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('defaults to a generic message when none is supplied', () => {
|
|
34
|
+
const e = new ConcurrentUpdateError({ currentVersion: null, currentRecord: null });
|
|
35
|
+
expect(e.message).toMatch(/modified/i);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('isConcurrentUpdateError', () => {
|
|
40
|
+
it('returns true for our typed instances', () => {
|
|
41
|
+
const e = new ConcurrentUpdateError({ currentVersion: null, currentRecord: null });
|
|
42
|
+
expect(isConcurrentUpdateError(e)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns true for plain objects with the canonical code', () => {
|
|
46
|
+
expect(isConcurrentUpdateError({ code: 'CONCURRENT_UPDATE' })).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns false for unrelated errors', () => {
|
|
50
|
+
expect(isConcurrentUpdateError(new Error('boom'))).toBe(false);
|
|
51
|
+
expect(isConcurrentUpdateError({ code: 'NOT_FOUND' })).toBe(false);
|
|
52
|
+
expect(isConcurrentUpdateError(null)).toBe(false);
|
|
53
|
+
expect(isConcurrentUpdateError(undefined)).toBe(false);
|
|
54
|
+
expect(isConcurrentUpdateError('CONCURRENT_UPDATE')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('normaliseClientError', () => {
|
|
59
|
+
it('returns the original error when it is not a 409 CONCURRENT_UPDATE', () => {
|
|
60
|
+
const generic = Object.assign(new Error('not found'), {
|
|
61
|
+
code: 'NOT_FOUND',
|
|
62
|
+
httpStatus: 404,
|
|
63
|
+
});
|
|
64
|
+
expect(normaliseClientError(generic)).toBe(generic);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns the original error for non-object inputs', () => {
|
|
68
|
+
expect(normaliseClientError(null)).toBe(null);
|
|
69
|
+
expect(normaliseClientError(undefined)).toBe(undefined);
|
|
70
|
+
expect(normaliseClientError('boom')).toBe('boom');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('wraps an upstream 409 CONCURRENT_UPDATE into a typed ConcurrentUpdateError', () => {
|
|
74
|
+
const upstream = Object.assign(new Error('Record was modified by another user'), {
|
|
75
|
+
code: 'CONCURRENT_UPDATE',
|
|
76
|
+
httpStatus: 409,
|
|
77
|
+
details: {
|
|
78
|
+
currentVersion: '2026-05-22T07:14:00.000Z',
|
|
79
|
+
currentRecord: { id: 'rec_1', name: 'Acme' },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const normalised = normaliseClientError(upstream);
|
|
83
|
+
expect(isConcurrentUpdateError(normalised)).toBe(true);
|
|
84
|
+
const typed = normalised as ConcurrentUpdateError;
|
|
85
|
+
expect(typed.currentVersion).toBe('2026-05-22T07:14:00.000Z');
|
|
86
|
+
expect((typed.currentRecord as any).name).toBe('Acme');
|
|
87
|
+
expect(typed.message).toBe('Record was modified by another user');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('tolerates a missing details payload', () => {
|
|
91
|
+
const upstream = Object.assign(new Error('stale'), {
|
|
92
|
+
code: 'CONCURRENT_UPDATE',
|
|
93
|
+
httpStatus: 409,
|
|
94
|
+
});
|
|
95
|
+
const normalised = normaliseClientError(upstream) as ConcurrentUpdateError;
|
|
96
|
+
expect(isConcurrentUpdateError(normalised)).toBe(true);
|
|
97
|
+
expect(normalised.currentVersion).toBeNull();
|
|
98
|
+
expect(normalised.currentRecord).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|