@byline/host-tanstack-start 4.18.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 (148) hide show
  1. package/dist/admin-shell/collections/create.js +22 -5
  2. package/dist/admin-shell/collections/edit.d.ts +1 -1
  3. package/dist/admin-shell/collections/edit.js +222 -34
  4. package/dist/admin-shell/collections/history.js +4 -2
  5. package/dist/admin-shell/collections/list.js +30 -6
  6. package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
  7. package/dist/admin-shell/collections/restore-version-modal.js +23 -6
  8. package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
  9. package/dist/admin-shell/collections/tree-list.js +16 -6
  10. package/dist/admin-shell/collections/version-history.d.ts +2 -1
  11. package/dist/admin-shell/collections/version-history.js +11 -2
  12. package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
  13. package/dist/admin-shell/document-mutation-notice.js +53 -0
  14. package/dist/admin-shell/document-mutation-state.d.ts +22 -0
  15. package/dist/admin-shell/document-mutation-state.js +71 -0
  16. package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
  17. package/dist/admin-shell/scheduled-publications/list.js +15 -5
  18. package/dist/admin-shell/singletons/history.js +4 -2
  19. package/dist/admin-shell/singletons/view.d.ts +1 -1
  20. package/dist/admin-shell/singletons/view.js +108 -23
  21. package/dist/integrations/byline-field-services.js +2 -9
  22. package/dist/routes/create-collection-edit-route.js +1 -1
  23. package/dist/routes/create-collection-list-route.d.ts +0 -7
  24. package/dist/routes/create-collection-list-route.js +7 -9
  25. package/dist/routes/create-singleton-route.d.ts +2 -0
  26. package/dist/routes/create-singleton-route.js +4 -3
  27. package/dist/server-fns/collections/copy-to-locale.d.ts +4 -8
  28. package/dist/server-fns/collections/copy-to-locale.js +17 -8
  29. package/dist/server-fns/collections/create.d.ts +2 -8
  30. package/dist/server-fns/collections/create.js +20 -10
  31. package/dist/server-fns/collections/delete-locale.d.ts +4 -8
  32. package/dist/server-fns/collections/delete-locale.js +15 -6
  33. package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
  34. package/dist/server-fns/collections/delete-outcome.js +4 -0
  35. package/dist/server-fns/collections/delete.d.ts +2 -7
  36. package/dist/server-fns/collections/delete.js +5 -3
  37. package/dist/server-fns/collections/duplicate.d.ts +4 -8
  38. package/dist/server-fns/collections/duplicate.js +14 -5
  39. package/dist/server-fns/collections/get.d.ts +1 -7
  40. package/dist/server-fns/collections/get.js +5 -4
  41. package/dist/server-fns/collections/index.d.ts +1 -0
  42. package/dist/server-fns/collections/index.js +1 -0
  43. package/dist/server-fns/collections/list.d.ts +0 -7
  44. package/dist/server-fns/collections/list.js +11 -3
  45. package/dist/server-fns/collections/reorder.d.ts +7 -8
  46. package/dist/server-fns/collections/reorder.js +20 -76
  47. package/dist/server-fns/collections/restore-version.d.ts +4 -8
  48. package/dist/server-fns/collections/restore-version.js +22 -12
  49. package/dist/server-fns/collections/save-outcome.d.ts +24 -0
  50. package/dist/server-fns/collections/save-outcome.js +19 -0
  51. package/dist/server-fns/collections/scheduled-publication.d.ts +49 -9
  52. package/dist/server-fns/collections/scheduled-publication.js +31 -21
  53. package/dist/server-fns/collections/status.d.ts +8 -7
  54. package/dist/server-fns/collections/status.js +13 -5
  55. package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
  56. package/dist/server-fns/collections/tree-mutation.js +17 -5
  57. package/dist/server-fns/collections/tree.d.ts +14 -8
  58. package/dist/server-fns/collections/tree.js +21 -17
  59. package/dist/server-fns/collections/update.d.ts +14 -11
  60. package/dist/server-fns/collections/update.js +48 -24
  61. package/dist/server-fns/collections/upload.d.ts +1 -0
  62. package/dist/server-fns/collections/upload.js +3 -2
  63. package/dist/server-fns/document-mutation-errors.d.ts +5 -0
  64. package/dist/server-fns/document-mutation-errors.js +50 -0
  65. package/dist/server-fns/editable-revision.d.ts +9 -0
  66. package/dist/server-fns/editable-revision.js +11 -0
  67. package/dist/server-fns/singleton-document-read.d.ts +1 -1
  68. package/dist/server-fns/singleton-document-read.js +7 -3
  69. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +6 -8
  70. package/dist/server-fns/singletons/cancel-scheduled-publish.js +4 -1
  71. package/dist/server-fns/singletons/change-status.d.ts +2 -7
  72. package/dist/server-fns/singletons/change-status.js +4 -1
  73. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +5 -8
  74. package/dist/server-fns/singletons/confirm-scheduled-publish.js +4 -2
  75. package/dist/server-fns/singletons/copy-to-locale.d.ts +2 -7
  76. package/dist/server-fns/singletons/copy-to-locale.js +16 -5
  77. package/dist/server-fns/singletons/get.d.ts +4 -4
  78. package/dist/server-fns/singletons/restore-version.d.ts +2 -7
  79. package/dist/server-fns/singletons/restore-version.js +13 -1
  80. package/dist/server-fns/singletons/schedule-publish.d.ts +5 -8
  81. package/dist/server-fns/singletons/schedule-publish.js +4 -2
  82. package/dist/server-fns/singletons/unpublish.d.ts +2 -7
  83. package/dist/server-fns/singletons/unpublish.js +4 -1
  84. package/dist/server-fns/singletons/update.d.ts +4 -11
  85. package/dist/server-fns/singletons/update.js +16 -8
  86. package/dist/server-fns/structural-receipt.d.ts +4 -0
  87. package/dist/server-fns/structural-receipt.js +31 -0
  88. package/package.json +11 -11
  89. package/src/admin-shell/collections/create.tsx +22 -3
  90. package/src/admin-shell/collections/edit.tsx +233 -61
  91. package/src/admin-shell/collections/history.tsx +11 -2
  92. package/src/admin-shell/collections/list.tsx +22 -6
  93. package/src/admin-shell/collections/restore-version-modal.tsx +23 -5
  94. package/src/admin-shell/collections/save-navigation.test.tsx +532 -0
  95. package/src/admin-shell/collections/tree-list.tsx +16 -6
  96. package/src/admin-shell/collections/version-history.tsx +13 -3
  97. package/src/admin-shell/document-mutation-notice.tsx +45 -0
  98. package/src/admin-shell/document-mutation-state.test.tsx +146 -0
  99. package/src/admin-shell/document-mutation-state.ts +95 -0
  100. package/src/admin-shell/scheduled-publications/list.tsx +16 -6
  101. package/src/admin-shell/singletons/history.test.tsx +46 -1
  102. package/src/admin-shell/singletons/history.tsx +10 -2
  103. package/src/admin-shell/singletons/view.test.tsx +86 -18
  104. package/src/admin-shell/singletons/view.tsx +145 -45
  105. package/src/integrations/byline-field-services.ts +2 -3
  106. package/src/routes/create-collection-edit-route.tsx +1 -0
  107. package/src/routes/create-collection-list-route.tsx +13 -2
  108. package/src/routes/create-singleton-route.test.node.ts +10 -2
  109. package/src/routes/create-singleton-route.tsx +6 -4
  110. package/src/server-fns/collections/copy-to-locale.ts +48 -29
  111. package/src/server-fns/collections/create.ts +56 -44
  112. package/src/server-fns/collections/delete-locale.ts +48 -25
  113. package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
  114. package/src/server-fns/collections/delete-outcome.ts +27 -16
  115. package/src/server-fns/collections/delete.ts +44 -38
  116. package/src/server-fns/collections/duplicate.ts +45 -25
  117. package/src/server-fns/collections/get.ts +8 -4
  118. package/src/server-fns/collections/index.ts +1 -0
  119. package/src/server-fns/collections/list.ts +8 -3
  120. package/src/server-fns/collections/reorder.ts +32 -124
  121. package/src/server-fns/collections/restore-version.ts +51 -36
  122. package/src/server-fns/collections/revision-input.test.node.ts +108 -0
  123. package/src/server-fns/collections/save-outcome.test.node.ts +80 -0
  124. package/src/server-fns/collections/save-outcome.ts +57 -0
  125. package/src/server-fns/collections/scheduled-publication.ts +138 -111
  126. package/src/server-fns/collections/status.ts +86 -64
  127. package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
  128. package/src/server-fns/collections/tree-mutation.ts +28 -11
  129. package/src/server-fns/collections/tree.ts +150 -122
  130. package/src/server-fns/collections/update.ts +110 -71
  131. package/src/server-fns/collections/upload-singleton.test.node.ts +3 -7
  132. package/src/server-fns/collections/upload.ts +81 -77
  133. package/src/server-fns/document-mutation-errors.ts +67 -0
  134. package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
  135. package/src/server-fns/editable-revision.test.node.ts +23 -0
  136. package/src/server-fns/editable-revision.ts +18 -0
  137. package/src/server-fns/singleton-document-read.ts +7 -3
  138. package/src/server-fns/singletons/cancel-scheduled-publish.ts +10 -3
  139. package/src/server-fns/singletons/change-status.ts +10 -3
  140. package/src/server-fns/singletons/confirm-scheduled-publish.ts +12 -6
  141. package/src/server-fns/singletons/copy-to-locale.ts +20 -8
  142. package/src/server-fns/singletons/get.ts +3 -3
  143. package/src/server-fns/singletons/restore-version.ts +17 -3
  144. package/src/server-fns/singletons/schedule-publish.ts +18 -7
  145. package/src/server-fns/singletons/singletons.test.node.ts +96 -24
  146. package/src/server-fns/singletons/unpublish.ts +10 -3
  147. package/src/server-fns/singletons/update.ts +21 -9
  148. package/src/server-fns/structural-receipt.ts +41 -0
