@byline/host-tanstack-start 4.19.0 → 5.0.0

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 (142) hide show
  1. package/dist/admin-shell/collections/edit.d.ts +1 -1
  2. package/dist/admin-shell/collections/edit.js +151 -31
  3. package/dist/admin-shell/collections/history.js +4 -2
  4. package/dist/admin-shell/collections/list.js +30 -6
  5. package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
  6. package/dist/admin-shell/collections/restore-version-modal.js +9 -4
  7. package/dist/admin-shell/collections/tree-list.js +16 -6
  8. package/dist/admin-shell/collections/version-history.d.ts +2 -1
  9. package/dist/admin-shell/collections/version-history.js +11 -2
  10. package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
  11. package/dist/admin-shell/document-mutation-notice.js +53 -0
  12. package/dist/admin-shell/document-mutation-state.d.ts +22 -0
  13. package/dist/admin-shell/document-mutation-state.js +71 -0
  14. package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
  15. package/dist/admin-shell/scheduled-publications/list.js +15 -5
  16. package/dist/admin-shell/singletons/history.js +4 -2
  17. package/dist/admin-shell/singletons/view.d.ts +1 -1
  18. package/dist/admin-shell/singletons/view.js +98 -19
  19. package/dist/integrations/byline-field-services.js +2 -9
  20. package/dist/routes/create-collection-edit-route.js +1 -1
  21. package/dist/routes/create-collection-list-route.d.ts +0 -7
  22. package/dist/routes/create-collection-list-route.js +7 -9
  23. package/dist/routes/create-singleton-route.d.ts +2 -0
  24. package/dist/routes/create-singleton-route.js +4 -3
  25. package/dist/server-fns/collections/copy-to-locale.d.ts +2 -7
  26. package/dist/server-fns/collections/copy-to-locale.js +4 -2
  27. package/dist/server-fns/collections/create.d.ts +1 -7
  28. package/dist/server-fns/collections/create.js +5 -3
  29. package/dist/server-fns/collections/delete-locale.d.ts +2 -7
  30. package/dist/server-fns/collections/delete-locale.js +4 -2
  31. package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
  32. package/dist/server-fns/collections/delete-outcome.js +4 -0
  33. package/dist/server-fns/collections/delete.d.ts +2 -7
  34. package/dist/server-fns/collections/delete.js +5 -3
  35. package/dist/server-fns/collections/duplicate.d.ts +2 -7
  36. package/dist/server-fns/collections/duplicate.js +5 -3
  37. package/dist/server-fns/collections/get.d.ts +1 -7
  38. package/dist/server-fns/collections/get.js +5 -4
  39. package/dist/server-fns/collections/list.d.ts +0 -7
  40. package/dist/server-fns/collections/list.js +11 -3
  41. package/dist/server-fns/collections/reorder.d.ts +7 -8
  42. package/dist/server-fns/collections/reorder.js +20 -76
  43. package/dist/server-fns/collections/restore-version.d.ts +3 -7
  44. package/dist/server-fns/collections/restore-version.js +5 -2
  45. package/dist/server-fns/collections/save-outcome.d.ts +1 -0
  46. package/dist/server-fns/collections/save-outcome.js +1 -0
  47. package/dist/server-fns/collections/scheduled-publication.d.ts +49 -9
  48. package/dist/server-fns/collections/scheduled-publication.js +31 -21
  49. package/dist/server-fns/collections/status.d.ts +8 -7
  50. package/dist/server-fns/collections/status.js +13 -5
  51. package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
  52. package/dist/server-fns/collections/tree-mutation.js +17 -5
  53. package/dist/server-fns/collections/tree.d.ts +14 -8
  54. package/dist/server-fns/collections/tree.js +21 -17
  55. package/dist/server-fns/collections/update.d.ts +12 -11
  56. package/dist/server-fns/collections/update.js +34 -20
  57. package/dist/server-fns/collections/upload.d.ts +1 -0
  58. package/dist/server-fns/collections/upload.js +3 -2
  59. package/dist/server-fns/document-mutation-errors.d.ts +5 -0
  60. package/dist/server-fns/document-mutation-errors.js +50 -0
  61. package/dist/server-fns/editable-revision.d.ts +9 -0
  62. package/dist/server-fns/editable-revision.js +11 -0
  63. package/dist/server-fns/singleton-document-read.d.ts +1 -1
  64. package/dist/server-fns/singleton-document-read.js +7 -3
  65. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +6 -8
  66. package/dist/server-fns/singletons/cancel-scheduled-publish.js +4 -1
  67. package/dist/server-fns/singletons/change-status.d.ts +2 -7
  68. package/dist/server-fns/singletons/change-status.js +4 -1
  69. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +5 -8
  70. package/dist/server-fns/singletons/confirm-scheduled-publish.js +4 -2
  71. package/dist/server-fns/singletons/copy-to-locale.d.ts +2 -7
  72. package/dist/server-fns/singletons/copy-to-locale.js +4 -2
  73. package/dist/server-fns/singletons/get.d.ts +4 -4
  74. package/dist/server-fns/singletons/restore-version.d.ts +2 -7
  75. package/dist/server-fns/singletons/restore-version.js +6 -3
  76. package/dist/server-fns/singletons/schedule-publish.d.ts +5 -8
  77. package/dist/server-fns/singletons/schedule-publish.js +4 -2
  78. package/dist/server-fns/singletons/unpublish.d.ts +2 -7
  79. package/dist/server-fns/singletons/unpublish.js +4 -1
  80. package/dist/server-fns/singletons/update.d.ts +4 -11
  81. package/dist/server-fns/singletons/update.js +5 -4
  82. package/dist/server-fns/structural-receipt.d.ts +4 -0
  83. package/dist/server-fns/structural-receipt.js +31 -0
  84. package/package.json +11 -11
  85. package/src/admin-shell/collections/edit.tsx +173 -54
  86. package/src/admin-shell/collections/history.tsx +11 -2
  87. package/src/admin-shell/collections/list.tsx +22 -6
  88. package/src/admin-shell/collections/restore-version-modal.tsx +12 -3
  89. package/src/admin-shell/collections/save-navigation.test.tsx +156 -8
  90. package/src/admin-shell/collections/tree-list.tsx +16 -6
  91. package/src/admin-shell/collections/version-history.tsx +13 -3
  92. package/src/admin-shell/document-mutation-notice.tsx +45 -0
  93. package/src/admin-shell/document-mutation-state.test.tsx +146 -0
  94. package/src/admin-shell/document-mutation-state.ts +95 -0
  95. package/src/admin-shell/scheduled-publications/list.tsx +16 -6
  96. package/src/admin-shell/singletons/history.test.tsx +3 -1
  97. package/src/admin-shell/singletons/history.tsx +10 -2
  98. package/src/admin-shell/singletons/view.test.tsx +59 -18
  99. package/src/admin-shell/singletons/view.tsx +110 -29
  100. package/src/integrations/byline-field-services.ts +2 -3
  101. package/src/routes/create-collection-edit-route.tsx +1 -0
  102. package/src/routes/create-collection-list-route.tsx +13 -2
  103. package/src/routes/create-singleton-route.test.node.ts +10 -2
  104. package/src/routes/create-singleton-route.tsx +6 -4
  105. package/src/server-fns/collections/copy-to-locale.ts +41 -36
  106. package/src/server-fns/collections/create.ts +54 -50
  107. package/src/server-fns/collections/delete-locale.ts +41 -32
  108. package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
  109. package/src/server-fns/collections/delete-outcome.ts +27 -16
  110. package/src/server-fns/collections/delete.ts +44 -38
  111. package/src/server-fns/collections/duplicate.ts +38 -32
  112. package/src/server-fns/collections/get.ts +8 -4
  113. package/src/server-fns/collections/list.ts +8 -3
  114. package/src/server-fns/collections/reorder.ts +32 -124
  115. package/src/server-fns/collections/restore-version.ts +47 -39
  116. package/src/server-fns/collections/revision-input.test.node.ts +108 -0
  117. package/src/server-fns/collections/save-outcome.test.node.ts +3 -0
  118. package/src/server-fns/collections/save-outcome.ts +2 -0
  119. package/src/server-fns/collections/scheduled-publication.ts +138 -111
  120. package/src/server-fns/collections/status.ts +86 -64
  121. package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
  122. package/src/server-fns/collections/tree-mutation.ts +28 -11
  123. package/src/server-fns/collections/tree.ts +150 -122
  124. package/src/server-fns/collections/update.ts +110 -86
  125. package/src/server-fns/collections/upload-singleton.test.node.ts +3 -7
  126. package/src/server-fns/collections/upload.ts +81 -77
  127. package/src/server-fns/document-mutation-errors.ts +67 -0
  128. package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
  129. package/src/server-fns/editable-revision.test.node.ts +23 -0
  130. package/src/server-fns/editable-revision.ts +18 -0
  131. package/src/server-fns/singleton-document-read.ts +7 -3
  132. package/src/server-fns/singletons/cancel-scheduled-publish.ts +10 -3
  133. package/src/server-fns/singletons/change-status.ts +10 -3
  134. package/src/server-fns/singletons/confirm-scheduled-publish.ts +12 -6
  135. package/src/server-fns/singletons/copy-to-locale.ts +20 -15
  136. package/src/server-fns/singletons/get.ts +3 -3
  137. package/src/server-fns/singletons/restore-version.ts +17 -12
  138. package/src/server-fns/singletons/schedule-publish.ts +18 -7
  139. package/src/server-fns/singletons/singletons.test.node.ts +63 -25
  140. package/src/server-fns/singletons/unpublish.ts +10 -3
  141. package/src/server-fns/singletons/update.ts +21 -16
  142. package/src/server-fns/structural-receipt.ts +41 -0
