@open-mercato/ui 0.6.6-develop.6330.1.a261878aa8 → 0.6.6-develop.6332.1.6e73f0f55b

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.
@@ -4,9 +4,12 @@ import * as React from 'react'
4
4
  import { fireEvent, screen, waitFor } from '@testing-library/react'
5
5
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
6
6
  import { NotesSection, type NotesDataAdapter } from '../detail/NotesSection'
7
+ import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
8
+ import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
7
9
 
8
10
  describe('NotesSection', () => {
9
11
  beforeEach(() => {
12
+ dismissRecordConflict()
10
13
  Object.defineProperty(Element.prototype, 'scrollIntoView', {
11
14
  configurable: true,
12
15
  writable: true,
@@ -22,6 +25,10 @@ describe('NotesSection', () => {
22
25
  })
23
26
  })
24
27
 
28
+ afterEach(() => {
29
+ dismissRecordConflict()
30
+ })
31
+
25
32
  it('keeps an add-note action visible after notes already exist', async () => {
26
33
  const dataAdapter: NotesDataAdapter = {
27
34
  list: jest.fn(async () => [
@@ -60,4 +67,68 @@ describe('NotesSection', () => {
60
67
  expect(container.querySelector('textarea')).not.toBeNull()
61
68
  })
62
69
  })
70
+
71
+ it('surfaces the unified conflict bar when a write fails with a 409', async () => {
72
+ const conflict = {
73
+ status: 409,
74
+ body: {
75
+ error: 'optimistic_lock_conflict',
76
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
77
+ currentUpdatedAt: '2026-06-02T00:00:00.000Z',
78
+ expectedUpdatedAt: '2026-06-01T00:00:00.000Z',
79
+ },
80
+ }
81
+ const dataAdapter: NotesDataAdapter = {
82
+ list: jest.fn(async () => [
83
+ {
84
+ id: 'note-1',
85
+ body: 'Existing note',
86
+ createdAt: '2026-04-10T08:00:00.000Z',
87
+ authorName: 'Ada Lovelace',
88
+ },
89
+ ]),
90
+ create: jest.fn(async () => {
91
+ throw conflict
92
+ }),
93
+ update: jest.fn(async () => undefined),
94
+ delete: jest.fn(async () => undefined),
95
+ }
96
+
97
+ const { container } = renderWithProviders(
98
+ <NotesSection
99
+ entityId="person-1"
100
+ emptyLabel="—"
101
+ viewerUserId="user-1"
102
+ viewerName="Ada Lovelace"
103
+ addActionLabel="Add note"
104
+ emptyState={{
105
+ title: 'No notes yet',
106
+ actionLabel: 'Add note',
107
+ }}
108
+ dataAdapter={dataAdapter}
109
+ disableMarkdown
110
+ />,
111
+ )
112
+
113
+ await screen.findByText('Existing note')
114
+ fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
115
+
116
+ const textarea = await waitFor(() => {
117
+ const el = container.querySelector('textarea')
118
+ if (!el) throw new Error('composer not open')
119
+ return el as HTMLTextAreaElement
120
+ })
121
+ fireEvent.change(textarea, { target: { value: 'Conflicting note' } })
122
+
123
+ const form = container.querySelector('form')
124
+ expect(form).not.toBeNull()
125
+ fireEvent.submit(form as HTMLFormElement)
126
+
127
+ await waitFor(() => {
128
+ expect(dataAdapter.create).toHaveBeenCalledTimes(1)
129
+ const entry = getRecordConflictForTest()
130
+ expect(entry).not.toBeNull()
131
+ expect(entry?.currentUpdatedAt).toBe('2026-06-02T00:00:00.000Z')
132
+ })
133
+ })
63
134
  })
@@ -0,0 +1,99 @@
1
+ /** @jest-environment jsdom */
2
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
3
+ import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
4
+ import {
5
+ dismissRecordConflict,
6
+ getRecordConflictForTest,
7
+ registerRecordLockConflictHandler,
8
+ resetRecordLockConflictHandlerForTest,
9
+ surfaceRecordConflict,
10
+ } from '..'
11
+
12
+ const t = (key: string, fallback?: string) => fallback ?? key
13
+
14
+ function recordLockConflict() {
15
+ return new CrudHttpError(409, {
16
+ error: 'Record conflict detected',
17
+ code: 'record_lock_conflict',
18
+ lock: null,
19
+ conflict: { id: 'c1' },
20
+ })
21
+ }
22
+
23
+ function ossConflict() {
24
+ return new CrudHttpError(409, {
25
+ error: 'record_modified',
26
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
27
+ currentUpdatedAt: '2026-06-01T00:00:01.000Z',
28
+ expectedUpdatedAt: '2026-06-01T00:00:00.000Z',
29
+ })
30
+ }
31
+
32
+ describe('surfaceRecordConflict — single surface (S3)', () => {
33
+ beforeEach(() => {
34
+ dismissRecordConflict()
35
+ resetRecordLockConflictHandlerForTest()
36
+ })
37
+ afterEach(() => {
38
+ dismissRecordConflict()
39
+ resetRecordLockConflictHandlerForTest()
40
+ })
41
+
42
+ it('defers a record_lock_conflict to the registered merge-dialog handler that owns the record (returns true → no OSS bar)', () => {
43
+ const handler = jest.fn(() => true)
44
+ registerRecordLockConflictHandler(handler)
45
+
46
+ const handled = surfaceRecordConflict(recordLockConflict(), t)
47
+
48
+ expect(handled).toBe(true)
49
+ expect(handler).toHaveBeenCalledTimes(1)
50
+ expect(handler.mock.calls[0][0]).toMatchObject({ code: 'record_lock_conflict' })
51
+ // The OSS conflict bar must NOT render when the dialog handles it.
52
+ expect(getRecordConflictForTest()).toBeNull()
53
+ })
54
+
55
+ it('SAFETY: handler registered but DECLINES the record (returns false) → OSS bar still renders (never swallowed)', () => {
56
+ const handler = jest.fn(() => false)
57
+ registerRecordLockConflictHandler(handler)
58
+
59
+ const handled = surfaceRecordConflict(recordLockConflict(), t)
60
+
61
+ expect(handled).toBe(true)
62
+ expect(handler).toHaveBeenCalledTimes(1)
63
+ expect(getRecordConflictForTest()).not.toBeNull()
64
+ })
65
+
66
+ it('SAFETY: with NO handler registered, a record_lock_conflict still renders the OSS bar (never swallowed)', () => {
67
+ const handled = surfaceRecordConflict(recordLockConflict(), t)
68
+ expect(handled).toBe(true)
69
+ expect(getRecordConflictForTest()).not.toBeNull()
70
+ })
71
+
72
+ it('renders the OSS conflict bar for an optimistic_lock_conflict regardless of handler registration', () => {
73
+ const handler = jest.fn(() => true)
74
+ registerRecordLockConflictHandler(handler)
75
+ const handled = surfaceRecordConflict(ossConflict(), t)
76
+ expect(handled).toBe(true)
77
+ // The record-lock handler must NOT be consulted for an OSS optimistic-lock conflict.
78
+ expect(handler).not.toHaveBeenCalled()
79
+ const entry = getRecordConflictForTest()
80
+ expect(entry).not.toBeNull()
81
+ expect(entry?.currentUpdatedAt).toBe('2026-06-01T00:00:01.000Z')
82
+ })
83
+
84
+ it('unregister restores the no-handler behavior', () => {
85
+ const handler = jest.fn(() => true)
86
+ const unregister = registerRecordLockConflictHandler(handler)
87
+ unregister()
88
+
89
+ surfaceRecordConflict(recordLockConflict(), t)
90
+ expect(handler).not.toHaveBeenCalled()
91
+ expect(getRecordConflictForTest()).not.toBeNull()
92
+ })
93
+
94
+ it('returns false for a non-conflict error', () => {
95
+ expect(surfaceRecordConflict(new CrudHttpError(422, { error: 'validation_failed' }), t)).toBe(false)
96
+ expect(surfaceRecordConflict(new Error('boom'), t)).toBe(false)
97
+ expect(getRecordConflictForTest()).toBeNull()
98
+ })
99
+ })
@@ -1,4 +1,8 @@
1
- import { extractOptimisticLockConflict } from '../utils/optimisticLock'
1
+ import {
2
+ extractOptimisticLockConflict,
3
+ extractRecordLockConflict,
4
+ type RecordLockConflictBody,
5
+ } from '../utils/optimisticLock'
2
6
  import { showRecordConflict } from './store'
3
7
 
4
8
  export {
@@ -21,29 +25,102 @@ export type SurfaceRecordConflictOptions = {
21
25
  }
22
26
 
23
27
  /**
24
- * If `error` is an OSS optimistic-lock conflict (HTTP 409 with
25
- * `code: 'optimistic_lock_conflict'`), push the localized "record modified"
26
- * message onto the shared conflict bar and return `true`. Otherwise return
27
- * `false` so callers can fall back to their normal error handling.
28
+ * Handler the enterprise `record_locks` merge-dialog widget registers (on mount)
29
+ * so `surfaceRecordConflict` can defer the OSS conflict bar in favor of the
30
+ * field-level merge dialog. Core/UI stays enterprise-free: enterprise calls
31
+ * {@link registerRecordLockConflictHandler}; this module never imports it.
28
32
  *
29
- * This is the single entry point every form uses, so the conflict surfaces the
30
- * same persistent, error-styled bar everywhere.
33
+ * MUST return `true` only when it actually owns/renders the surface for THIS
34
+ * conflict (its widget is mounted for the conflicting record). Returning `false`
35
+ * (different record / cannot render) makes `surfaceRecordConflict` fall through
36
+ * to the OSS conflict bar, so a conflict is never swallowed and we never render
37
+ * BOTH the merge dialog and the bar (single surface, S3).
38
+ */
39
+ export type RecordLockConflictHandler = (conflict: RecordLockConflictBody, error: unknown) => boolean
40
+
41
+ let recordLockConflictHandler: RecordLockConflictHandler | null = null
42
+
43
+ /**
44
+ * Register the record-lock conflict handler (enterprise merge-dialog widget).
45
+ * Returns an unregister function. When a handler is registered,
46
+ * `surfaceRecordConflict` defers `record_lock_conflict` 409s to it instead of
47
+ * rendering the OSS conflict bar (single surface, S3).
48
+ *
49
+ * SAFETY: when NO handler is registered (widget absent/removed),
50
+ * `surfaceRecordConflict` still renders the OSS conflict bar for ANY 409 — a
51
+ * conflict is never silently swallowed.
52
+ */
53
+ export function registerRecordLockConflictHandler(handler: RecordLockConflictHandler): () => void {
54
+ recordLockConflictHandler = handler
55
+ return () => {
56
+ if (recordLockConflictHandler === handler) recordLockConflictHandler = null
57
+ }
58
+ }
59
+
60
+ /** Test-only: inspect whether a record-lock conflict handler is registered. */
61
+ export function hasRecordLockConflictHandlerForTest(): boolean {
62
+ return recordLockConflictHandler !== null
63
+ }
64
+
65
+ /** Test-only: clear any registered record-lock conflict handler. */
66
+ export function resetRecordLockConflictHandlerForTest(): void {
67
+ recordLockConflictHandler = null
68
+ }
69
+
70
+ /**
71
+ * Single conflict surface (S3). Resolves a 409 to exactly one UI:
72
+ *
73
+ * - `record_lock_conflict` payload AND a merge-dialog handler registered →
74
+ * defer to the handler (no conflict bar) and return `true`.
75
+ * - otherwise (OSS `optimistic_lock_conflict`, OR a `record_lock_conflict`
76
+ * with NO handler registered) → render the OSS conflict bar and return
77
+ * `true`. A conflict is ALWAYS surfaced; the worst case is plainer UX, never
78
+ * a silently-swallowed conflict.
79
+ * - not a recognized conflict → return `false` so callers fall back to their
80
+ * normal error handling.
31
81
  */
32
82
  export function surfaceRecordConflict(
33
83
  error: unknown,
34
84
  t: Translate,
35
85
  options: SurfaceRecordConflictOptions = {},
36
86
  ): boolean {
37
- const conflict = extractOptimisticLockConflict(error)
38
- if (!conflict) return false
39
- showRecordConflict({
40
- message: t(
41
- 'ui.forms.flash.recordModified',
42
- 'This record was modified by someone else. Refresh and try again.',
43
- ),
44
- title: options.title ?? null,
45
- currentUpdatedAt: conflict.currentUpdatedAt,
46
- onRefresh: options.onRefresh ?? null,
47
- })
48
- return true
87
+ const recordLockConflict = extractRecordLockConflict(error)
88
+ if (recordLockConflict && recordLockConflictHandler) {
89
+ // The widget owns the surface ONLY when it handles this record's conflict
90
+ // (returns true). If it declines (different record / not mounted for it),
91
+ // fall through to the OSS bar so the conflict is never swallowed and we
92
+ // never render both the merge dialog AND the bar.
93
+ if (recordLockConflictHandler(recordLockConflict, error)) return true
94
+ }
95
+
96
+ const ossConflict = extractOptimisticLockConflict(error)
97
+ if (ossConflict) {
98
+ showRecordConflict({
99
+ message: t(
100
+ 'ui.forms.flash.recordModified',
101
+ 'This record was modified by someone else. Refresh and try again.',
102
+ ),
103
+ title: options.title ?? null,
104
+ currentUpdatedAt: ossConflict.currentUpdatedAt,
105
+ onRefresh: options.onRefresh ?? null,
106
+ })
107
+ return true
108
+ }
109
+
110
+ // No handler is registered but the server returned a record-lock 409: still
111
+ // render the OSS bar so the conflict is never swallowed.
112
+ if (recordLockConflict) {
113
+ showRecordConflict({
114
+ message: t(
115
+ 'ui.forms.flash.recordModified',
116
+ 'This record was modified by someone else. Refresh and try again.',
117
+ ),
118
+ title: options.title ?? null,
119
+ currentUpdatedAt: null,
120
+ onRefresh: options.onRefresh ?? null,
121
+ })
122
+ return true
123
+ }
124
+
125
+ return false
49
126
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
5
+ import { surfaceRecordConflict } from '../conflicts'
5
6
  import { LoadingMessage } from '@open-mercato/ui/backend/detail'
6
7
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
7
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -40,13 +41,14 @@ export type AddressSummary = {
40
41
  postalCode?: string | null
41
42
  country?: string | null
42
43
  isPrimary?: boolean
44
+ updatedAt?: string | null
43
45
  }
44
46
 
45
47
  export type AddressDataAdapter<C = unknown> = {
46
48
  list: (params: { entityId: string | null; context?: C }) => Promise<AddressSummary[]>
47
49
  create: (params: { entityId: string; payload: AddressInput; context?: C }) => Promise<{ id?: string } | void>
48
- update: (params: { id: string; payload: AddressInput; context?: C }) => Promise<void>
49
- delete: (params: { id: string; context?: C }) => Promise<void>
50
+ update: (params: { id: string; payload: AddressInput; updatedAt?: string | null; context?: C }) => Promise<void>
51
+ delete: (params: { id: string; updatedAt?: string | null; context?: C }) => Promise<void>
50
52
  }
51
53
 
52
54
  export type AddressesSectionProps<C = unknown> = {
@@ -205,7 +207,8 @@ function AddressesSectionImpl<C = unknown>({
205
207
  pushLoading()
206
208
  setIsSubmitting(true)
207
209
  try {
208
- await dataAdapter.update({ id, payload, context: dataContext })
210
+ const addressUpdatedAt = addresses.find((address) => address.id === id)?.updatedAt ?? null
211
+ await dataAdapter.update({ id, payload, updatedAt: addressUpdatedAt, context: dataContext })
209
212
  setAddresses((prev) => {
210
213
  return prev.map((address) => {
211
214
  if (address.id !== id) {
@@ -230,6 +233,7 @@ function AddressesSectionImpl<C = unknown>({
230
233
  })
231
234
  flash(label('success', 'Address saved.'), 'success')
232
235
  } catch (err) {
236
+ surfaceRecordConflict(err, t)
233
237
  const message =
234
238
  err instanceof Error && err.message ? err.message : label('error', 'Failed to save address.')
235
239
  const error = new Error(message) as Error & { details?: unknown }
@@ -242,7 +246,7 @@ function AddressesSectionImpl<C = unknown>({
242
246
  popLoading()
243
247
  }
244
248
  },
245
- [dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading],
249
+ [addresses, dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading, t],
246
250
  )
247
251
 
248
252
  const handleDelete = React.useCallback(
@@ -253,20 +257,21 @@ function AddressesSectionImpl<C = unknown>({
253
257
  pushLoading()
254
258
  setIsSubmitting(true)
255
259
  try {
256
- await dataAdapter.delete({ id, context: dataContext })
260
+ const addressUpdatedAt = addresses.find((address) => address.id === id)?.updatedAt ?? null
261
+ await dataAdapter.delete({ id, updatedAt: addressUpdatedAt, context: dataContext })
257
262
  setAddresses((prev) => prev.filter((address) => address.id !== id))
258
263
  flash(label('deleted', 'Address deleted.'), 'success')
259
264
  } catch (err) {
260
265
  const message =
261
266
  err instanceof Error && err.message ? err.message : label('error', 'Failed to delete address.')
262
- flash(message, 'error')
267
+ if (!surfaceRecordConflict(err, t)) flash(message, 'error')
263
268
  throw err
264
269
  } finally {
265
270
  setIsSubmitting(false)
266
271
  popLoading()
267
272
  }
268
273
  },
269
- [dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading],
274
+ [addresses, dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading, t],
270
275
  )
271
276
 
272
277
  const displayAddresses = React.useMemo<AddressValue[]>(() => {
@@ -16,6 +16,7 @@ import {
16
16
  SelectValue,
17
17
  } from '@open-mercato/ui/primitives/select'
18
18
  import { flash } from '../FlashMessages'
19
+ import { surfaceRecordConflict } from '../conflicts'
19
20
  import { SwitchableMarkdownInput } from '../inputs/SwitchableMarkdownInput'
20
21
  import { ErrorMessage } from './ErrorMessage'
21
22
  import { LoadingMessage } from './LoadingMessage'
@@ -48,6 +49,7 @@ export type CommentSummary = {
48
49
  id: string
49
50
  body: string
50
51
  createdAt: string
52
+ updatedAt?: string | null
51
53
  authorUserId?: string | null
52
54
  authorName?: string | null
53
55
  authorEmail?: string | null
@@ -87,8 +89,8 @@ export type NotesDataAdapter<C = unknown> = {
87
89
  totalPages: number
88
90
  }>
89
91
  create: (params: NotesCreatePayload & { context?: C }) => Promise<Partial<CommentSummary> | void>
90
- update: (params: { id: string; patch: NotesUpdatePayload; context?: C }) => Promise<void>
91
- delete: (params: { id: string; context?: C }) => Promise<void>
92
+ update: (params: { id: string; patch: NotesUpdatePayload; updatedAt?: string | null; context?: C }) => Promise<void>
93
+ delete: (params: { id: string; updatedAt?: string | null; context?: C }) => Promise<void>
92
94
  }
93
95
 
94
96
  type RenderIconFn = (icon: string, className?: string) => React.ReactNode
@@ -221,6 +223,12 @@ export function mapCommentSummary(input: unknown): CommentSummary {
221
223
  : typeof data.created_at === 'string'
222
224
  ? data.created_at
223
225
  : new Date().toISOString()
226
+ const updatedAt =
227
+ typeof data.updatedAt === 'string'
228
+ ? data.updatedAt
229
+ : typeof data.updated_at === 'string'
230
+ ? data.updated_at
231
+ : null
224
232
  const authorUserId =
225
233
  typeof data.authorUserId === 'string'
226
234
  ? data.authorUserId
@@ -267,6 +275,7 @@ export function mapCommentSummary(input: unknown): CommentSummary {
267
275
  id,
268
276
  body,
269
277
  createdAt,
278
+ updatedAt,
270
279
  authorUserId,
271
280
  authorName,
272
281
  authorEmail,
@@ -678,7 +687,7 @@ function NotesSectionImpl<C = unknown>({
678
687
  return true
679
688
  } catch (err) {
680
689
  const message = err instanceof Error ? err.message : label('error')
681
- flash(message, 'error')
690
+ if (!surfaceRecordConflict(err, t)) flash(message, 'error')
682
691
  return false
683
692
  } finally {
684
693
  setIsSubmitting(false)
@@ -699,6 +708,7 @@ function NotesSectionImpl<C = unknown>({
699
708
  : undefined
700
709
  const sanitizedColor =
701
710
  patch.appearanceColor !== undefined ? sanitizeHexColor(patch.appearanceColor ?? null) : undefined
711
+ const noteUpdatedAt = notes.find((comment) => comment.id === noteId)?.updatedAt ?? null
702
712
  try {
703
713
  await dataAdapter.update({
704
714
  id: noteId,
@@ -707,6 +717,7 @@ function NotesSectionImpl<C = unknown>({
707
717
  appearanceIcon: sanitizedIcon,
708
718
  appearanceColor: sanitizedColor,
709
719
  },
720
+ updatedAt: noteUpdatedAt,
710
721
  context: dataContext,
711
722
  })
712
723
  setNotes((prev) => {
@@ -723,11 +734,11 @@ function NotesSectionImpl<C = unknown>({
723
734
  flash(label('updateSuccess'), 'success')
724
735
  } catch (error) {
725
736
  const message = error instanceof Error ? error.message : label('updateError')
726
- flash(message, 'error')
737
+ if (!surfaceRecordConflict(error, t)) flash(message, 'error')
727
738
  throw error instanceof Error ? error : new Error(message)
728
739
  }
729
740
  },
730
- [dataAdapter, dataContext, t],
741
+ [dataAdapter, dataContext, notes, t],
731
742
  )
732
743
 
733
744
  const handleDeleteNote = React.useCallback(
@@ -740,7 +751,7 @@ function NotesSectionImpl<C = unknown>({
740
751
  setDeletingNoteId(note.id)
741
752
  pushLoading()
742
753
  try {
743
- await dataAdapter.delete({ id: note.id, context: dataContext })
754
+ await dataAdapter.delete({ id: note.id, updatedAt: note.updatedAt ?? null, context: dataContext })
744
755
  setNotes((prev) => prev.filter((existing) => existing.id !== note.id))
745
756
  if (pagedMode) {
746
757
  setVisibleCount((prev) => Math.max(0, prev - 1))
@@ -748,13 +759,13 @@ function NotesSectionImpl<C = unknown>({
748
759
  flash(label('deleteSuccess', 'Note deleted'), 'success')
749
760
  } catch (err) {
750
761
  const message = err instanceof Error ? err.message : label('deleteError', 'Failed to delete note')
751
- flash(message, 'error')
762
+ if (!surfaceRecordConflict(err, t)) flash(message, 'error')
752
763
  } finally {
753
764
  setDeletingNoteId(null)
754
765
  popLoading()
755
766
  }
756
767
  },
757
- [confirm, dataAdapter, dataContext, label, popLoading, pushLoading],
768
+ [confirm, dataAdapter, dataContext, label, popLoading, pushLoading, t],
758
769
  )
759
770
 
760
771
  const handleSubmit = React.useCallback(
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+
4
+ /**
5
+ * AppShell-owned transport for the current detail record's injection context
6
+ * (Phase 0 / S2). The `backend:record:current` injection spot is mounted once,
7
+ * globally, by `AppShell` — before page `children` — so a page cannot pass props
8
+ * "up" to it. Instead, a detail page publishes its record context through this
9
+ * provider; `AppShell` merges it into the spot's base `{ path, query }` context.
10
+ *
11
+ * Keeps `packages/ui`/core enterprise-free: this is a plain context object, no
12
+ * record_locks dependency, no second `<InjectionSpot>`.
13
+ */
14
+ export type RecordInjectionContext = {
15
+ /** record_locks resource key, e.g. `customers.person`, `sales.order`. */
16
+ resourceKind: string
17
+ /** The record's id. */
18
+ resourceId: string
19
+ /** Server `updated_at` (ISO) for the optimistic-lock floor + action-log base. */
20
+ updatedAt?: string | null
21
+ /** Optional record payload for field-level merge UX. */
22
+ data?: Record<string, unknown> | null
23
+ /** The detail screen path this context belongs to (used for stale-context guarding). */
24
+ path?: string | null
25
+ }
26
+
27
+ /**
28
+ * Build a normalized record injection context. Returns `null` when the required
29
+ * keys are missing so callers can publish unconditionally.
30
+ */
31
+ export function buildRecordInjectionContext(input: {
32
+ resourceKind: string | null | undefined
33
+ resourceId: string | null | undefined
34
+ updatedAt?: string | Date | null
35
+ data?: Record<string, unknown> | null
36
+ path?: string | null
37
+ }): RecordInjectionContext | null {
38
+ const resourceKind = typeof input.resourceKind === 'string' ? input.resourceKind.trim() : ''
39
+ const resourceId = typeof input.resourceId === 'string' ? input.resourceId.trim() : ''
40
+ if (!resourceKind || !resourceId) return null
41
+ const updatedAt = input.updatedAt instanceof Date
42
+ ? input.updatedAt.toISOString()
43
+ : (typeof input.updatedAt === 'string' && input.updatedAt.trim().length ? input.updatedAt.trim() : null)
44
+ return {
45
+ resourceKind,
46
+ resourceId,
47
+ updatedAt,
48
+ data: input.data ?? null,
49
+ path: typeof input.path === 'string' && input.path.length ? input.path : null,
50
+ }
51
+ }
52
+
53
+ type RecordInjectionContextSetter = (context: RecordInjectionContext | null) => void
54
+
55
+ const BackendRecordInjectionContextSetterContext = React.createContext<RecordInjectionContextSetter | null>(null)
56
+
57
+ /**
58
+ * AppShell renders this provider around page `children`, supplying the setter
59
+ * that publishes record context into AppShell-owned state. Detail pages call
60
+ * `useSetCurrentRecordInjectionContext()` to publish/clear.
61
+ */
62
+ export function BackendRecordInjectionContextProvider({
63
+ setCurrentRecordInjectionContext,
64
+ children,
65
+ }: {
66
+ setCurrentRecordInjectionContext: RecordInjectionContextSetter
67
+ children: React.ReactNode
68
+ }) {
69
+ return (
70
+ <BackendRecordInjectionContextSetterContext.Provider value={setCurrentRecordInjectionContext}>
71
+ {children}
72
+ </BackendRecordInjectionContextSetterContext.Provider>
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Publish the current detail record's injection context to the AppShell-owned
78
+ * `backend:record:current` mount, and clear it automatically on unmount /
79
+ * dependency change. Pass `null` to clear (e.g. while the record is loading).
80
+ *
81
+ * ```tsx
82
+ * useSetCurrentRecordInjectionContext(
83
+ * buildRecordInjectionContext({ resourceKind: 'customers.person', resourceId: id, updatedAt, data, path }),
84
+ * )
85
+ * ```
86
+ */
87
+ export function useSetCurrentRecordInjectionContext(context: RecordInjectionContext | null): void {
88
+ const setter = React.useContext(BackendRecordInjectionContextSetterContext)
89
+ // Serialize so we only re-publish when the meaningful fields change.
90
+ const serialized = context
91
+ ? JSON.stringify({ k: context.resourceKind, i: context.resourceId, u: context.updatedAt ?? null, p: context.path ?? null })
92
+ : null
93
+ React.useEffect(() => {
94
+ if (!setter) return
95
+ setter(context)
96
+ return () => {
97
+ setter(null)
98
+ }
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ }, [setter, serialized])
101
+ }
@@ -68,3 +68,39 @@ export function extractOptimisticLockConflict(
68
68
  expectedUpdatedAt,
69
69
  }
70
70
  }
71
+
72
+ /**
73
+ * The enterprise `record_locks` 409 conflict code. Kept as a literal here so
74
+ * core/UI stays enterprise-free (no import from `@open-mercato/enterprise`).
75
+ */
76
+ export const RECORD_LOCK_CONFLICT_CODE = 'record_lock_conflict' as const
77
+
78
+ export type RecordLockConflictBody = {
79
+ code: typeof RECORD_LOCK_CONFLICT_CODE
80
+ error?: string
81
+ lock?: unknown
82
+ conflict?: unknown
83
+ }
84
+
85
+ /**
86
+ * Detect whether an error is an enterprise `record_locks` conflict (HTTP 409
87
+ * with `code: 'record_lock_conflict'`). Returns the conflict body when matched
88
+ * so `surfaceRecordConflict` can defer to the merge-dialog widget, or `null`.
89
+ */
90
+ export function extractRecordLockConflict(err: unknown): RecordLockConflictBody | null {
91
+ if (!err || typeof err !== 'object') return null
92
+ const candidate = err as Record<string, unknown>
93
+ if (candidate.status !== 409) return null
94
+ const body = candidate.body && typeof candidate.body === 'object'
95
+ ? candidate.body
96
+ : candidate
97
+ if (!body || typeof body !== 'object') return null
98
+ const bodyRecord = body as Record<string, unknown>
99
+ if (bodyRecord.code !== RECORD_LOCK_CONFLICT_CODE) return null
100
+ return {
101
+ code: RECORD_LOCK_CONFLICT_CODE,
102
+ error: typeof bodyRecord.error === 'string' ? bodyRecord.error : undefined,
103
+ lock: bodyRecord.lock ?? null,
104
+ conflict: bodyRecord.conflict ?? null,
105
+ }
106
+ }