@@ -9,7 +9,12 @@
9
9
  import { act } from 'react'
10
10
 
11
11
  import { BylineFieldServicesProvider } from '@byline/admin/react'
12
- import { defineAdminConfig, defineSingleton, type SingletonDefinition } from '@byline/core'
12
+ import {
13
+ defineAdminConfig,
14
+ defineSingleton,
15
+ ERR_DOCUMENT_STALE,
16
+ type SingletonDefinition,
17
+ } from '@byline/core'
13
18
  import { adminTranslations } from '@byline/i18n/admin'
14
19
  import { I18nProvider } from '@byline/i18n/react'
15
20
  import { createRoot, type Root } from 'react-dom/client'
@@ -54,6 +59,14 @@ vi.mock('../../server-fns/singletons/index.js', () => ({
54
59
  copySingletonToLocale: mocks.copyToLocale,
55
60
  }))
56
61
 
62
+ vi.mock('../../server-fns/collections/index.js', () => ({
63
+ hasCommittedDocumentHookFailure: (result: unknown) =>
64
+ typeof result === 'object' &&
65
+ result != null &&
66
+ 'status' in result &&
67
+ result.status === 'committed-hook-failed',
68
+ }))
69
+
57
70
  vi.mock('../../server-fns/preview/index.js', () => ({
58
71
  enablePreviewModeFn: vi.fn(async () => {}),
59
72
  }))
