@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +57 -11
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +20 -8
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/PerspectiveSidebar.js +8 -1
  10. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  11. package/dist/backend/RowActions.js +1 -1
  12. package/dist/backend/RowActions.js.map +2 -2
  13. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  14. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  15. package/dist/backend/conflicts/index.js +32 -0
  16. package/dist/backend/conflicts/index.js.map +7 -0
  17. package/dist/backend/conflicts/store.js +50 -0
  18. package/dist/backend/conflicts/store.js.map +7 -0
  19. package/dist/backend/injection/useGuardedMutation.js +8 -1
  20. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  21. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  23. package/dist/backend/utils/optimisticLock.js +34 -0
  24. package/dist/backend/utils/optimisticLock.js.map +7 -0
  25. package/package.json +7 -3
  26. package/src/backend/AppShell.tsx +10 -0
  27. package/src/backend/CrudForm.tsx +99 -9
  28. package/src/backend/DataTable.tsx +22 -8
  29. package/src/backend/PerspectiveSidebar.tsx +8 -1
  30. package/src/backend/RowActions.tsx +1 -1
  31. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
  32. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  33. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  34. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  35. package/src/backend/conflicts/index.ts +49 -0
  36. package/src/backend/conflicts/store.ts +94 -0
  37. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  38. package/src/backend/injection/useGuardedMutation.ts +16 -1
  39. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  40. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  41. 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
+ }