@open-mercato/ui 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +57 -11
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +20 -8
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/package.json +7 -3
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +99 -9
- package/src/backend/DataTable.tsx +22 -8
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '../optimisticLock'
|
|
2
|
+
import {
|
|
3
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
4
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
5
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
6
|
+
|
|
7
|
+
describe('buildOptimisticLockHeader', () => {
|
|
8
|
+
it('returns the extension header when updatedAt is a non-empty string', () => {
|
|
9
|
+
expect(buildOptimisticLockHeader('2026-05-25T08:42:18.123Z')).toEqual({
|
|
10
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: '2026-05-25T08:42:18.123Z',
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('trims surrounding whitespace', () => {
|
|
15
|
+
expect(buildOptimisticLockHeader(' 2026-05-25T08:42:18.123Z ')).toEqual({
|
|
16
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: '2026-05-25T08:42:18.123Z',
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns an empty object for null / undefined / empty / whitespace input', () => {
|
|
21
|
+
expect(buildOptimisticLockHeader(null)).toEqual({})
|
|
22
|
+
expect(buildOptimisticLockHeader(undefined)).toEqual({})
|
|
23
|
+
expect(buildOptimisticLockHeader('')).toEqual({})
|
|
24
|
+
expect(buildOptimisticLockHeader(' ')).toEqual({})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns an empty object for non-string input (defensive)', () => {
|
|
28
|
+
// @ts-expect-error intentional misuse
|
|
29
|
+
expect(buildOptimisticLockHeader(12345)).toEqual({})
|
|
30
|
+
// @ts-expect-error intentional misuse
|
|
31
|
+
expect(buildOptimisticLockHeader({ updatedAt: '2026' })).toEqual({})
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('extractOptimisticLockConflict', () => {
|
|
36
|
+
it('returns the conflict body when the error has status 409 + optimistic-lock code + both timestamps', () => {
|
|
37
|
+
const err = {
|
|
38
|
+
status: 409,
|
|
39
|
+
body: {
|
|
40
|
+
error: 'record_modified',
|
|
41
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
42
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
43
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
expect(extractOptimisticLockConflict(err)).toEqual({
|
|
47
|
+
error: 'record_modified',
|
|
48
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
49
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
50
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns the conflict body when raiseCrudError attached the response fields directly to the error', () => {
|
|
55
|
+
const err = {
|
|
56
|
+
status: 409,
|
|
57
|
+
error: 'record_modified',
|
|
58
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
59
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
60
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
61
|
+
}
|
|
62
|
+
expect(extractOptimisticLockConflict(err)).toEqual({
|
|
63
|
+
error: 'record_modified',
|
|
64
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
65
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
66
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns null for non-409 errors', () => {
|
|
71
|
+
expect(
|
|
72
|
+
extractOptimisticLockConflict({
|
|
73
|
+
status: 422,
|
|
74
|
+
body: { error: 'validation_failed', code: OPTIMISTIC_LOCK_CONFLICT_CODE },
|
|
75
|
+
}),
|
|
76
|
+
).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns null for 409 errors that are not optimistic-lock conflicts', () => {
|
|
80
|
+
expect(
|
|
81
|
+
extractOptimisticLockConflict({
|
|
82
|
+
status: 409,
|
|
83
|
+
body: { error: 'duplicate', code: 'duplicate_resource' },
|
|
84
|
+
}),
|
|
85
|
+
).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns null when the conflict body is missing required timestamp fields', () => {
|
|
89
|
+
expect(
|
|
90
|
+
extractOptimisticLockConflict({
|
|
91
|
+
status: 409,
|
|
92
|
+
body: { error: 'record_modified', code: OPTIMISTIC_LOCK_CONFLICT_CODE },
|
|
93
|
+
}),
|
|
94
|
+
).toBeNull()
|
|
95
|
+
expect(
|
|
96
|
+
extractOptimisticLockConflict({
|
|
97
|
+
status: 409,
|
|
98
|
+
body: {
|
|
99
|
+
error: 'record_modified',
|
|
100
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
101
|
+
currentUpdatedAt: 123,
|
|
102
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns null for non-object inputs', () => {
|
|
109
|
+
expect(extractOptimisticLockConflict(null)).toBeNull()
|
|
110
|
+
expect(extractOptimisticLockConflict(undefined)).toBeNull()
|
|
111
|
+
expect(extractOptimisticLockConflict('boom')).toBeNull()
|
|
112
|
+
expect(extractOptimisticLockConflict(409)).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('falls back to the canonical error token when body.error is missing', () => {
|
|
116
|
+
const result = extractOptimisticLockConflict({
|
|
117
|
+
status: 409,
|
|
118
|
+
body: {
|
|
119
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
120
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
121
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
expect(result?.error).toBe('record_modified')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side helpers for the OSS opt-in optimistic-locking guard
|
|
3
|
+
* (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
|
|
4
|
+
*
|
|
5
|
+
* These are deliberately small and dependency-light so they can be wired
|
|
6
|
+
* into any backend page without touching the shared CrudForm / useGuardedMutation
|
|
7
|
+
* components. A future PR may pull them into CrudForm directly once the
|
|
8
|
+
* reference rollout is broader.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
12
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
13
|
+
type OptimisticLockConflictBody,
|
|
14
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the extension-header bag for an `apiCall` request. Pass into
|
|
18
|
+
* `withScopedApiRequestHeaders(buildOptimisticLockHeader(updatedAt), ...)`
|
|
19
|
+
* when issuing a `PUT`/`PATCH`/`DELETE` for a record that exposes
|
|
20
|
+
* `updatedAt`. Returns an empty object when the input is missing/empty so
|
|
21
|
+
* the call site stays unconditional:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* await withScopedApiRequestHeaders(
|
|
25
|
+
* buildOptimisticLockHeader(record.updatedAt),
|
|
26
|
+
* () => updateCrud('customers/companies', id, { ... }),
|
|
27
|
+
* )
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function buildOptimisticLockHeader(
|
|
31
|
+
updatedAt: string | null | undefined,
|
|
32
|
+
): Record<string, string> {
|
|
33
|
+
if (typeof updatedAt !== 'string') return {}
|
|
34
|
+
const trimmed = updatedAt.trim()
|
|
35
|
+
if (!trimmed) return {}
|
|
36
|
+
return { [OPTIMISTIC_LOCK_HEADER_NAME]: trimmed }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detect whether an error thrown by `raiseCrudError`/`apiCallOrThrow` is an
|
|
41
|
+
* optimistic-lock conflict (HTTP 409 with `code: 'optimistic_lock_conflict'`).
|
|
42
|
+
*
|
|
43
|
+
* Returns the typed conflict body when matched so the caller can show a
|
|
44
|
+
* UI that includes the server's `currentUpdatedAt`, or `null` otherwise.
|
|
45
|
+
*/
|
|
46
|
+
export function extractOptimisticLockConflict(
|
|
47
|
+
err: unknown,
|
|
48
|
+
): OptimisticLockConflictBody | null {
|
|
49
|
+
if (!err || typeof err !== 'object') return null
|
|
50
|
+
const candidate = err as Record<string, unknown>
|
|
51
|
+
const status = candidate.status
|
|
52
|
+
if (status !== 409) return null
|
|
53
|
+
const body = candidate.body && typeof candidate.body === 'object'
|
|
54
|
+
? candidate.body
|
|
55
|
+
: candidate
|
|
56
|
+
if (!body || typeof body !== 'object') return null
|
|
57
|
+
const bodyRecord = body as Record<string, unknown>
|
|
58
|
+
if (bodyRecord.code !== OPTIMISTIC_LOCK_CONFLICT_CODE) return null
|
|
59
|
+
const currentUpdatedAt = bodyRecord.currentUpdatedAt
|
|
60
|
+
const expectedUpdatedAt = bodyRecord.expectedUpdatedAt
|
|
61
|
+
if (typeof currentUpdatedAt !== 'string' || typeof expectedUpdatedAt !== 'string') return null
|
|
62
|
+
return {
|
|
63
|
+
error: typeof bodyRecord.error === 'string'
|
|
64
|
+
? (bodyRecord.error as OptimisticLockConflictBody['error'])
|
|
65
|
+
: 'record_modified',
|
|
66
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
67
|
+
currentUpdatedAt,
|
|
68
|
+
expectedUpdatedAt,
|
|
69
|
+
}
|
|
70
|
+
}
|