@@ -9,11 +9,23 @@
9
9
  import type React from 'react'
10
10
  import { act } from 'react'
11
11
 
12
- import { type MultiCollectionDefinition, SINGLE_STATUS_WORKFLOW } from '@byline/core'
12
+ import {
13
+ DEFAULT_WORKFLOW,
14
+ ERR_DOCUMENT_STALE,
15
+ ERR_LOCK_CONFLICT,
16
+ type MultiCollectionDefinition,
17
+ SINGLE_STATUS_WORKFLOW,
18
+ } from '@byline/core'
13
19
  import { createRoot, type Root } from 'react-dom/client'
14
20
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
15
21
 
16
22
  const mocks = vi.hoisted(() => ({
23
+ status: vi.fn(),
24
+ deletion: vi.fn(),
25
+ schedule: vi.fn(),
26
+ confirm: vi.fn(),
27
+ cancelSchedule: vi.fn(),
28
+ unpublish: vi.fn(),
17
29
  copyToLocale: vi.fn(),
18
30
  create: vi.fn(),
19
31
  deleteLocale: vi.fn(),
@@ -60,21 +72,21 @@ vi.mock('../../routes/list-return-storage.js', () => ({
60
72
  }))
61
73
 
62
74
  vi.mock('../../server-fns/collections/index.js', () => ({
63
- cancelCollectionDocumentScheduledPublish: vi.fn(),
64
- confirmCollectionDocumentScheduledPublish: vi.fn(),
75
+ cancelCollectionDocumentScheduledPublish: mocks.cancelSchedule,
76
+ confirmCollectionDocumentScheduledPublish: mocks.confirm,
65
77
  copyDocumentToLocale: mocks.copyToLocale,
66
78
  createCollectionDocument: mocks.create,
67
- deleteDocument: vi.fn(),
79
+ deleteDocument: mocks.deletion,
68
80
  deleteDocumentLocale: mocks.deleteLocale,
69
81
  duplicateCollectionDocument: mocks.duplicate,
70
82
  hasCommittedDocumentHookFailure: (result: { status: string }) =>
71
83
  result.status === 'committed-hook-failed',
72
84
  hasDeleteSideEffectFailures: () => false,
73
- scheduleCollectionDocumentPublish: vi.fn(),
74
- unpublishDocument: vi.fn(),
85
+ scheduleCollectionDocumentPublish: mocks.schedule,
86
+ unpublishDocument: mocks.unpublish,
75
87
  updateCollectionDocumentSystemFields: mocks.updateSystemFields,
76
88
  updateCollectionDocumentWithPatches: mocks.update,
77
- updateDocumentStatus: vi.fn(),
89
+ updateDocumentStatus: mocks.status,
78
90
  }))
79
91
 
80
92
  vi.mock('../chrome/loose-router.js', () => ({
@@ -106,6 +118,7 @@ const collection = {
106
118
  const initialData = {
107
119
  id: 'language-en',
108
120
  versionId: 'version-1',
121
+ revision: 7,
109
122
  status: 'published',
110
123
  fields: { name: 'English' },
111
124
  }
@@ -127,11 +140,12 @@ beforeEach(() => {
127
140
  documentId: 'language-th',
128
141
  documentVersionId: 'version-created',
129
142
  })
130
- mocks.update.mockResolvedValue({ status: 'ok' })
143
+ mocks.update.mockResolvedValue({ status: 'ok', documentId: 'language-en', revision: 8 })
131
144
  mocks.updateSystemFields.mockResolvedValue({ status: 'ok' })
132
145
  mocks.copyToLocale.mockResolvedValue({
133
146
  documentId: 'language-en',
134
147
  documentVersionId: 'version-2',
148
+ revision: 8,
135
149
  sourceLocale: 'en',
136
150
  targetLocale: 'th',
137
151
  fieldsUpdated: 1,
@@ -139,6 +153,7 @@ beforeEach(() => {
139
153
  mocks.deleteLocale.mockResolvedValue({
140
154
  documentId: 'language-en',
141
155
  documentVersionId: 'version-2',
156
+ revision: 8,
142
157
  locale: 'th',
143
158
  })
144
159
  mocks.duplicate.mockResolvedValue({
@@ -253,6 +268,44 @@ describe('collection post-save navigation', () => {
253
268
  })
254
269
  })
255
270
 
271
+ it('sends content and metadata in one save using the loaded revision', async () => {
272
+ act(() => {
273
+ root.render(
274
+ <EditView
275
+ collectionDefinition={collection}
276
+ initialData={initialData as never}
277
+ contentLocales={[{ code: 'en', label: 'English' }]}
278
+ defaultContentLocale="en"
279
+ locale="en"
280
+ />
281
+ )
282
+ })
283
+ const patches = [{ kind: 'field.set', path: 'name', value: 'English' }]
284
+ await act(async () => {
285
+ await latestSubmit()({
286
+ data: { name: 'English' },
287
+ patches,
288
+ contentDirty: true,
289
+ pathDirty: true,
290
+ systemPath: 'renamed',
291
+ availableLocalesDirty: true,
292
+ systemAvailableLocales: ['en', 'fr'],
293
+ })
294
+ })
295
+ expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
296
+ data: {
297
+ collection: 'languages',
298
+ id: 'language-en',
299
+ expectedRevision: 7,
300
+ patches,
301
+ locale: 'en',
302
+ path: 'renamed',
303
+ availableLocales: ['en', 'fr'],
304
+ },
305
+ })
306
+ expect(mocks.updateSystemFields).not.toHaveBeenCalled()
307
+ })
308
+
256
309
  it('bypasses the dirty-form blocker during the successful edit refresh', async () => {
257
310
  act(() => {
258
311
  root.render(
@@ -289,6 +342,7 @@ describe('collection post-save navigation', () => {
289
342
  status: 'committed-hook-failed',
290
343
  documentId: 'language-en',
291
344
  documentVersionId: 'version-2',
345
+ revision: 8,
292
346
  sideEffectFailure: { phase: 'afterUpdate', code: 'ERR_UNHANDLED' },
293
347
  })
294
348
  act(() => {
@@ -351,6 +405,7 @@ describe('collection post-save navigation', () => {
351
405
  status: 'committed-hook-failed',
352
406
  documentId: testCase.expectedId,
353
407
  documentVersionId: 'version-committed',
408
+ revision: 8,
354
409
  sideEffectFailure: { phase: testCase.phase, code: 'ERR_UNHANDLED' },
355
410
  })
356
411
  act(() => {
@@ -382,3 +437,96 @@ describe('collection post-save navigation', () => {
382
437
  })
383
438
  })
384
439
  })
440
+
441
+ describe('collection editor mutation lockout', () => {
442
+ const stale = ERR_DOCUMENT_STALE({
443
+ message: 'Test failure',
444
+ details: {
445
+ reason: 'revision_mismatch',
446
+ documentId: 'language-en',
447
+ expectedRevision: 7,
448
+ currentRevision: 8,
449
+ },
450
+ })
451
+ const opened = {
452
+ ...initialData,
453
+ _publishedVersion: initialData,
454
+ status: 'draft',
455
+ _scheduledPublicationEnabled: true,
456
+ _canSchedulePublication: true,
457
+ _scheduledPublish: { state: 'needs_reconfirm' },
458
+ }
459
+ const renderEditor = () =>
460
+ act(() =>
461
+ root.render(
462
+ <EditView
463
+ collectionDefinition={{ ...collection, workflow: DEFAULT_WORKFLOW }}
464
+ initialData={opened as never}
465
+ contentLocales={[
466
+ { code: 'en', label: 'English' },
467
+ { code: 'th', label: 'Thai' },
468
+ ]}
469
+ defaultContentLocale="en"
470
+ locale="en"
471
+ />
472
+ )
473
+ )
474
+ const operations = [
475
+ ['onSubmit', mocks.update, { patches: [], contentDirty: true }],
476
+ ['onStatusChange', mocks.status, 'published'],
477
+ ['onDelete', mocks.deletion, undefined],
478
+ ['onDuplicate', mocks.duplicate, undefined],
479
+ ['onCopyToLocale', mocks.copyToLocale, { targetLocale: 'th', overwrite: true }],
480
+ ['onDeleteLocale', mocks.deleteLocale, { targetLocale: 'th' }],
481
+ ['onUnpublish', mocks.unpublish, undefined],
482
+ ['onSchedulePublication', mocks.schedule, { publishAt: '2030-01-01T00:00:00Z' }],
483
+ ['onConfirmScheduledPublication', mocks.confirm, undefined],
484
+ ['onCancelScheduledPublication', mocks.cancelSchedule, undefined],
485
+ ] as const
486
+ it.each(operations)(
487
+ 'blocks subsequent writes after %s reports stale',
488
+ async (name, operation, argument) => {
489
+ operation.mockRejectedValueOnce(stale)
490
+ renderEditor()
491
+ await act(async () => {
492
+ await expect(latestAction(name)(argument)).rejects.toBe(stale)
493
+ })
494
+ expect(mocks.formProps.at(-1)).toMatchObject({
495
+ mutationIssue: 'stale',
496
+ mutationsBlocked: true,
497
+ observedRevision: 7,
498
+ })
499
+ for (const [otherName, , otherArgument] of operations) {
500
+ await act(async () => {
501
+ await expect(latestAction(otherName)(otherArgument)).rejects.toBe(stale)
502
+ })
503
+ }
504
+ expect(operations.reduce((total, [, mock]) => total + mock.mock.calls.length, 0)).toBe(1)
505
+ expect(mocks.navigate).not.toHaveBeenCalled()
506
+ expect(mocks.toastAdd).not.toHaveBeenCalled()
507
+ }
508
+ )
509
+ it.each([
510
+ ['onSubmit', mocks.update, { patches: [], contentDirty: true }],
511
+ ['onDelete', mocks.deletion, undefined],
512
+ ] as const)(
513
+ 'presents confirmed lock rollback safely for %s',
514
+ async (name, operation, argument) => {
515
+ const failure = ERR_LOCK_CONFLICT({
516
+ message: 'raw InnoDB private SQL',
517
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
518
+ })
519
+ operation.mockRejectedValueOnce(failure)
520
+ renderEditor()
521
+ await act(async () => {
522
+ await expect(latestAction(name)(argument)).rejects.toBe(failure)
523
+ })
524
+ expect(mocks.formProps.at(-1)).toMatchObject({
525
+ mutationIssue: 'lock',
526
+ mutationsBlocked: true,
527
+ observedRevision: 7,
528
+ })
529
+ expect(mocks.toastAdd).not.toHaveBeenCalled()
530
+ }
531
+ )
532
+ })
@@ -1,3 +1,5 @@
1
+ import { DocumentMutationNotice } from '../document-mutation-notice.js'
2
+ import { useDocumentMutationState } from '../document-mutation-state.js'
1
3
  /**
2
4
  * This Source Code is subject to the terms of the Mozilla Public
3
5
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -144,6 +146,7 @@ export const TreeListView = ({
144
146
  /** Header action components (`CollectionAdminConfig.listActions`). */
145
147
  listActions?: Array<(props: ListActionComponentProps) => React.ReactNode>
146
148
  }) => {
149
+ const mutation = useDocumentMutationState(undefined)
147
150
  const { t } = useTranslation('byline-admin')
148
151
  const router = useRouter()
149
152
  const toastManager = useToastManager()
@@ -159,10 +162,14 @@ export const TreeListView = ({
159
162
  const [moveController] = useState(() => createAdminTreeMoveController())
160
163
  const preserveOptimisticForRows = useRef<CollectionTreeRow[] | null>(null)
161
164
  useEffect(() => {
162
- if (!shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)) return
165
+ if (
166
+ mutation.blocked ||
167
+ !shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)
168
+ )
169
+ return
163
170
  preserveOptimisticForRows.current = null
164
171
  setLocalPlaced(rows.filter((r) => !r.unplaced))
165
- }, [rows, isMoving])
172
+ }, [rows, isMoving, mutation.blocked])
166
173
 
167
174
  const [activeId, setActiveId] = useState<string | null>(null)
168
175
  const [overId, setOverId] = useState<string | null>(null)
@@ -186,13 +193,13 @@ export const TreeListView = ({
186
193
  }
187
194
 
188
195
  const handleDragStart = ({ active }: DragStartEvent) => {
189
- if (!moveController.isMoving()) setActiveId(String(active.id))
196
+ if (!mutation.blocked && !moveController.isMoving()) setActiveId(String(active.id))
190
197
  }
191
198
  const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x)
192
199
  const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null)
193
200
 
194
201
  const handleDragEnd = async ({ active, over }: DragEndEvent) => {
195
- if (moveController.isMoving()) {
202
+ if (mutation.blocked || moveController.isMoving()) {
196
203
  resetDnd()
197
204
  return
198
205
  }
@@ -231,10 +238,12 @@ export const TreeListView = ({
231
238
  return
232
239
  }
233
240
 
241
+ if ('receipt' in outcome) mutation.reportStructure(outcome.receipt)
242
+ const handled = 'error' in outcome ? mutation.report(outcome.error) : null
234
243
  const effects = getAdminTreeMoveUiEffects(outcome)
235
244
  if (effects.rollback) setLocalPlaced(current)
236
245
 
237
- if (effects.structuralFailure != null) {
246
+ if (!handled && effects.structuralFailure != null) {
238
247
  const conflict = effects.structuralFailure === 'conflict'
239
248
  toastManager.add({
240
249
  title: t(
@@ -318,6 +327,7 @@ export const TreeListView = ({
318
327
  return (
319
328
  <Section>
320
329
  <Container>
330
+ <DocumentMutationNotice issue={mutation.issue} scheduleNotice={mutation.scheduleNotice} />
321
331
  <div className={cx('byline-coll-list-head', styles.head)}>
322
332
  <h1 className={cx('byline-coll-list-title', styles.title)}>{collectionLabels.plural}</h1>
323
333
  <span className={cx('byline-coll-list-stats', styles.stats)}>
@@ -380,7 +390,7 @@ export const TreeListView = ({
380
390
  key={row.id}
381
391
  id={row.id}
382
392
  dragging={row.id === activeId}
383
- disabled={isMoving}
393
+ disabled={mutation.blocked || isMoving}
384
394
  dragHandleLabel={t('collections.list.dragHandleAriaLabel')}
385
395
  >
386
396
  {renderCells(row, depth)}
@@ -1,3 +1,5 @@
1
+ import { DocumentMutationNotice } from '../document-mutation-notice.js'
2
+ import { useDocumentMutationState } from '../document-mutation-state.js'
1
3
  /**
2
4
  * This Source Code is subject to the terms of the Mozilla Public
3
5
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -55,6 +57,7 @@ interface VersionHistoryRowContext {
55
57
  actionLabel: string
56
58
  restoreStatusLabel: string
57
59
  openCompare: () => void
60
+ mutationsBlocked: boolean
58
61
  openRestore: () => void
59
62
  }
60
63
 
@@ -126,9 +129,10 @@ export function VersionHistoryCore({
126
129
  rowPresentation: VersionHistoryRowPresentation
127
130
  loadHistoricalVersion: HistoricalVersionLoader
128
131
  onPageSizeChange: (pageSize: number) => void
129
- restoreVersion: (versionId: string) => Promise<unknown>
132
+ restoreVersion: (versionId: string, expectedRevision: number) => Promise<unknown>
130
133
  onRestoreComplete: () => void | Promise<void>
131
134
  }) {
135
+ const mutation = useDocumentMutationState(currentDocument)
132
136
  const { t } = useTranslation('byline-admin')
133
137
  const [selectedVersion, setSelectedVersion] = useState<{
134
138
  versionId: string
@@ -150,6 +154,7 @@ export function VersionHistoryCore({
150
154
 
151
155
  return (
152
156
  <>
157
+ <DocumentMutationNotice issue={mutation.issue} />
153
158
  <div className={cx('byline-coll-history-options', styles.options)}>
154
159
  <RouterPager
155
160
  page={data.meta.page}
@@ -195,7 +200,7 @@ export function VersionHistoryCore({
195
200
  setSelectedVersion({ versionId, label: versionLabel })
196
201
  }
197
202
  const openRestore = () => {
198
- if (versionId == null || versionId === currentVersionId) return
203
+ if (mutation.blocked || versionId == null || versionId === currentVersionId) return
199
204
  setRestoreTarget({ versionId, label: versionLabel, versionNumber })
200
205
  }
201
206
 
@@ -228,6 +233,7 @@ export function VersionHistoryCore({
228
233
  actionLabel,
229
234
  restoreStatusLabel,
230
235
  openCompare,
236
+ mutationsBlocked: mutation.blocked,
231
237
  openRestore,
232
238
  })}
233
239
  </Table.Row>
@@ -334,11 +340,15 @@ export function VersionHistoryCore({
334
340
  </Modal.Header>
335
341
  {restoreTarget ? (
336
342
  <RestoreVersionModal
343
+ disabled={mutation.blocked}
344
+ onMutationError={mutation.report}
337
345
  versionLabel={restoreTarget.label}
338
346
  versionNumber={restoreTarget.versionNumber}
339
347
  restoreStatusLabel={restoreStatusLabel}
340
348
  onClose={() => setRestoreTarget(null)}
341
- restoreVersion={() => restoreVersion(restoreTarget.versionId)}
349
+ restoreVersion={() =>
350
+ restoreVersion(restoreTarget.versionId, mutation.expectedRevision())
351
+ }
342
352
  onRestoreComplete={onRestoreComplete}
343
353
  />
344
354
  ) : null}
@@ -0,0 +1,45 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import { useTranslation } from '@byline/i18n/react'
4
+ import { Alert, Button } from '@byline/ui/react'
5
+
6
+ import { getAdminRoutePath } from '../routes/admin-path.js'
7
+ import type { DocumentMutationIssue } from './document-mutation-state.js'
8
+
9
+ /** Persistent recovery for list, history and schedule views without local form drafts. */
10
+ export function DocumentMutationNotice({
11
+ issue,
12
+ scheduleNotice = false,
13
+ }: {
14
+ issue: DocumentMutationIssue | null
15
+ scheduleNotice?: boolean
16
+ }) {
17
+ const { t } = useTranslation('byline-admin')
18
+ const warning = useRef<HTMLDivElement>(null)
19
+ useEffect(() => {
20
+ if (issue) warning.current?.focus()
21
+ }, [issue])
22
+ if (!issue && !scheduleNotice) return null
23
+ return (
24
+ <div ref={warning} tabIndex={-1} role="alert" aria-live="assertive">
25
+ {issue && (
26
+ <Alert close={false} intent="warning" title={t(`documentConcurrency.${issue}Title`)}>
27
+ <p>{t(`documentConcurrency.${issue}`)}</p>
28
+ {issue !== 'committed' && (
29
+ <Button type="button" onClick={() => window.location.reload()}>
30
+ {t('documentConcurrency.reloadAction')}
31
+ </Button>
32
+ )}
33
+ </Alert>
34
+ )}
35
+ {scheduleNotice && (
36
+ <Alert close={false} intent="warning" title={t('documentConcurrency.schedulesTitle')}>
37
+ <p>{t('documentConcurrency.schedules')}</p>
38
+ <a href={getAdminRoutePath('scheduled-publications')}>
39
+ {t('documentConcurrency.reviewSchedules')}
40
+ </a>
41
+ </Alert>
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,146 @@
1
+ import { act } from 'react'
2
+
3
+ import {
4
+ ERR_DOCUMENT_HOOK_COMMITTED,
5
+ ERR_DOCUMENT_STALE,
6
+ ERR_LOCK_CONFLICT,
7
+ ERR_VALIDATION,
8
+ } from '@byline/core'
9
+ import { createRoot, type Root } from 'react-dom/client'
10
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
11
+
12
+ import { useDocumentMutationState } from './document-mutation-state.js'
13
+
14
+ type Observation = { id: string; versionId: string; revision?: number; fields: { title: string } }
15
+ let state: ReturnType<typeof useDocumentMutationState<Observation>>
16
+ let root: Root
17
+ let container: HTMLDivElement
18
+ function Probe({ observation }: { observation: Observation | null }) {
19
+ state = useDocumentMutationState(observation)
20
+ return null
21
+ }
22
+ const original: Observation = {
23
+ id: 'doc',
24
+ versionId: 'v1',
25
+ revision: 7,
26
+ fields: { title: 'Opened' },
27
+ }
28
+ const render = (observation: Observation | null) =>
29
+ act(() => root.render(<Probe observation={observation} />))
30
+ beforeEach(() => {
31
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
32
+ container = document.createElement('div')
33
+ root = createRoot(container)
34
+ render(original)
35
+ })
36
+ afterEach(() => {
37
+ act(() => root.unmount())
38
+ container.remove()
39
+ Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
40
+ })
41
+ const stale = ERR_DOCUMENT_STALE({
42
+ message: 'Test failure',
43
+ details: {
44
+ reason: 'revision_mismatch',
45
+ documentId: 'doc',
46
+ expectedRevision: 7,
47
+ currentRevision: 8,
48
+ },
49
+ })
50
+
51
+ describe('editor observation ownership', () => {
52
+ it('does not attach a background metadata revision to the opened fields', () => {
53
+ render({ ...original, revision: 8 })
54
+ expect(state.document).toBe(original)
55
+ expect(state.expectedRevision()).toBe(7)
56
+ })
57
+ it('adopts its own structural receipt without discarding fields and surfaces schedule suspension', () => {
58
+ act(() =>
59
+ state.adopt({ documentId: 'doc', revision: 8, scheduledPublicationsNeedReconfirmation: true })
60
+ )
61
+ expect(state.document).toBe(original)
62
+ expect(state.expectedRevision()).toBe(8)
63
+ expect(state.scheduleNotice).toBe(true)
64
+ const refreshed = { ...original, revision: 8 }
65
+ render(refreshed)
66
+ expect(state.document).toBe(refreshed)
67
+ })
68
+ it.each([
69
+ ['stale', stale],
70
+ [
71
+ 'reload',
72
+ ERR_VALIDATION({ message: 'Test failure', details: { reason: 'missing_document_revision' } }),
73
+ ],
74
+ [
75
+ 'lock',
76
+ ERR_LOCK_CONFLICT({
77
+ message: 'Test failure',
78
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
79
+ }),
80
+ ],
81
+ ['unavailable', { code: 'ERR_NOT_FOUND' }],
82
+ ])(
83
+ 'blocks every later call after %s without adopting fresh data or a late receipt',
84
+ (issue, error) => {
85
+ act(() => {
86
+ expect(state.report(error)).toBe('blocked')
87
+ })
88
+ render({ ...original, revision: 9 })
89
+ act(() => state.adopt({ documentId: 'doc', revision: 8 }))
90
+ expect(state.issue).toBe(issue)
91
+ expect(state.blocked).toBe(true)
92
+ expect(state.document).toBe(original)
93
+ expect(state.revision).toBe(7)
94
+ expect(() => state.assertWritable()).toThrow()
95
+ expect(() => state.expectedRevision()).toThrow()
96
+ }
97
+ )
98
+ it('keeps a rejected empty singleton empty until a new editor session', () => {
99
+ act(() => root.unmount())
100
+ root = createRoot(container)
101
+ render(null)
102
+ const error = ERR_DOCUMENT_STALE({
103
+ message: 'Test failure',
104
+ details: {
105
+ reason: 'singleton_slot_changed',
106
+ singletonPath: 'settings',
107
+ expectedState: 'empty',
108
+ currentState: 'document',
109
+ },
110
+ })
111
+ act(() => {
112
+ state.report(error)
113
+ })
114
+ render(original)
115
+ expect(state.document).toBeNull()
116
+ expect(state.blocked).toBe(true)
117
+ })
118
+ it('keeps committed hook failures distinct and advances only to the committed revision', () => {
119
+ const error = ERR_DOCUMENT_HOOK_COMMITTED({
120
+ message: 'Test failure',
121
+ details: {
122
+ phase: 'afterUpdate',
123
+ documentId: 'doc',
124
+ documentVersionId: 'v2',
125
+ revision: 8,
126
+ sideEffectCode: 'ERR_UNHANDLED',
127
+ },
128
+ })
129
+ act(() => {
130
+ expect(state.report(error)).toBe('committed')
131
+ })
132
+ expect(state.issue).toBe('committed')
133
+ expect(state.blocked).toBe(false)
134
+ expect(state.expectedRevision()).toBe(8)
135
+ })
136
+ it('does not classify arbitrary conflicts or uncertain driver failures as safe rollback', () => {
137
+ for (const error of [
138
+ { code: 'ERR_CONFLICT' },
139
+ new Error('deadlock SQL secret'),
140
+ { code: 'ECONNRESET' },
141
+ ]) {
142
+ expect(state.report(error)).toBeNull()
143
+ }
144
+ expect(state.blocked).toBe(false)
145
+ })
146
+ })
@@ -0,0 +1,95 @@
1
+ import { useRef, useState } from 'react'
2
+
3
+ import {
4
+ ErrorCodes,
5
+ getDocumentRevisionValidationDetails,
6
+ getDocumentStaleDetails,
7
+ getLockConflictDetails,
8
+ parseDocumentRevision,
9
+ } from '@byline/core'
10
+ import { getDocumentHookCommittedDetails } from '@byline/core/services'
11
+
12
+ import { committedTreeReceipt, structuralReceipt } from '../server-fns/structural-receipt.js'
13
+
14
+ export type DocumentMutationIssue = 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed'
15
+
16
+ function documentMutationIssue(error: unknown): DocumentMutationIssue | null {
17
+ if (getDocumentStaleDetails(error)) return 'stale'
18
+ if (getDocumentRevisionValidationDetails(error)) return 'reload'
19
+ if (getLockConflictDetails(error)) return 'lock'
20
+ if (getDocumentHookCommittedDetails(error) || committedTreeReceipt(error)) return 'committed'
21
+ if (
22
+ typeof error === 'object' &&
23
+ error !== null &&
24
+ Reflect.get(error, 'code') === ErrorCodes.NOT_FOUND
25
+ )
26
+ return 'unavailable'
27
+ return null
28
+ }
29
+
30
+ /** A rejected observation stays frozen until an explicit editor reload. */
31
+ export function useDocumentMutationState<
32
+ T extends { id?: unknown; versionId?: unknown; revision?: unknown },
33
+ >(document: T | null | undefined) {
34
+ const [issue, setIssue] = useState<DocumentMutationIssue | null>(null)
35
+ const [scheduleNotice, setScheduleNotice] = useState(false)
36
+ const [, setReceiptRevision] = useState<number | undefined>()
37
+ const blockedError = useRef<unknown>(undefined)
38
+ const observation = useRef({ identity: document, revision: document?.revision })
39
+ if (
40
+ observation.current.identity !== document &&
41
+ blockedError.current === undefined &&
42
+ document?.revision === observation.current.revision
43
+ ) {
44
+ observation.current = { identity: document, revision: document?.revision }
45
+ }
46
+ const assertWritable = () => {
47
+ if (blockedError.current !== undefined) throw blockedError.current
48
+ }
49
+ const expectedRevision = () => {
50
+ assertWritable()
51
+ return parseDocumentRevision(observation.current.revision)
52
+ }
53
+ const adopt = (receipt: {
54
+ documentId: string
55
+ revision: number
56
+ scheduledPublicationsNeedReconfirmation?: boolean
57
+ }) => {
58
+ if (blockedError.current !== undefined) return
59
+ if (document?.id != null && receipt.documentId !== document.id) return
60
+ observation.current.revision = parseDocumentRevision(receipt.revision)
61
+ setReceiptRevision(receipt.revision)
62
+ if (receipt.scheduledPublicationsNeedReconfirmation) setScheduleNotice(true)
63
+ }
64
+ const reportStructure = (result: unknown) => {
65
+ const receipt = structuralReceipt(result)
66
+ if (receipt) adopt(receipt)
67
+ }
68
+ const report = (error: unknown): 'blocked' | 'committed' | null => {
69
+ const kind = documentMutationIssue(error)
70
+ if (kind === null) return null
71
+ if (kind === 'committed') {
72
+ if (blockedError.current !== undefined) return 'blocked'
73
+ const details = getDocumentHookCommittedDetails(error) ?? committedTreeReceipt(error)
74
+ if (details) adopt(details)
75
+ setIssue(kind)
76
+ return 'committed'
77
+ }
78
+ blockedError.current = error
79
+ setIssue(kind)
80
+ return 'blocked'
81
+ }
82
+ return {
83
+ issue,
84
+ blocked: issue !== null && issue !== 'committed',
85
+ scheduleNotice,
86
+ document: observation.current.identity,
87
+ revision:
88
+ typeof observation.current.revision === 'number' ? observation.current.revision : undefined,
89
+ expectedRevision,
90
+ assertWritable,
91
+ adopt,
92
+ report,
93
+ reportStructure,
94
+ }
95
+ }