@object-ui/data-objectstack 5.0.1 → 5.1.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 CHANGED
@@ -1,5 +1,59 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 5.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - @object-ui/types@5.1.1
8
+ - @object-ui/core@5.1.1
9
+
10
+ ## 5.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 5b80cfd: feat: Optimistic Concurrency Control (OCC) on DataSource writes
15
+
16
+ `DataSource.update()` and `DataSource.delete()` now accept an optional fourth /
17
+ third argument `opts?: { ifMatch?: string }`. When supplied, adapters forward
18
+ the token to the backend; servers that implement OCC (e.g. ObjectStack
19
+ `>=4.2.0`) compare it against the record's current `updated_at` and reject
20
+ with `409 CONCURRENT_UPDATE` on mismatch, preventing silent overwrites in
21
+ multi-user editing scenarios.
22
+
23
+ **`@object-ui/data-objectstack`**
24
+ - Exports `ConcurrentUpdateError` (carries `currentVersion` and
25
+ `currentRecord`) and `isConcurrentUpdateError()` type guard.
26
+ - `update()` / `delete()` accept `opts.ifMatch` and forward it via the
27
+ `@objectstack/client` data API (header: `If-Match`). Requires
28
+ `@objectstack/client@>=4.1.2` for the header to reach the server;
29
+ older clients silently drop the option and fall back to today's
30
+ "last writer wins" behaviour.
31
+ - Adapter-level error handling maps a 409 with `code === 'CONCURRENT_UPDATE'`
32
+ into a typed `ConcurrentUpdateError` so callers can detect and recover
33
+ from conflicts without parsing the wire format.
34
+
35
+ **`@object-ui/core`**
36
+ - `ApiDataSource.update()` and `.delete()` accept `opts.ifMatch` and emit
37
+ the `If-Match` HTTP header.
38
+
39
+ UI consumers (Detail view, inline cell-edit) will be wired in a follow-up
40
+ patch to capture `updated_at` at load time, pass it as `ifMatch` on save,
41
+ and present a Reload / Overwrite / Cancel dialog on conflict.
42
+
43
+ ### Patch Changes
44
+
45
+ - Updated dependencies [cf30cc2]
46
+ - Updated dependencies [5b80cfd]
47
+ - @object-ui/types@5.1.0
48
+ - @object-ui/core@5.1.0
49
+
50
+ ## 5.0.2
51
+
52
+ ### Patch Changes
53
+
54
+ - @object-ui/types@5.0.2
55
+ - @object-ui/core@5.0.2
56
+
3
57
  ## 5.0.1
4
58
 
5
59
  ### 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,48 @@ 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
- const result = await this.client.data.update(resource, String(id), data);
1300
- return result.record;
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
- const result = await this.client.data.delete(resource, String(id));
1308
- return result.deleted;
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
+ }
1309
1370
  }
1310
1371
  /**
1311
1372
  * Bulk operations with optimized batch processing and error handling.
@@ -2126,6 +2187,63 @@ var ObjectStackAdapter = class {
2126
2187
  }
2127
2188
  return response.json();
2128
2189
  }
2190
+ /**
2191
+ * Cancel (recall) the active pending approval request for a given record.
2192
+ *
2193
+ * Looks up the most recent `sys_approval_request` for the (object, record)
2194
+ * pair whose status is `pending` or `in_approval`, then issues a POST to
2195
+ * `/api/v1/approvals/requests/:id/recall`. The submitter is the only role
2196
+ * permitted to recall on the server — non-submitters will receive a 403.
2197
+ *
2198
+ * On success, the backend mirrors `approval_status = 'recalled'` onto the
2199
+ * source record so the lock badge disappears on next fetch.
2200
+ */
2201
+ async cancelPendingApproval(objectName, recordId) {
2202
+ await this.connect();
2203
+ const listUrl = `${this.baseUrl}/api/v1/approvals/requests?recordId=${encodeURIComponent(recordId)}&object=${encodeURIComponent(objectName)}`;
2204
+ const listRes = await this.fetchImpl(listUrl, {
2205
+ method: "GET",
2206
+ headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }
2207
+ });
2208
+ if (!listRes.ok) {
2209
+ throw new ObjectStackError(
2210
+ `Failed to look up approval requests (status ${listRes.status})`,
2211
+ "APPROVAL_LOOKUP_FAILED",
2212
+ listRes.status
2213
+ );
2214
+ }
2215
+ const listBody = await listRes.json().catch(() => ({}));
2216
+ const rows = Array.isArray(listBody) ? listBody : listBody?.data ?? [];
2217
+ const pending = rows.find(
2218
+ (r) => r?.status === "pending" || r?.status === "in_approval"
2219
+ );
2220
+ if (!pending?.id) {
2221
+ throw new ObjectStackError(
2222
+ "No pending approval request found for this record",
2223
+ "NO_PENDING_REQUEST",
2224
+ 404
2225
+ );
2226
+ }
2227
+ const url = `${this.baseUrl}/api/v1/approvals/requests/${encodeURIComponent(pending.id)}/recall`;
2228
+ const response = await this.fetchImpl(url, {
2229
+ method: "POST",
2230
+ headers: {
2231
+ "Content-Type": "application/json",
2232
+ ...this.getAuthHeaders()
2233
+ },
2234
+ body: JSON.stringify({})
2235
+ });
2236
+ if (!response.ok) {
2237
+ const err = await response.json().catch(() => ({}));
2238
+ throw new ObjectStackError(
2239
+ err?.error || err?.message || `Recall failed with status ${response.status}`,
2240
+ err?.code || "APPROVAL_RECALL_FAILED",
2241
+ response.status
2242
+ );
2243
+ }
2244
+ const body = await response.json().catch(() => ({}));
2245
+ return { requestId: pending.id, status: body?.data?.request?.status ?? "recalled" };
2246
+ }
2129
2247
  /**
2130
2248
  * Get authorization headers from the adapter config.
2131
2249
  */
