@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
@@ -1,3 +1,4 @@
1
+ import { withDocumentMutationErrors } from '../document-mutation-errors.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
@@ -10,23 +11,34 @@ import { createServerFn } from '@tanstack/react-start'
10
11
 
11
12
  import { getAdminBylineClient } from '@byline/client/server'
12
13
 
14
+ import { toCommittedDocumentHookFailureResponse } from '../collections/save-outcome.js'
13
15
  import { serialise } from '../serialise.js'
14
16
 
15
17
  export const copySingletonToLocale = createServerFn({ method: 'POST' })
16
18
  .validator(
17
19
  (input: {
20
+ expectedRevision: number
18
21
  singleton: string
19
22
  sourceLocale: string
20
23
  targetLocale: string
21
24
  overwrite?: boolean
22
25
  }) => input
23
26
  )
24
- .handler(async ({ data }) =>
25
- serialise(
26
- await getAdminBylineClient().singleton(data.singleton).copyToLocale({
27
- sourceLocale: data.sourceLocale,
28
- targetLocale: data.targetLocale,
29
- overwrite: data.overwrite,
30
- })
31
- )
27
+ .handler(
28
+ withDocumentMutationErrors(async ({ data }) => {
29
+ try {
30
+ return serialise(
31
+ await getAdminBylineClient().singleton(data.singleton).copyToLocale({
32
+ expectedRevision: data.expectedRevision,
33
+ sourceLocale: data.sourceLocale,
34
+ targetLocale: data.targetLocale,
35
+ overwrite: data.overwrite,
36
+ })
37
+ )
38
+ } catch (error) {
39
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
40
+ if (committedFailure != null) return committedFailure
41
+ throw error
42
+ }
43
+ })
32
44
  )
@@ -23,9 +23,9 @@ export interface GetSingletonInput {
23
23
  * - this shared host layer owns lenient reads, raw missing-locale values,
24
24
  * relation-summary population, transport serialization, and preservation of
25
25
  * restore warnings and locale metadata;
26
- * - the route loader composes published-version metadata plus scheduled-
27
- * publication enablement, capability, and schedule aliases because those
28
- * require host-only database and actor context beyond the singleton handle.
26
+ * - the route loader composes the informational published-version badge, feature
27
+ * enablement, capability, and schedule aliases. Schedule state itself comes
28
+ * from the same editable snapshot as the source document.
29
29
  */
30
30
  export const getSingleton = createServerFn({ method: 'GET' })
31
31
  .validator((input: GetSingletonInput) => input)
@@ -1,3 +1,4 @@
1
+ import { withDocumentMutationErrors } from '../document-mutation-errors.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
@@ -10,10 +11,23 @@ import { createServerFn } from '@tanstack/react-start'
10
11
 
11
12
  import { getAdminBylineClient } from '@byline/client/server'
12
13
 
14
+ import { toCommittedDocumentHookFailureResponse } from '../collections/save-outcome.js'
13
15
  import { serialise } from '../serialise.js'
14
16
 
15
17
  export const restoreSingletonVersion = createServerFn({ method: 'POST' })
16
- .validator((input: { singleton: string; versionId: string }) => input)
17
- .handler(async ({ data }) =>
18
- serialise(await getAdminBylineClient().singleton(data.singleton).restoreVersion(data.versionId))
18
+ .validator((input: { expectedRevision: number; singleton: string; versionId: string }) => input)
19
+ .handler(
20
+ withDocumentMutationErrors(async ({ data }) => {
21
+ try {
22
+ return serialise(
23
+ await getAdminBylineClient()
24
+ .singleton(data.singleton)
25
+ .restoreVersion(data.versionId, { expectedRevision: data.expectedRevision })
26
+ )
27
+ } catch (error) {
28
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
29
+ if (committedFailure != null) return committedFailure
30
+ throw error
31
+ }
32
+ })
19
33
  )
@@ -1,3 +1,4 @@
1
+ import { withDocumentMutationErrors } from '../document-mutation-errors.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
@@ -13,12 +14,22 @@ import { getAdminBylineClient } from '@byline/client/server'
13
14
  import { serialise } from '../serialise.js'
14
15
 
15
16
  export const scheduleSingletonPublish = createServerFn({ method: 'POST' })
16
- .validator((input: { singleton: string; publishAt: string; expectedVersionId: string }) => input)
17
- .handler(async ({ data }) =>
18
- serialise(
19
- await getAdminBylineClient().singleton(data.singleton).schedulePublish({
20
- publishAt: data.publishAt,
21
- expectedVersionId: data.expectedVersionId,
22
- })
17
+ .validator(
18
+ (input: {
19
+ expectedRevision: number
20
+ singleton: string
21
+ publishAt: string
22
+ expectedVersionId: string
23
+ }) => input
24
+ )
25
+ .handler(
26
+ withDocumentMutationErrors(async ({ data }) =>
27
+ serialise(
28
+ await getAdminBylineClient().singleton(data.singleton).schedulePublish({
29
+ publishAt: data.publishAt,
30
+ expectedRevision: data.expectedRevision,
31
+ expectedVersionId: data.expectedVersionId,
32
+ })
33
+ )
23
34
  )
24
35
  )
@@ -6,6 +6,12 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import {
10
+ ERR_DOCUMENT_HOOK_COMMITTED,
11
+ ERR_LOCK_CONFLICT,
12
+ ErrorCodes,
13
+ getLockConflictDetails,
14
+ } from '@byline/core'
9
15
  import { beforeEach, describe, expect, it, vi } from 'vitest'
10
16
 
11
17
  const mocks = vi.hoisted(() => ({
@@ -56,7 +62,7 @@ const invoke = (serverFunction: unknown, data: Record<string, any>) =>
56
62
  (serverFunction as ServerFunction)({ data })
57
63
 
58
64
  const handle = {
59
- get: vi.fn(),
65
+ getForEdit: vi.fn(),
60
66
  update: vi.fn(),
61
67
  changeStatus: vi.fn(),
62
68
  unpublish: vi.fn(),
@@ -83,14 +89,13 @@ describe('singleton server functions', () => {
83
89
  const cases = [
84
90
  {
85
91
  serverFunction: getSingleton,
86
- method: 'get',
92
+ method: 'getForEdit',
87
93
  input: { singleton: 'site-settings', locale: 'fr' },
88
94
  args: [
89
95
  {
90
96
  locale: 'fr',
91
97
  populate: undefined,
92
98
  depth: undefined,
93
- status: 'any',
94
99
  onMissingLocale: 'empty',
95
100
  lenient: true,
96
101
  },
@@ -104,23 +109,23 @@ describe('singleton server functions', () => {
104
109
  singleton: 'site-settings',
105
110
  data: { title: 'Changed' },
106
111
  locale: 'fr',
107
- expectedVersionId: 'version-1',
112
+ expectedRevision: 7,
108
113
  },
109
- args: [{ title: 'Changed' }, { locale: 'fr', expectedVersionId: 'version-1' }],
114
+ args: [{ title: 'Changed' }, { locale: 'fr', expectedRevision: 7 }],
110
115
  result: { versionId: 'version-2' },
111
116
  },
112
117
  {
113
118
  serverFunction: changeSingletonStatus,
114
119
  method: 'changeStatus',
115
- input: { singleton: 'site-settings', status: 'published' },
116
- args: ['published'],
120
+ input: { singleton: 'site-settings', status: 'published', expectedRevision: 7 },
121
+ args: ['published', { expectedRevision: 7 }],
117
122
  result: { newStatus: 'published' },
118
123
  },
119
124
  {
120
125
  serverFunction: unpublishSingleton,
121
126
  method: 'unpublish',
122
- input: { singleton: 'site-settings' },
123
- args: [],
127
+ input: { singleton: 'site-settings', expectedRevision: 7 },
128
+ args: [{ expectedRevision: 7 }],
124
129
  result: { archivedCount: 1 },
125
130
  },
126
131
  {
@@ -129,11 +134,13 @@ describe('singleton server functions', () => {
129
134
  input: {
130
135
  singleton: 'site-settings',
131
136
  publishAt: '2026-09-01T10:00:00Z',
137
+ expectedRevision: 7,
132
138
  expectedVersionId: 'version-2',
133
139
  },
134
140
  args: [
135
141
  {
136
142
  publishAt: '2026-09-01T10:00:00Z',
143
+ expectedRevision: 7,
137
144
  expectedVersionId: 'version-2',
138
145
  },
139
146
  ],
@@ -142,15 +149,15 @@ describe('singleton server functions', () => {
142
149
  {
143
150
  serverFunction: confirmSingletonScheduledPublish,
144
151
  method: 'confirmScheduledPublish',
145
- input: { singleton: 'site-settings', expectedVersionId: 'version-3' },
146
- args: [{ expectedVersionId: 'version-3' }],
152
+ input: { singleton: 'site-settings', expectedRevision: 7, expectedVersionId: 'version-3' },
153
+ args: [{ expectedRevision: 7, expectedVersionId: 'version-3' }],
147
154
  result: { state: 'armed' },
148
155
  },
149
156
  {
150
157
  serverFunction: cancelSingletonScheduledPublish,
151
158
  method: 'cancelScheduledPublish',
152
- input: { singleton: 'site-settings' },
153
- args: [],
159
+ input: { singleton: 'site-settings', expectedRevision: 7 },
160
+ args: [{ expectedRevision: 7 }],
154
161
  result: { state: 'cancelled' },
155
162
  },
156
163
  {
@@ -184,8 +191,8 @@ describe('singleton server functions', () => {
184
191
  {
185
192
  serverFunction: restoreSingletonVersion,
186
193
  method: 'restoreVersion',
187
- input: { singleton: 'site-settings', versionId: 'version-1' },
188
- args: ['version-1'],
194
+ input: { singleton: 'site-settings', versionId: 'version-1', expectedRevision: 7 },
195
+ args: ['version-1', { expectedRevision: 7 }],
189
196
  result: { versionId: 'version-4' },
190
197
  },
191
198
  {
@@ -193,18 +200,23 @@ describe('singleton server functions', () => {
193
200
  method: 'copyToLocale',
194
201
  input: {
195
202
  singleton: 'site-settings',
203
+ expectedRevision: 7,
196
204
  sourceLocale: 'en',
197
205
  targetLocale: 'fr',
198
206
  overwrite: true,
199
207
  },
200
- args: [{ sourceLocale: 'en', targetLocale: 'fr', overwrite: true }],
208
+ args: [{ expectedRevision: 7, sourceLocale: 'en', targetLocale: 'fr', overwrite: true }],
201
209
  result: { versionId: 'version-5' },
202
210
  },
203
211
  ] as const
204
212
 
205
213
  for (const testCase of cases) {
206
214
  const method = handle[testCase.method]
207
- method.mockResolvedValueOnce(testCase.result)
215
+ method.mockResolvedValueOnce(
216
+ testCase.method === 'getForEdit'
217
+ ? { state: 'document', document: testCase.result }
218
+ : testCase.result
219
+ )
208
220
 
209
221
  const result = await invoke(testCase.serverFunction, testCase.input)
210
222
 
@@ -218,24 +230,59 @@ describe('singleton server functions', () => {
218
230
  }
219
231
  })
220
232
 
221
- it('forwards the optimistic version to update unchanged', async () => {
233
+ it('forwards the observed revision to update unchanged', async () => {
222
234
  handle.update.mockResolvedValueOnce({ versionId: 'version-next' })
223
235
 
224
236
  await invoke(updateSingleton, {
225
237
  singleton: 'site-settings',
226
238
  data: { title: 'Changed' },
227
- expectedVersionId: 'version-current',
239
+ expectedRevision: 7,
228
240
  })
229
241
 
230
242
  expect(handle.update).toHaveBeenCalledWith(
231
243
  { title: 'Changed' },
232
- { locale: undefined, expectedVersionId: 'version-current' }
244
+ { locale: undefined, expectedRevision: 7 }
233
245
  )
234
246
  })
235
247
 
248
+ it.each([
249
+ ['update', updateSingleton, { singleton: 'site-settings', data: { title: 'Changed' } }],
250
+ [
251
+ 'copyToLocale',
252
+ copySingletonToLocale,
253
+ { singleton: 'site-settings', sourceLocale: 'en', targetLocale: 'fr' },
254
+ ],
255
+ [
256
+ 'restoreVersion',
257
+ restoreSingletonVersion,
258
+ { singleton: 'site-settings', versionId: 'version-1' },
259
+ ],
260
+ ] as const)('returns a committed hook outcome for %s', async (method, serverFunction, input) => {
261
+ handle[method].mockRejectedValueOnce(
262
+ ERR_DOCUMENT_HOOK_COMMITTED({
263
+ message: 'write committed',
264
+ details: {
265
+ phase: 'afterSave',
266
+ documentId: 'doc-settings',
267
+ documentVersionId: 'version-next',
268
+ revision: 8,
269
+ sideEffectCode: ErrorCodes.UNHANDLED,
270
+ },
271
+ })
272
+ )
273
+
274
+ await expect(invoke(serverFunction, input)).resolves.toEqual({
275
+ status: 'committed-hook-failed',
276
+ documentId: 'doc-settings',
277
+ documentVersionId: 'version-next',
278
+ revision: 8,
279
+ sideEffectFailure: { phase: 'afterSave', code: ErrorCodes.UNHANDLED },
280
+ })
281
+ })
282
+
236
283
  it('preserves authentication failures from the handle', async () => {
237
284
  const error = { code: 'ERR_UNAUTHENTICATED', message: 'sign in required' }
238
- handle.get.mockRejectedValueOnce(error)
285
+ handle.getForEdit.mockRejectedValueOnce(error)
239
286
 
240
287
  await expect(invoke(getSingleton, { singleton: 'site-settings' })).rejects.toBe(error)
241
288
  })
@@ -267,9 +314,34 @@ describe('singleton server functions', () => {
267
314
  expect(mocks.resolveActorLabels).toHaveBeenCalledWith([])
268
315
  })
269
316
 
317
+ it('preserves confirmed lock failures as safe wire data through the host', async () => {
318
+ const conflict = ERR_LOCK_CONFLICT({
319
+ message: 'private diagnostic',
320
+ cause: new Error('driver SQL'),
321
+ })
322
+ handle.update.mockRejectedValueOnce(conflict)
323
+ let received: unknown
324
+ try {
325
+ await invoke(updateSingleton, {
326
+ singleton: 'site-settings',
327
+ data: { title: 'Changed' },
328
+ expectedRevision: 6,
329
+ })
330
+ } catch (error) {
331
+ received = error
332
+ }
333
+ expect(received).not.toHaveProperty('cause')
334
+ expect(getLockConflictDetails(JSON.parse(JSON.stringify(received)))).toEqual({
335
+ reason: 'lock_conflict',
336
+ rolledBack: true,
337
+ retryable: true,
338
+ })
339
+ expect(handle.update).toHaveBeenCalledTimes(1)
340
+ })
341
+
270
342
  it('keeps missing-slot and stale-write failures distinguishable', async () => {
271
343
  const missing = { code: 'ERR_NOT_FOUND', message: 'slot is empty' }
272
- const stale = { code: 'ERR_CONFLICT', message: 'version changed' }
344
+ const stale = { code: 'ERR_DOCUMENT_STALE', message: 'document changed' }
273
345
  handle.changeStatus.mockRejectedValueOnce(missing)
274
346
  handle.update.mockRejectedValueOnce(stale)
275
347
 
@@ -280,9 +352,9 @@ describe('singleton server functions', () => {
280
352
  invoke(updateSingleton, {
281
353
  singleton: 'site-settings',
282
354
  data: { title: 'Changed' },
283
- expectedVersionId: 'stale',
355
+ expectedRevision: 6,
284
356
  })
285
- ).rejects.toMatchObject({ code: 'ERR_CONFLICT' })
357
+ ).rejects.toMatchObject({ code: 'ERR_DOCUMENT_STALE' })
286
358
  })
287
359
 
288
360
  it('serialises schedule dates at the transport boundary', async () => {
@@ -1,3 +1,4 @@
1
+ import { withDocumentMutationErrors } from '../document-mutation-errors.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
@@ -13,7 +14,13 @@ import { getAdminBylineClient } from '@byline/client/server'
13
14
  import { serialise } from '../serialise.js'
14
15
 
15
16
  export const unpublishSingleton = createServerFn({ method: 'POST' })
16
- .validator((input: { singleton: string }) => input)
17
- .handler(async ({ data }) =>
18
- serialise(await getAdminBylineClient().singleton(data.singleton).unpublish())
17
+ .validator((input: { expectedRevision: number; singleton: string }) => input)
18
+ .handler(
19
+ withDocumentMutationErrors(async ({ data }) =>
20
+ serialise(
21
+ await getAdminBylineClient()
22
+ .singleton(data.singleton)
23
+ .unpublish({ expectedRevision: data.expectedRevision })
24
+ )
25
+ )
19
26
  )
@@ -1,3 +1,4 @@
1
+ import { withDocumentMutationErrors } from '../document-mutation-errors.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
@@ -9,23 +10,34 @@
9
10
  import { createServerFn } from '@tanstack/react-start'
10
11
 
11
12
  import { getAdminBylineClient } from '@byline/client/server'
13
+ import type { SingletonSavePrecondition } from '@byline/core'
12
14
 
15
+ import { toCommittedDocumentHookFailureResponse } from '../collections/save-outcome.js'
13
16
  import { serialise } from '../serialise.js'
14
17
 
15
- export interface UpdateSingletonInput {
18
+ export type UpdateSingletonInput = SingletonSavePrecondition & {
16
19
  singleton: string
17
20
  data: Record<string, any>
18
21
  locale?: string
19
- expectedVersionId?: string
20
22
  }
21
23
 
22
24
  export const updateSingleton = createServerFn({ method: 'POST' })
23
25
  .validator((input: UpdateSingletonInput) => input)
24
- .handler(async ({ data }) => {
25
- const { singleton, data: fields, locale, expectedVersionId } = data
26
- const result = await getAdminBylineClient().singleton(singleton).update(fields, {
27
- locale,
28
- expectedVersionId,
26
+ .handler(
27
+ withDocumentMutationErrors(async ({ data }) => {
28
+ const { singleton, data: fields, locale, ...precondition } = data
29
+ try {
30
+ const result = await getAdminBylineClient()
31
+ .singleton(singleton)
32
+ .update(fields, {
33
+ locale,
34
+ ...precondition,
35
+ })
36
+ return serialise(result)
37
+ } catch (error) {
38
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
39
+ if (committedFailure != null) return committedFailure
40
+ throw error
41
+ }
29
42
  })
30
- return serialise(result)
31
- })
43
+ )
@@ -0,0 +1,41 @@
1
+ import { ErrorCodes, isDocumentRevision, type StructuralMutationReceipt } from '@byline/core'
2
+
3
+ /** Copy only the authorized receipt fields emitted by the lifecycle service. */
4
+ export function structuralReceipt(value: unknown): StructuralMutationReceipt | null {
5
+ if (typeof value !== 'object' || value === null) return null
6
+ const documentId = Reflect.get(value, 'documentId')
7
+ const revision = Reflect.get(value, 'revision')
8
+ const suspended = Reflect.get(value, 'scheduledPublicationsNeedReconfirmation')
9
+ const affected = Reflect.get(value, 'affectedDocuments')
10
+ if (
11
+ typeof documentId !== 'string' ||
12
+ !isDocumentRevision(revision) ||
13
+ typeof suspended !== 'boolean' ||
14
+ !Array.isArray(affected)
15
+ )
16
+ return null
17
+ const affectedDocuments = []
18
+ for (const row of affected) {
19
+ if (typeof row !== 'object' || row === null) return null
20
+ const id = Reflect.get(row, 'documentId')
21
+ const token = Reflect.get(row, 'revision')
22
+ if (typeof id !== 'string' || !isDocumentRevision(token)) return null
23
+ affectedDocuments.push({ documentId: id, revision: token })
24
+ }
25
+ return {
26
+ documentId,
27
+ revision,
28
+ affectedDocuments,
29
+ scheduledPublicationsNeedReconfirmation: suspended,
30
+ }
31
+ }
32
+
33
+ export function committedTreeReceipt(error: unknown): StructuralMutationReceipt | null {
34
+ if (
35
+ typeof error !== 'object' ||
36
+ error === null ||
37
+ Reflect.get(error, 'code') !== ErrorCodes.TREE_HOOK_COMMITTED
38
+ )
39
+ return null
40
+ return structuralReceipt(Reflect.get(error, 'details'))
41
+ }