@@ -141,6 +154,7 @@ const locales = [
141
154
  const loadedDocument = {
142
155
  id: 'doc-settings',
143
156
  versionId: 'version-1',
157
+ revision: 7,
144
158
  path: '__singleton__/site-settings',
145
159
  status: 'draft',
146
160
  fields: { title: 'Loaded title', attachment: null },
@@ -225,13 +239,29 @@ function latestToastDescription(): string {
225
239
  beforeEach(() => {
226
240
  vi.clearAllMocks()
227
241
  mocks.guardStates.length = 0
228
- mocks.update.mockResolvedValue({ versionId: 'version-next' })
229
- mocks.changeStatus.mockResolvedValue({ newStatus: 'published' })
230
- mocks.unpublish.mockResolvedValue({ archivedCount: 1 })
231
- mocks.schedule.mockResolvedValue({ state: 'armed' })
232
- mocks.confirmSchedule.mockResolvedValue({ state: 'armed' })
233
- mocks.cancelSchedule.mockResolvedValue(null)
234
- mocks.copyToLocale.mockResolvedValue({ versionId: 'version-next' })
242
+ mocks.update.mockResolvedValue({
243
+ documentId: 'doc-settings',
244
+ revision: 8,
245
+ versionId: 'version-next',
246
+ })
247
+ mocks.changeStatus.mockResolvedValue({
248
+ documentId: 'doc-settings',
249
+ revision: 8,
250
+ newStatus: 'published',
251
+ })
252
+ mocks.unpublish.mockResolvedValue({ documentId: 'doc-settings', revision: 8, archivedCount: 1 })
253
+ mocks.schedule.mockResolvedValue({ documentId: 'doc-settings', revision: 8, state: 'armed' })
254
+ mocks.confirmSchedule.mockResolvedValue({
255
+ documentId: 'doc-settings',
256
+ revision: 8,
257
+ state: 'armed',
258
+ })
259
+ mocks.cancelSchedule.mockResolvedValue({ schedule: null, revision: 8 })
260
+ mocks.copyToLocale.mockResolvedValue({
261
+ documentId: 'doc-settings',
262
+ revision: 8,
263
+ versionId: 'version-next',
264
+ })
235
265
 
236
266
  defineAdminConfig({
237
267
  routes: { admin: '/internal/cms' },
@@ -308,7 +338,7 @@ describe('SingletonView', () => {
308
338
  expect(container.querySelector('.byline-preview-link')).toBeNull()
309
339
  })
310
340
 
311
- it('saves full data with the active locale and the correct optimistic version', async () => {
341
+ it('saves full data with the active locale and the observed revision or explicit empty slot', async () => {
312
342
  render(null, 'en')
313
343
  typeIntoTitle('First save')
314
344
  await submit()
@@ -318,7 +348,7 @@ describe('SingletonView', () => {
318
348
  singleton: 'site-settings',
319
349
  data: { title: 'First save', attachment: undefined },
320
350
  locale: 'en',
321
- expectedVersionId: undefined,
351
+ expectedState: 'empty',
322
352
  },
323
353
  })
324
354
  expect(mocks.invalidate).toHaveBeenCalledTimes(1)
@@ -334,19 +364,28 @@ describe('SingletonView', () => {
334
364
  singleton: 'site-settings',
335
365
  data: { title: 'Second save', attachment: null },
336
366
  locale: 'fr',
337
- expectedVersionId: 'version-1',
367
+ expectedRevision: 7,
338
368
  },
339
369
  })
340
370
  expect(mocks.invalidate).toHaveBeenCalledTimes(2)
341
371
  })
342
372
 
343
373
  it('distinguishes stale saves from a singleton missing on the server', async () => {
344
- mocks.update.mockRejectedValueOnce({ code: 'ERR_CONFLICT' })
374
+ mocks.update.mockRejectedValueOnce(
375
+ ERR_DOCUMENT_STALE({
376
+ message: 'Test failure',
377
+ details: {
378
+ reason: 'revision_mismatch',
379
+ documentId: 'doc-settings',
380
+ expectedRevision: 7,
381
+ currentRevision: 8,
382
+ },
383
+ })
384
+ )
345
385
  render(loadedDocument)
346
386
  typeIntoTitle('Stale edit')
347
387
  await submit()
348
- const conflict = latestToastDescription()
349
- expect(conflict).toContain('Someone else saved this singleton first')
388
+ expect(container.textContent).toContain('This document has changed since you opened it.')
350
389
 
351
390
  act(() => root.unmount())
352
391
  root = createRoot(container)
@@ -354,13 +393,22 @@ describe('SingletonView', () => {
354
393
  render(loadedDocument)
355
394
  typeIntoTitle('Missing edit')
356
395
  await submit()
357
- const notConfigured = latestToastDescription()
358
- expect(notConfigured).toContain('not configured on the server')
359
- expect(notConfigured).not.toBe(conflict)
396
+ expect(container.textContent).toContain('This document is no longer available.')
397
+ expect(container.textContent).not.toContain('This document has changed since you opened it.')
360
398
  })
361
399
 
362
400
  it('keeps a failed save dirty with the navigation guard active', async () => {
363
- mocks.update.mockRejectedValueOnce({ code: 'ERR_CONFLICT' })
401
+ mocks.update.mockRejectedValueOnce(
402
+ ERR_DOCUMENT_STALE({
403
+ message: 'Test failure',
404
+ details: {
405
+ reason: 'revision_mismatch',
406
+ documentId: 'doc-settings',
407
+ expectedRevision: 7,
408
+ currentRevision: 8,
409
+ },
410
+ })
411
+ )
364
412
  render(loadedDocument)
365
413
  typeIntoTitle('Unsaved change')
366
414
  await submit()
@@ -370,6 +418,26 @@ describe('SingletonView', () => {
370
418
  expect(container.textContent).toContain('Cancel')
371
419
  })
372
420
 
421
+ it('treats a committed afterSave failure as saved, warns, and reloads canonical state', async () => {
422
+ mocks.update.mockResolvedValueOnce({
423
+ status: 'committed-hook-failed',
424
+ documentId: 'doc-settings',
425
+ documentVersionId: 'version-next',
426
+ revision: 8,
427
+ sideEffectFailure: { phase: 'afterSave', code: 'ERR_UNHANDLED' },
428
+ })
429
+ render(loadedDocument)
430
+ typeIntoTitle('Committed change')
431
+ await submit()
432
+
433
+ expect(latestToastDescription()).toContain(
434
+ 'Your changes were saved, but a post-save update failed'
435
+ )
436
+ expect(mocks.invalidate).toHaveBeenCalledOnce()
437
+ expect(mocks.guardStates.at(-1)).toBe(false)
438
+ expect(container.textContent).toContain('Close')
439
+ })
440
+
373
441
  it('re-runs the loader and remounts before unlocking saved-document uploads', async () => {
374
442
  const loader = vi
375
443
  .fn<() => Promise<typeof loadedDocument | null>>()
@@ -13,7 +13,6 @@ import { useRouter } from '@tanstack/react-router'
13
13
  import { FormRenderer } from '@byline/admin/react'
14
14
  import type { SingletonAdminConfig, SingletonDefinition } from '@byline/core'
15
15
  import {
16
- ErrorCodes,
17
16
  getDefaultStatus,
18
17
  getWorkflow,
19
18
  getWorkflowStatuses,
@@ -23,6 +22,10 @@ import { useTranslation } from '@byline/i18n/react'
23
22
  import { Container, Section, useToastManager } from '@byline/ui/react'
24
23
 
25
24
  import { getAdminRoutePath } from '../../routes/admin-path.js'
25
+ import {
26
+ hasCommittedDocumentHookFailure,
27
+ type SerializedDocumentPublishSchedule,
28
+ } from '../../server-fns/collections/index.js'
26
29
  import {
27
30
  cancelSingletonScheduledPublish,
28
31
  changeSingletonStatus,
@@ -34,8 +37,8 @@ import {
34
37
  } from '../../server-fns/singletons/index.js'
35
38
  import { useNavigate } from '../chrome/loose-router.js'
36
39
  import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
40
+ import { useDocumentMutationState } from '../document-mutation-state.js'
37
41
  import { SingletonViewMenu } from './view-menu.js'
38
- import type { SerializedDocumentPublishSchedule } from '../../server-fns/collections/index.js'
39
42
  import type { ContentLocaleOption } from '../collections/view-menu.js'
40
43
 
41
44
  type SingletonDocument = Record<string, any> & {
@@ -44,16 +47,10 @@ type SingletonDocument = Record<string, any> & {
44
47
  fields: Record<string, any>
45
48
  }
46
49
 
47
- function errorCode(error: unknown): string | null {
48
- return typeof (error as { code?: unknown })?.code === 'string'
49
- ? (error as { code: string }).code
50
- : null
51
- }
52
-
53
50
  export function SingletonView({
54
51
  singletonDefinition,
55
52
  adminConfig,
56
- document,
53
+ document: loadedDocument,
57
54
  initialData,
58
55
  locale,
59
56
  contentLocales,
@@ -68,6 +65,8 @@ export function SingletonView({
68
65
  contentLocales: ReadonlyArray<ContentLocaleOption>
69
66
  defaultContentLocale: string
70
67
  }) {
68
+ const mutation = useDocumentMutationState(loadedDocument)
69
+ const document = mutation.document ?? null
71
70
  const router = useRouter()
72
71
  const navigate = useNavigate()
73
72
  const toastManager = useToastManager()
@@ -119,31 +118,50 @@ export function SingletonView({
119
118
  )
120
119
  }
121
120
 
121
+ const notifyCommittedHookFailure = () => {
122
+ toast(
123
+ t('collections.save.hookFailedToast'),
124
+ t('collections.save.hookFailedDescription'),
125
+ 'warning'
126
+ )
127
+ }
128
+
122
129
  const handleSubmit = async ({ data }: { data: Record<string, any> }) => {
123
130
  try {
124
- await updateSingleton({
131
+ mutation.assertWritable()
132
+ const result = await updateSingleton({
125
133
  data: {
126
134
  singleton: path,
127
135
  data,
128
136
  locale: locale ?? defaultContentLocale,
129
- expectedVersionId: document?.versionId,
137
+ ...(document == null
138
+ ? { expectedState: 'empty' as const }
139
+ : { expectedRevision: mutation.expectedRevision() }),
130
140
  },
131
141
  })
142
+ mutation.adopt(result)
132
143
  if (document != null) notifyScheduleSuspended()
133
- toast(
134
- t('singletons.edit.updateTitle', { label }),
135
- t('singletons.edit.updatedDescription', { label: label.toLowerCase() }),
136
- 'success'
137
- )
144
+ if (hasCommittedDocumentHookFailure(result)) {
145
+ notifyCommittedHookFailure()
146
+ } else {
147
+ toast(
148
+ t('singletons.edit.updateTitle', { label }),
149
+ t('singletons.edit.updatedDescription', { label: label.toLowerCase() }),
150
+ 'success'
151
+ )
152
+ }
138
153
  await reload()
139
154
  } catch (err) {
140
- const code = errorCode(err)
141
- const description =
142
- code === ErrorCodes.CONFLICT
143
- ? t('singletons.edit.conflictDescription')
144
- : code === ErrorCodes.NOT_FOUND
145
- ? t('singletons.edit.notConfiguredDescription')
146
- : t('singletons.edit.updateFailedDescription', { label: label.toLowerCase() })
155
+ const handled = mutation.report(err)
156
+ if (handled === 'blocked') throw err
157
+ if (handled === 'committed') {
158
+ notifyCommittedHookFailure()
159
+ await reload()
160
+ return
161
+ }
162
+ const description = t('singletons.edit.updateFailedDescription', {
163
+ label: label.toLowerCase(),
164
+ })
147
165
  toast(t('singletons.edit.updateTitle', { label }), description, 'danger')
148
166
 
149
167
  // FormRenderer commits its clean baseline only when this promise
@@ -155,7 +173,11 @@ export function SingletonView({
155
173
 
156
174
  const handleStatusChange = async (status: string) => {
157
175
  try {
158
- await changeSingletonStatus({ data: { singleton: path, status } })
176
+ mutation.assertWritable()
177
+ const result = await changeSingletonStatus({
178
+ data: { expectedRevision: mutation.expectedRevision(), singleton: path, status },
179
+ })
180
+ mutation.adopt(result)
159
181
  toast(
160
182
  t('collections.edit.statusUpdateTitle', { label }),
161
183
  t('collections.edit.statusChangedDescription', { status }),
@@ -163,9 +185,18 @@ export function SingletonView({
163
185
  )
164
186
  await reload()
165
187
  } catch (err) {
188
+ const handled = mutation.report(err)
189
+ if (handled === 'blocked') throw err
190
+ if (handled === 'committed') {
191
+ notifyCommittedHookFailure()
192
+ await reload()
193
+ return
194
+ }
166
195
  toast(
167
196
  t('collections.edit.statusUpdateTitle', { label }),
168
- t('collections.edit.statusChangeFailedDescription', { message: (err as Error).message }),
197
+ t('collections.edit.statusChangeFailedDescription', {
198
+ message: t('documentConcurrency.failed'),
199
+ }),
169
200
  'danger'
170
201
  )
171
202
  throw err
@@ -174,7 +205,11 @@ export function SingletonView({
174
205
 
175
206
  const handleUnpublish = async () => {
176
207
  try {
177
- await unpublishSingleton({ data: { singleton: path } })
208
+ mutation.assertWritable()
209
+ const result = await unpublishSingleton({
210
+ data: { expectedRevision: mutation.expectedRevision(), singleton: path },
211
+ })
212
+ mutation.adopt(result)
178
213
  toast(
179
214
  t('collections.edit.unpublishTitle', { label }),
180
215
  t('collections.edit.unpublishedDescription'),
@@ -182,9 +217,18 @@ export function SingletonView({
182
217
  )
183
218
  await reload()
184
219
  } catch (err) {
220
+ const handled = mutation.report(err)
221
+ if (handled === 'blocked') throw err
222
+ if (handled === 'committed') {
223
+ notifyCommittedHookFailure()
224
+ await reload()
225
+ return
226
+ }
185
227
  toast(
186
228
  t('collections.edit.unpublishTitle', { label }),
187
- t('collections.edit.unpublishFailedDescription', { message: (err as Error).message }),
229
+ t('collections.edit.unpublishFailedDescription', {
230
+ message: t('documentConcurrency.failed'),
231
+ }),
188
232
  'danger'
189
233
  )
190
234
  throw err
@@ -199,27 +243,34 @@ export function SingletonView({
199
243
  overwrite: boolean
200
244
  }) => {
201
245
  try {
202
- await copySingletonToLocale({
246
+ mutation.assertWritable()
247
+ const result = await copySingletonToLocale({
203
248
  data: {
249
+ expectedRevision: mutation.expectedRevision(),
204
250
  singleton: path,
205
251
  sourceLocale: locale ?? defaultContentLocale,
206
252
  targetLocale,
207
253
  overwrite,
208
254
  },
209
255
  })
256
+ mutation.adopt(result)
210
257
  const sourceLocale = locale ?? defaultContentLocale
211
258
  const sourceLabel =
212
259
  contentLocales.find((entry) => entry.code === sourceLocale)?.label ?? sourceLocale
213
260
  const targetLabel =
214
261
  contentLocales.find((entry) => entry.code === targetLocale)?.label ?? targetLocale
215
- toast(
216
- t('collections.edit.copyToLocaleTitle', { label }),
217
- t('collections.edit.copiedSuccessMessage', {
218
- source: sourceLabel,
219
- target: targetLabel,
220
- }),
221
- 'success'
222
- )
262
+ if (hasCommittedDocumentHookFailure(result)) {
263
+ notifyCommittedHookFailure()
264
+ } else {
265
+ toast(
266
+ t('collections.edit.copyToLocaleTitle', { label }),
267
+ t('collections.edit.copiedSuccessMessage', {
268
+ source: sourceLabel,
269
+ target: targetLabel,
270
+ }),
271
+ 'success'
272
+ )
273
+ }
223
274
  notifyScheduleSuspended()
224
275
  navigate({
225
276
  to: getAdminRoutePath('singletons', '$singleton'),
@@ -227,9 +278,16 @@ export function SingletonView({
227
278
  search: { locale: targetLocale },
228
279
  })
229
280
  } catch (err) {
281
+ const handled = mutation.report(err)
282
+ if (handled === 'blocked') throw err
283
+ if (handled === 'committed') {
284
+ notifyCommittedHookFailure()
285
+ await reload()
286
+ return
287
+ }
230
288
  toast(
231
289
  t('collections.edit.copyToLocaleTitle', { label }),
232
- t('collections.edit.copyFailedDescription', { message: (err as Error).message }),
290
+ t('collections.edit.copyFailedDescription', { message: t('documentConcurrency.failed') }),
233
291
  'danger'
234
292
  )
235
293
  throw err
@@ -238,9 +296,16 @@ export function SingletonView({
238
296
 
239
297
  const handleSchedulePublication = async ({ publishAt }: { publishAt: string }) => {
240
298
  try {
241
- await scheduleSingletonPublish({
242
- data: { singleton: path, publishAt, expectedVersionId: String(document?.versionId) },
299
+ mutation.assertWritable()
300
+ const result = await scheduleSingletonPublish({
301
+ data: {
302
+ expectedRevision: mutation.expectedRevision(),
303
+ singleton: path,
304
+ publishAt,
305
+ expectedVersionId: String(document?.versionId),
306
+ },
243
307
  })
308
+ mutation.adopt(result)
244
309
  toast(
245
310
  t('scheduledPublication.toast.scheduledTitle'),
246
311
  t('scheduledPublication.toast.scheduledDescription'),
@@ -248,16 +313,29 @@ export function SingletonView({
248
313
  )
249
314
  await reload()
250
315
  } catch (err) {
251
- toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
316
+ const handled = mutation.report(err)
317
+ if (handled === 'blocked') throw err
318
+ if (handled === 'committed') {
319
+ notifyCommittedHookFailure()
320
+ await reload()
321
+ return
322
+ }
323
+ toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
252
324
  throw err
253
325
  }
254
326
  }
255
327
 
256
328
  const handleConfirmScheduledPublication = async () => {
257
329
  try {
258
- await confirmSingletonScheduledPublish({
259
- data: { singleton: path, expectedVersionId: String(document?.versionId) },
330
+ mutation.assertWritable()
331
+ const result = await confirmSingletonScheduledPublish({
332
+ data: {
333
+ expectedRevision: mutation.expectedRevision(),
334
+ singleton: path,
335
+ expectedVersionId: String(document?.versionId),
336
+ },
260
337
  })
338
+ mutation.adopt(result)
261
339
  toast(
262
340
  t('scheduledPublication.toast.confirmedTitle'),
263
341
  t('scheduledPublication.toast.confirmedDescription'),
@@ -265,14 +343,25 @@ export function SingletonView({
265
343
  )
266
344
  await reload()
267
345
  } catch (err) {
268
- toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
346
+ const handled = mutation.report(err)
347
+ if (handled === 'blocked') throw err
348
+ if (handled === 'committed') {
349
+ notifyCommittedHookFailure()
350
+ await reload()
351
+ return
352
+ }
353
+ toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
269
354
  throw err
270
355
  }
271
356
  }
272
357
 
273
358
  const handleCancelScheduledPublication = async () => {
274
359
  try {
275
- await cancelSingletonScheduledPublish({ data: { singleton: path } })
360
+ mutation.assertWritable()
361
+ const result = await cancelSingletonScheduledPublish({
362
+ data: { expectedRevision: mutation.expectedRevision(), singleton: path },
363
+ })
364
+ mutation.adopt({ ...result, documentId: String(document?.id) })
276
365
  toast(
277
366
  t('scheduledPublication.toast.cancelledTitle'),
278
367
  t('scheduledPublication.toast.cancelledDescription'),
@@ -280,7 +369,14 @@ export function SingletonView({
280
369
  )
281
370
  await reload()
282
371
  } catch (err) {
283
- toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
372
+ const handled = mutation.report(err)
373
+ if (handled === 'blocked') throw err
374
+ if (handled === 'committed') {
375
+ notifyCommittedHookFailure()
376
+ await reload()
377
+ return
378
+ }
379
+ toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
284
380
  throw err
285
381
  }
286
382
  }
@@ -289,6 +385,10 @@ export function SingletonView({
289
385
  <Section>
290
386
  <Container>
291
387
  <FormRenderer
388
+ mutationIssue={mutation.issue}
389
+ mutationsBlocked={mutation.blocked}
390
+ observedRevision={mutation.revision}
391
+ onMutationError={mutation.report}
292
392
  mode={document == null ? 'create' : 'edit'}
293
393
  fields={fields}
294
394
  onSubmit={handleSubmit}
@@ -44,12 +44,11 @@ const uploadField: UploadFieldFn = (collection, formData, createDocument) =>
44
44
  serverUploadField(collection, formData, createDocument)
45
45
 
46
46
  const placeTreeNode: PlaceTreeNodeFn = async (input) => {
47
- const { orderKey } = await serverPlaceTreeNode({ data: input })
48
- return { orderKey }
47
+ return serverPlaceTreeNode({ data: input })
49
48
  }
50
49
 
51
50
  const removeFromTree: RemoveFromTreeFn = async (input) => {
52
- await serverRemoveFromTree({ data: input })
51
+ return serverRemoveFromTree({ data: input })
53
52
  }
54
53
 
55
54
  const getTreeAncestors: GetTreeAncestorsFn = (input) =>
@@ -152,6 +152,7 @@ export function createCollectionEditRoute(path: string) {
152
152
  ]}
153
153
  />
154
154
  <EditView
155
+ key={`${collection}/${id}/${locale}`}
155
156
  collectionDefinition={collectionDef}
156
157
  adminConfig={adminConfig ?? undefined}
157
158
  initialData={data}
@@ -1,3 +1,4 @@
1
+ import { parseDocumentRevision } from '@byline/core'
1
2
  /**
2
3
  * This Source Code is subject to the terms of the Mozilla Public
3
4
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -177,8 +178,13 @@ export function createCollectionListRoute(path: string) {
177
178
  beforeDocumentId,
178
179
  afterDocumentId,
179
180
  }) => {
180
- await placeTreeNode({
181
+ return await placeTreeNode({
181
182
  data: {
183
+ expectedRevision: parseDocumentRevision(
184
+ (data as { rows: CollectionTreeRow[] }).rows.find(
185
+ (row) => row.id === documentId
186
+ )?.revision
187
+ ),
182
188
  collection,
183
189
  documentId,
184
190
  parentDocumentId,
@@ -199,8 +205,13 @@ export function createCollectionListRoute(path: string) {
199
205
  orderable={collectionDef.orderable === true}
200
206
  listActions={adminConfig?.listActions}
201
207
  onReorder={async ({ documentId, beforeDocumentId, afterDocumentId }) => {
202
- await reorderCollectionDocument({
208
+ return await reorderCollectionDocument({
203
209
  data: {
210
+ expectedRevision: parseDocumentRevision(
211
+ data.docs.find(
212
+ (row: { id: string; revision?: number }) => row.id === documentId
213
+ )?.revision
214
+ ),
204
215
  collection,
205
216
  documentId,
206
217
  beforeDocumentId,
@@ -1,3 +1,4 @@
1
+ import { testAdapter } from '../../../core/src/storage/db-adapter.test-helper.js'
1
2
  /**
2
3
  * This Source Code is subject to the terms of the Mozilla Public
3
4
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -61,7 +62,7 @@ vi.mock('@byline/core', () => ({
61
62
  getServerConfig: () => ({
62
63
  i18n: { content: { defaultLocale: 'en' } },
63
64
  scheduledPublication: { enabled: mocks.scheduledPublicationEnabled },
64
- db: { queries: { documents: { getPublishedVersion: mocks.getPublishedVersion } } },
65
+ db: testAdapter({ queries: { documents: { getPublishedVersion: mocks.getPublishedVersion } } }),
65
66
  }),
66
67
  getSingletonAdminConfig: () => null,
67
68
  }))
@@ -112,6 +113,7 @@ describe('singleton editor route loader', () => {
112
113
 
113
114
  await expect(load('fr')).resolves.toEqual({
114
115
  document: null,
116
+ expectedEmpty: true,
115
117
  initialData: { title: 'Default title' },
116
118
  })
117
119
  expect(mocks.read).toHaveBeenCalledWith({
@@ -130,6 +132,12 @@ describe('singleton editor route loader', () => {
130
132
  versionId: 'version-current',
131
133
  status: 'draft',
132
134
  fields: { title: 'Loaded' },
135
+ revision: 7,
136
+ scheduledPublication: {
137
+ state: 'armed',
138
+ publishAt: '2026-09-01T00:00:00.000Z',
139
+ targetVersionId: 'version-current',
140
+ },
133
141
  })
134
142
  mocks.getPublishedVersion.mockResolvedValue({
135
143
  document_id: 'document-settings',
@@ -167,6 +175,6 @@ describe('singleton editor route loader', () => {
167
175
  collection_id: 'collection-settings',
168
176
  document_id: 'document-settings',
169
177
  })
170
- expect(mocks.getScheduledPublish).toHaveBeenCalledTimes(1)
178
+ expect(mocks.getScheduledPublish).not.toHaveBeenCalled()
171
179
  })
172
180
  })
@@ -10,7 +10,7 @@ import { createFileRoute, notFound } from '@tanstack/react-router'
10
10
  import { createServerFn } from '@tanstack/react-start'
11
11
 
12
12
  import type { DocumentPublishScheduleInfo } from '@byline/client'
13
- import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
13
+ import { getAdminRequestContext } from '@byline/client/server'
14
14
  import {
15
15
  buildInitialDataFromFields,
16
16
  documentAbilityKey,
@@ -51,6 +51,7 @@ export const getSingletonRouteDocument = createServerFn({ method: 'GET' })
51
51
  if (document == null) {
52
52
  return {
53
53
  document: null,
54
+ expectedEmpty: true,
54
55
  initialData: serialise(
55
56
  await buildInitialDataFromFields(definition.fields, {
56
57
  locale: data.locale ?? getServerConfig().i18n.content.defaultLocale,
@@ -93,9 +94,9 @@ export const getSingletonRouteDocument = createServerFn({ method: 'GET' })
93
94
  actor?.hasAbility(documentAbilityKey(definition, 'changeStatus')) === true &&
94
95
  actor.hasAbility(documentAbilityKey(definition, 'publish'))
95
96
  if (canSchedulePublication) {
96
- scheduledPublish = await getAdminBylineClient()
97
- .singleton(data.singleton)
98
- .getScheduledPublish()
97
+ scheduledPublish = (
98
+ document as unknown as { scheduledPublication: DocumentPublishScheduleInfo | null }
99
+ ).scheduledPublication
99
100
  }
100
101
  }
101
102
  const serializedSchedule = scheduledPublish == null ? null : serialise(scheduledPublish)
@@ -159,6 +160,7 @@ export function createSingletonRoute(path: string) {
159
160
  ]}
160
161
  />
161
162
  <SingletonView
163
+ key={`${singleton}/${locale}`}
162
164
  singletonDefinition={definition}
163
165
  adminConfig={getSingletonAdminConfig(singleton) ?? undefined}
164
166
  document={routeState.document}