@@ -2145,6 +2263,7 @@ function createObjectStackAdapter(config) {
2145
2263
  AuthenticationError,
2146
2264
  BulkOperationError,
2147
2265
  CloudOperations,
2266
+ ConcurrentUpdateError,
2148
2267
  ConnectionError,
2149
2268
  IntegrationManager,
2150
2269
  MetadataNotFoundError,
@@ -2161,8 +2280,10 @@ function createObjectStackAdapter(config) {
2161
2280
  generateContractManifest,
2162
2281
  getSharedDiscovery,
2163
2282
  is404Error,
2283
+ isConcurrentUpdateError,
2164
2284
  isErrorType,
2165
2285
  isObjectStackError,
2286
+ normaliseClientError,
2166
2287
  snapToGrid,
2167
2288
  validatePluginContract
2168
2289
  });
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,28 @@ 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>): Promise<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}.
881
937
  */
882
- delete(resource: string, id: string | number): Promise<boolean>;
938
+ delete(resource: string, id: string | number, opts?: {
939
+ ifMatch?: string;
940
+ }): Promise<boolean>;
883
941
  /**
884
942
  * Bulk operations with optimized batch processing and error handling.
885
943
  * Emits progress events for tracking operation status.
@@ -1104,6 +1162,21 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1104
1162
  metadata?: Record<string, unknown>;
1105
1163
  onProgress?: (percent: number) => void;
1106
1164
  }): Promise<FileUploadResult[]>;
1165
+ /**
1166
+ * Cancel (recall) the active pending approval request for a given record.
1167
+ *
1168
+ * Looks up the most recent `sys_approval_request` for the (object, record)
1169
+ * pair whose status is `pending` or `in_approval`, then issues a POST to
1170
+ * `/api/v1/approvals/requests/:id/recall`. The submitter is the only role
1171
+ * permitted to recall on the server — non-submitters will receive a 403.
1172
+ *
1173
+ * On success, the backend mirrors `approval_status = 'recalled'` onto the
1174
+ * source record so the lock badge disappears on next fetch.
1175
+ */
1176
+ cancelPendingApproval(objectName: string, recordId: string): Promise<{
1177
+ requestId: string;
1178
+ status: string;
1179
+ }>;
1107
1180
  /**
1108
1181
  * Get authorization headers from the adapter config.
1109
1182
  */
@@ -1136,4 +1209,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
1136
1209
  reconnectDelay?: number;
1137
1210
  }): DataSource<T>;
1138
1211
 
1139
- 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 };
1212
+ 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,28 @@ 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>): Promise<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}.
881
937
  */
882
- delete(resource: string, id: string | number): Promise<boolean>;
938
+ delete(resource: string, id: string | number, opts?: {
939
+ ifMatch?: string;
940
+ }): Promise<boolean>;
883
941
  /**
884
942
  * Bulk operations with optimized batch processing and error handling.
885
943
  * Emits progress events for tracking operation status.
@@ -1104,6 +1162,21 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1104
1162
  metadata?: Record<string, unknown>;
1105
1163
  onProgress?: (percent: number) => void;
1106
1164
  }): Promise<FileUploadResult[]>;
1165
+ /**
1166
+ * Cancel (recall) the active pending approval request for a given record.
1167
+ *
1168
+ * Looks up the most recent `sys_approval_request` for the (object, record)
1169
+ * pair whose status is `pending` or `in_approval`, then issues a POST to
1170
+ * `/api/v1/approvals/requests/:id/recall`. The submitter is the only role
1171
+ * permitted to recall on the server — non-submitters will receive a 403.
1172
+ *
1173
+ * On success, the backend mirrors `approval_status = 'recalled'` onto the
1174
+ * source record so the lock badge disappears on next fetch.
1175
+ */
1176
+ cancelPendingApproval(objectName: string, recordId: string): Promise<{
1177
+ requestId: string;
1178
+ status: string;
1179
+ }>;
1107
1180
  /**
1108
1181
  * Get authorization headers from the adapter config.
1109
1182
  */
@@ -1136,4 +1209,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
1136
1209
  reconnectDelay?: number;
1137
1210
  }): DataSource<T>;
1138
1211
 
1139
- 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 };
1212
+ 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,48 @@ 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
- const result = await this.client.data.update(resource, String(id), data);
1256
- return result.record;
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
- const result = await this.client.data.delete(resource, String(id));
1264
- return result.deleted;
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
+ }
1265
1323
  }
1266
1324
  /**
1267
1325
  * Bulk operations with optimized batch processing and error handling.
@@ -2082,6 +2140,63 @@ var ObjectStackAdapter = class {
2082
2140
  }
2083
2141
  return response.json();
2084
2142
  }
2143
+ /**
2144
+ * Cancel (recall) the active pending approval request for a given record.
2145
+ *
2146
+ * Looks up the most recent `sys_approval_request` for the (object, record)
2147
+ * pair whose status is `pending` or `in_approval`, then issues a POST to
2148
+ * `/api/v1/approvals/requests/:id/recall`. The submitter is the only role
2149
+ * permitted to recall on the server — non-submitters will receive a 403.
2150
+ *
2151
+ * On success, the backend mirrors `approval_status = 'recalled'` onto the
2152
+ * source record so the lock badge disappears on next fetch.
2153
+ */
2154
+ async cancelPendingApproval(objectName, recordId) {
2155
+ await this.connect();
2156
+ const listUrl = `${this.baseUrl}/api/v1/approvals/requests?recordId=${encodeURIComponent(recordId)}&object=${encodeURIComponent(objectName)}`;
2157
+ const listRes = await this.fetchImpl(listUrl, {
2158
+ method: "GET",
2159
+ headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }
2160
+ });
2161
+ if (!listRes.ok) {
2162
+ throw new ObjectStackError(
2163
+ `Failed to look up approval requests (status ${listRes.status})`,
2164
+ "APPROVAL_LOOKUP_FAILED",
2165
+ listRes.status
2166
+ );
2167
+ }
2168
+ const listBody = await listRes.json().catch(() => ({}));
2169
+ const rows = Array.isArray(listBody) ? listBody : listBody?.data ?? [];
2170
+ const pending = rows.find(
2171
+ (r) => r?.status === "pending" || r?.status === "in_approval"
2172
+ );
2173
+ if (!pending?.id) {
2174
+ throw new ObjectStackError(
2175
+ "No pending approval request found for this record",
2176
+ "NO_PENDING_REQUEST",
2177
+ 404
2178
+ );
2179
+ }
2180
+ const url = `${this.baseUrl}/api/v1/approvals/requests/${encodeURIComponent(pending.id)}/recall`;
2181
+ const response = await this.fetchImpl(url, {
2182
+ method: "POST",
2183
+ headers: {
2184
+ "Content-Type": "application/json",
2185
+ ...this.getAuthHeaders()
2186
+ },
2187
+ body: JSON.stringify({})
2188
+ });
2189
+ if (!response.ok) {
2190
+ const err = await response.json().catch(() => ({}));
2191
+ throw new ObjectStackError(
2192
+ err?.error || err?.message || `Recall failed with status ${response.status}`,
2193
+ err?.code || "APPROVAL_RECALL_FAILED",
2194
+ response.status
2195
+ );
2196
+ }
2197
+ const body = await response.json().catch(() => ({}));
2198
+ return { requestId: pending.id, status: body?.data?.request?.status ?? "recalled" };
2199
+ }
2085
2200
  /**
2086
2201
  * Get authorization headers from the adapter config.
2087
2202
  */
@@ -2100,6 +2215,7 @@ export {
2100
2215
  AuthenticationError,
2101
2216
  BulkOperationError,
2102
2217
  CloudOperations,
2218
+ ConcurrentUpdateError,
2103
2219
  ConnectionError,
2104
2220
  IntegrationManager,
2105
2221
  MetadataNotFoundError,
@@ -2116,8 +2232,10 @@ export {
2116
2232
  generateContractManifest,
2117
2233
  getSharedDiscovery,
2118
2234
  is404Error,
2235
+ isConcurrentUpdateError,
2119
2236
  isErrorType,
2120
2237
  isObjectStackError,
2238
+ normaliseClientError,
2121
2239
  snapToGrid,
2122
2240
  validatePluginContract
2123
2241
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "5.0.1",
3
+ "version": "5.1.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.0.5",
26
- "@object-ui/core": "5.0.1",
27
- "@object-ui/types": "5.0.1"
25
+ "@objectstack/client": "^4.2.0",
26
+ "@object-ui/core": "5.1.1",
27
+ "@object-ui/types": "5.1.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsup": "^8.5.1",
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,58 @@ 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(resource: string, id: string | number, data: Partial<T>): Promise<T> {
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
- const result = await this.client.data.update<T>(resource, String(id), data);
642
- return result.record;
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(resource: string, id: string | number): Promise<boolean> {
735
+ async delete(
736
+ resource: string,
737
+ id: string | number,
738
+ opts?: { ifMatch?: string },
739
+ ): Promise<boolean> {
649
740
  await this.connect();
650
- const result = await this.client.data.delete(resource, String(id));
651
- return result.deleted;
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
+ }
652
751
  }
653
752
 
654
753
  /**
@@ -1728,6 +1827,75 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1728
1827
  return response.json();
1729
1828
  }
1730
1829
 
1830
+ /**
1831
+ * Cancel (recall) the active pending approval request for a given record.
1832
+ *
1833
+ * Looks up the most recent `sys_approval_request` for the (object, record)
1834
+ * pair whose status is `pending` or `in_approval`, then issues a POST to
1835
+ * `/api/v1/approvals/requests/:id/recall`. The submitter is the only role
1836
+ * permitted to recall on the server — non-submitters will receive a 403.
1837
+ *
1838
+ * On success, the backend mirrors `approval_status = 'recalled'` onto the
1839
+ * source record so the lock badge disappears on next fetch.
1840
+ */
1841
+ async cancelPendingApproval(
1842
+ objectName: string,
1843
+ recordId: string,
1844
+ ): Promise<{ requestId: string; status: string }> {
1845
+ await this.connect();
1846
+
1847
+ // Use the approvals service REST endpoint directly. The generic
1848
+ // `/api/v1/data/sys_approval_request` route applies record-sharing
1849
+ // ACLs that the approvals collection isn't always registered for,
1850
+ // so prefer the cross-cutting `/approvals/requests` endpoint which
1851
+ // is owned by the approvals service itself.
1852
+ const listUrl = `${this.baseUrl}/api/v1/approvals/requests?recordId=${encodeURIComponent(recordId)}&object=${encodeURIComponent(objectName)}`;
1853
+ const listRes = await this.fetchImpl(listUrl, {
1854
+ method: 'GET',
1855
+ headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
1856
+ });
1857
+ if (!listRes.ok) {
1858
+ throw new ObjectStackError(
1859
+ `Failed to look up approval requests (status ${listRes.status})`,
1860
+ 'APPROVAL_LOOKUP_FAILED',
1861
+ listRes.status,
1862
+ );
1863
+ }
1864
+ const listBody: any = await listRes.json().catch(() => ({}));
1865
+ const rows: any[] = Array.isArray(listBody) ? listBody : (listBody?.data ?? []);
1866
+ const pending = rows.find(
1867
+ (r) => r?.status === 'pending' || r?.status === 'in_approval',
1868
+ );
1869
+ if (!pending?.id) {
1870
+ throw new ObjectStackError(
1871
+ 'No pending approval request found for this record',
1872
+ 'NO_PENDING_REQUEST',
1873
+ 404,
1874
+ );
1875
+ }
1876
+
1877
+ const url = `${this.baseUrl}/api/v1/approvals/requests/${encodeURIComponent(pending.id)}/recall`;
1878
+ const response = await this.fetchImpl(url, {
1879
+ method: 'POST',
1880
+ headers: {
1881
+ 'Content-Type': 'application/json',
1882
+ ...this.getAuthHeaders(),
1883
+ },
1884
+ body: JSON.stringify({}),
1885
+ });
1886
+
1887
+ if (!response.ok) {
1888
+ const err: any = await response.json().catch(() => ({}));
1889
+ throw new ObjectStackError(
1890
+ err?.error || err?.message || `Recall failed with status ${response.status}`,
1891
+ err?.code || 'APPROVAL_RECALL_FAILED',
1892
+ response.status,
1893
+ );
1894
+ }
1895
+ const body: any = await response.json().catch(() => ({}));
1896
+ return { requestId: pending.id, status: body?.data?.request?.status ?? 'recalled' };
1897
+ }
1898
+
1731
1899
  /**
1732
1900
  * Get authorization headers from the adapter config.
1733
1901
  */
@@ -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
+ });