@byline/host-tanstack-start 4.17.0 → 4.19.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 (52) hide show
  1. package/dist/admin-shell/chrome/router-pager.d.ts +7 -0
  2. package/dist/admin-shell/chrome/router-pager.js +13 -0
  3. package/dist/admin-shell/chrome/scroll-to-top.d.ts +20 -0
  4. package/dist/admin-shell/chrome/scroll-to-top.js +9 -0
  5. package/dist/admin-shell/collections/create.js +22 -5
  6. package/dist/admin-shell/collections/edit.js +85 -17
  7. package/dist/admin-shell/collections/restore-version-modal.js +14 -2
  8. package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
  9. package/dist/admin-shell/singletons/view.js +10 -4
  10. package/dist/server-fns/collections/copy-to-locale.d.ts +2 -1
  11. package/dist/server-fns/collections/copy-to-locale.js +13 -6
  12. package/dist/server-fns/collections/create.d.ts +1 -1
  13. package/dist/server-fns/collections/create.js +15 -7
  14. package/dist/server-fns/collections/delete-locale.d.ts +2 -1
  15. package/dist/server-fns/collections/delete-locale.js +11 -4
  16. package/dist/server-fns/collections/duplicate.d.ts +2 -1
  17. package/dist/server-fns/collections/duplicate.js +10 -3
  18. package/dist/server-fns/collections/index.d.ts +1 -0
  19. package/dist/server-fns/collections/index.js +1 -0
  20. package/dist/server-fns/collections/restore-version.d.ts +1 -1
  21. package/dist/server-fns/collections/restore-version.js +17 -10
  22. package/dist/server-fns/collections/save-outcome.d.ts +23 -0
  23. package/dist/server-fns/collections/save-outcome.js +18 -0
  24. package/dist/server-fns/collections/update.d.ts +3 -1
  25. package/dist/server-fns/collections/update.js +17 -7
  26. package/dist/server-fns/singletons/copy-to-locale.js +14 -5
  27. package/dist/server-fns/singletons/restore-version.js +10 -1
  28. package/dist/server-fns/singletons/update.js +12 -5
  29. package/package.json +10 -10
  30. package/src/admin-shell/chrome/router-pager.tsx +22 -0
  31. package/src/admin-shell/chrome/scroll-to-top.test.ts +56 -0
  32. package/src/admin-shell/chrome/scroll-to-top.ts +28 -0
  33. package/src/admin-shell/collections/create.tsx +22 -3
  34. package/src/admin-shell/collections/edit.tsx +63 -10
  35. package/src/admin-shell/collections/restore-version-modal.tsx +11 -2
  36. package/src/admin-shell/collections/save-navigation.test.tsx +384 -0
  37. package/src/admin-shell/singletons/history.test.tsx +43 -0
  38. package/src/admin-shell/singletons/view.test.tsx +27 -0
  39. package/src/admin-shell/singletons/view.tsx +35 -16
  40. package/src/server-fns/collections/copy-to-locale.ts +43 -29
  41. package/src/server-fns/collections/create.ts +16 -8
  42. package/src/server-fns/collections/delete-locale.ts +38 -24
  43. package/src/server-fns/collections/duplicate.ts +38 -24
  44. package/src/server-fns/collections/index.ts +1 -0
  45. package/src/server-fns/collections/restore-version.ts +16 -9
  46. package/src/server-fns/collections/save-outcome.test.node.ts +77 -0
  47. package/src/server-fns/collections/save-outcome.ts +55 -0
  48. package/src/server-fns/collections/update.ts +23 -8
  49. package/src/server-fns/singletons/copy-to-locale.ts +16 -9
  50. package/src/server-fns/singletons/restore-version.ts +12 -3
  51. package/src/server-fns/singletons/singletons.test.node.ts +34 -0
  52. package/src/server-fns/singletons/update.ts +12 -5
@@ -10,10 +10,11 @@ import { createServerFn } from '@tanstack/react-start'
10
10
 
11
11
  import { getAdminRequestContext } from '@byline/client/server'
12
12
  import { ERR_NOT_FOUND, getLogger, getServerConfig } from '@byline/core'
13
- import type { DocumentLifecycleContext } from '@byline/core/services'
13
+ import type { CreateDocumentResult, DocumentLifecycleContext } from '@byline/core/services'
14
14
  import { createDocument } from '@byline/core/services'
15
15
 
16
16
  import { ensureCollection } from '../../integrations/api-utils.js'
17
+ import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Create document
@@ -59,13 +60,20 @@ export const createCollectionDocument = createServerFn({ method: 'POST' })
59
60
  requestContext: await getAdminRequestContext(),
60
61
  }
61
62
 
62
- const result = await createDocument(ctx, {
63
- data: structuredClone(documentData),
64
- status: documentData.status,
65
- locale: locale ?? serverConfig.i18n.content.defaultLocale,
66
- path: explicitPath,
67
- availableLocales,
68
- })
63
+ let result: CreateDocumentResult
64
+ try {
65
+ result = await createDocument(ctx, {
66
+ data: structuredClone(documentData),
67
+ status: documentData.status,
68
+ locale: locale ?? serverConfig.i18n.content.defaultLocale,
69
+ path: explicitPath,
70
+ availableLocales,
71
+ })
72
+ } catch (error) {
73
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
74
+ if (committedFailure != null) return committedFailure
75
+ throw error
76
+ }
69
77
 
70
78
  // The new document's id is returned so the create view can navigate
71
79
  // straight to the edit view (create → edit).
@@ -14,6 +14,10 @@ import type { DeleteLocaleResult, DocumentLifecycleContext } from '@byline/core/
14
14
  import { deleteLocale } from '@byline/core/services'
15
15
 
16
16
  import { ensureCollection } from '../../integrations/api-utils.js'
17
+ import {
18
+ type CollectionDocumentCommittedHookFailureResponse,
19
+ toCommittedDocumentHookFailureResponse,
20
+ } from './save-outcome.js'
17
21
 
18
22
  // ---------------------------------------------------------------------------
19
23
  // Delete one content locale's data from a document
@@ -31,29 +35,39 @@ import { ensureCollection } from '../../integrations/api-utils.js'
31
35
  */
32
36
  export const deleteDocumentLocale = createServerFn({ method: 'POST' })
33
37
  .validator((input: { collection: string; id: string; locale: string }) => input)
34
- .handler(async ({ data: input }): Promise<DeleteLocaleResult> => {
35
- const { collection: path, id, locale } = input
36
- const logger = getLogger()
37
- const config = await ensureCollection(path)
38
- if (!config) {
39
- throw ERR_NOT_FOUND({
40
- message: 'Collection not found',
41
- details: { collectionPath: path },
42
- }).log(logger)
43
- }
38
+ .handler(
39
+ async ({
40
+ data: input,
41
+ }): Promise<DeleteLocaleResult | CollectionDocumentCommittedHookFailureResponse> => {
42
+ const { collection: path, id, locale } = input
43
+ const logger = getLogger()
44
+ const config = await ensureCollection(path)
45
+ if (!config) {
46
+ throw ERR_NOT_FOUND({
47
+ message: 'Collection not found',
48
+ details: { collectionPath: path },
49
+ }).log(logger)
50
+ }
44
51
 
45
- const serverConfig = getServerConfig()
46
- const ctx: DocumentLifecycleContext = {
47
- db: serverConfig.db,
48
- definition: config.definition,
49
- collectionId: config.collection.id,
50
- collectionVersion: config.collection.version,
51
- collectionPath: path,
52
- logger,
53
- defaultLocale: serverConfig.i18n.content.defaultLocale,
54
- slugifier: serverConfig.slugifier,
55
- requestContext: await getAdminRequestContext(),
56
- }
52
+ const serverConfig = getServerConfig()
53
+ const ctx: DocumentLifecycleContext = {
54
+ db: serverConfig.db,
55
+ definition: config.definition,
56
+ collectionId: config.collection.id,
57
+ collectionVersion: config.collection.version,
58
+ collectionPath: path,
59
+ logger,
60
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
61
+ slugifier: serverConfig.slugifier,
62
+ requestContext: await getAdminRequestContext(),
63
+ }
57
64
 
58
- return deleteLocale(ctx, { documentId: id, locale })
59
- })
65
+ try {
66
+ return await deleteLocale(ctx, { documentId: id, locale })
67
+ } catch (error) {
68
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
69
+ if (committedFailure != null) return committedFailure
70
+ throw error
71
+ }
72
+ }
73
+ )
@@ -14,6 +14,10 @@ import type { DocumentLifecycleContext, DuplicateDocumentResult } from '@byline/
14
14
  import { duplicateDocument } from '@byline/core/services'
15
15
 
16
16
  import { ensureCollection } from '../../integrations/api-utils.js'
17
+ import {
18
+ type CollectionDocumentCommittedHookFailureResponse,
19
+ toCommittedDocumentHookFailureResponse,
20
+ } from './save-outcome.js'
17
21
 
18
22
  // ---------------------------------------------------------------------------
19
23
  // Duplicate document
@@ -32,29 +36,39 @@ import { ensureCollection } from '../../integrations/api-utils.js'
32
36
  */
33
37
  export const duplicateCollectionDocument = createServerFn({ method: 'POST' })
34
38
  .validator((input: { collection: string; id: string }) => input)
35
- .handler(async ({ data: input }): Promise<DuplicateDocumentResult> => {
36
- const { collection: path, id: sourceDocumentId } = input
37
- const logger = getLogger()
38
- const config = await ensureCollection(path)
39
- if (!config) {
40
- throw ERR_NOT_FOUND({
41
- message: 'Collection not found',
42
- details: { collectionPath: path },
43
- }).log(logger)
44
- }
39
+ .handler(
40
+ async ({
41
+ data: input,
42
+ }): Promise<DuplicateDocumentResult | CollectionDocumentCommittedHookFailureResponse> => {
43
+ const { collection: path, id: sourceDocumentId } = input
44
+ const logger = getLogger()
45
+ const config = await ensureCollection(path)
46
+ if (!config) {
47
+ throw ERR_NOT_FOUND({
48
+ message: 'Collection not found',
49
+ details: { collectionPath: path },
50
+ }).log(logger)
51
+ }
45
52
 
46
- const serverConfig = getServerConfig()
47
- const ctx: DocumentLifecycleContext = {
48
- db: serverConfig.db,
49
- definition: config.definition,
50
- collectionId: config.collection.id,
51
- collectionVersion: config.collection.version,
52
- collectionPath: path,
53
- logger,
54
- defaultLocale: serverConfig.i18n.content.defaultLocale,
55
- slugifier: serverConfig.slugifier,
56
- requestContext: await getAdminRequestContext(),
57
- }
53
+ const serverConfig = getServerConfig()
54
+ const ctx: DocumentLifecycleContext = {
55
+ db: serverConfig.db,
56
+ definition: config.definition,
57
+ collectionId: config.collection.id,
58
+ collectionVersion: config.collection.version,
59
+ collectionPath: path,
60
+ logger,
61
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
62
+ slugifier: serverConfig.slugifier,
63
+ requestContext: await getAdminRequestContext(),
64
+ }
58
65
 
59
- return duplicateDocument(ctx, { sourceDocumentId })
60
- })
66
+ try {
67
+ return await duplicateDocument(ctx, { sourceDocumentId })
68
+ } catch (error) {
69
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
70
+ if (committedFailure != null) return committedFailure
71
+ throw error
72
+ }
73
+ }
74
+ )
@@ -18,6 +18,7 @@ export * from './list'
18
18
  export * from './reindex'
19
19
  export * from './reorder'
20
20
  export * from './restore-version'
21
+ export * from './save-outcome'
21
22
  export * from './scheduled-publication'
22
23
  export * from './set-list-view-preference'
23
24
  export * from './stats'
@@ -14,6 +14,7 @@ import type { DocumentLifecycleContext } from '@byline/core/services'
14
14
  import { restoreDocumentVersion as restoreDocumentVersionService } from '@byline/core/services'
15
15
 
16
16
  import { ensureCollection } from '../../integrations/api-utils.js'
17
+ import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Restore a historical document version as the new current version
@@ -45,15 +46,21 @@ export const restoreDocumentVersion = createServerFn({ method: 'POST' })
45
46
  requestContext: await getAdminRequestContext(),
46
47
  }
47
48
 
48
- const result = await restoreDocumentVersionService(ctx, {
49
- documentId: id,
50
- sourceVersionId: versionId,
51
- })
49
+ try {
50
+ const result = await restoreDocumentVersionService(ctx, {
51
+ documentId: id,
52
+ sourceVersionId: versionId,
53
+ })
52
54
 
53
- return {
54
- status: 'ok' as const,
55
- documentId: result.documentId,
56
- documentVersionId: result.documentVersionId,
57
- sourceVersionId: result.sourceVersionId,
55
+ return {
56
+ status: 'ok' as const,
57
+ documentId: result.documentId,
58
+ documentVersionId: result.documentVersionId,
59
+ sourceVersionId: result.sourceVersionId,
60
+ }
61
+ } catch (error) {
62
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
63
+ if (committedFailure != null) return committedFailure
64
+ throw error
58
65
  }
59
66
  })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { ERR_DOCUMENT_HOOK_COMMITTED, ErrorCodes } from '@byline/core'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import {
13
+ hasCommittedDocumentHookFailure,
14
+ toCommittedDocumentHookFailureResponse,
15
+ } from './save-outcome.js'
16
+
17
+ describe('collection save outcome transport', () => {
18
+ it('returns only allowlisted committed hook metadata', () => {
19
+ const error = ERR_DOCUMENT_HOOK_COMMITTED({
20
+ message: 'internal message',
21
+ cause: new Error('secret hook failure'),
22
+ details: {
23
+ phase: 'afterCreate',
24
+ documentId: 'doc-1',
25
+ documentVersionId: 'version-1',
26
+ sideEffectCode: ErrorCodes.STORAGE,
27
+ privatePath: '/private/cache/key',
28
+ },
29
+ })
30
+
31
+ const response = toCommittedDocumentHookFailureResponse(error)
32
+
33
+ expect(response).toEqual({
34
+ status: 'committed-hook-failed',
35
+ documentId: 'doc-1',
36
+ documentVersionId: 'version-1',
37
+ sideEffectFailure: { phase: 'afterCreate', code: 'ERR_STORAGE' },
38
+ })
39
+ expect(JSON.stringify(response)).not.toContain('secret hook failure')
40
+ expect(JSON.stringify(response)).not.toContain('/private/cache/key')
41
+ expect(hasCommittedDocumentHookFailure(response)).toBe(true)
42
+ })
43
+
44
+ it('accepts the singleton afterSave phase exported by core', () => {
45
+ const response = toCommittedDocumentHookFailureResponse(
46
+ ERR_DOCUMENT_HOOK_COMMITTED({
47
+ message: 'singleton committed',
48
+ details: {
49
+ phase: 'afterSave',
50
+ documentId: 'doc-singleton',
51
+ documentVersionId: 'version-singleton',
52
+ sideEffectCode: ErrorCodes.UNHANDLED,
53
+ },
54
+ })
55
+ )
56
+
57
+ expect(response?.sideEffectFailure).toEqual({
58
+ phase: 'afterSave',
59
+ code: ErrorCodes.UNHANDLED,
60
+ })
61
+ })
62
+
63
+ it('does not classify ordinary or malformed failures as committed', () => {
64
+ expect(toCommittedDocumentHookFailureResponse(new Error('write failed'))).toBeNull()
65
+ expect(
66
+ toCommittedDocumentHookFailureResponse({
67
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
68
+ message: 'missing ids',
69
+ details: {
70
+ phase: 'afterUpdate',
71
+ sideEffectCode: ErrorCodes.UNHANDLED,
72
+ },
73
+ })
74
+ ).toBeNull()
75
+ expect(hasCommittedDocumentHookFailure({ status: 'ok' })).toBe(false)
76
+ })
77
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import {
10
+ type DocumentHookCommittedPhase,
11
+ type DocumentHookSideEffectCode,
12
+ getDocumentHookCommittedDetails,
13
+ } from '@byline/core/services'
14
+
15
+ export interface CollectionDocumentCommittedHookFailureResponse {
16
+ status: 'committed-hook-failed'
17
+ documentId: string
18
+ documentVersionId: string
19
+ sideEffectFailure: {
20
+ phase: DocumentHookCommittedPhase
21
+ code: DocumentHookSideEffectCode
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Convert only validated core committed-hook errors into safe wire data. Raw
27
+ * hook errors, messages, stack traces, and storage paths remain server-side.
28
+ */
29
+ export function toCommittedDocumentHookFailureResponse(
30
+ error: unknown
31
+ ): CollectionDocumentCommittedHookFailureResponse | null {
32
+ const details = getDocumentHookCommittedDetails(error)
33
+ if (details == null) return null
34
+
35
+ return {
36
+ status: 'committed-hook-failed',
37
+ documentId: details.documentId,
38
+ documentVersionId: details.documentVersionId,
39
+ sideEffectFailure: {
40
+ phase: details.phase,
41
+ code: details.sideEffectCode,
42
+ },
43
+ }
44
+ }
45
+
46
+ export function hasCommittedDocumentHookFailure(
47
+ result: unknown
48
+ ): result is CollectionDocumentCommittedHookFailureResponse {
49
+ return (
50
+ typeof result === 'object' &&
51
+ result != null &&
52
+ 'status' in result &&
53
+ result.status === 'committed-hook-failed'
54
+ )
55
+ }
@@ -11,10 +11,14 @@ import { createServerFn } from '@tanstack/react-start'
11
11
  import { getAdminRequestContext } from '@byline/client/server'
12
12
  import { ERR_NOT_FOUND, getLogger, getServerConfig } from '@byline/core'
13
13
  import type { DocumentPatch } from '@byline/core/patches'
14
- import type { DocumentLifecycleContext } from '@byline/core/services'
14
+ import type {
15
+ DocumentLifecycleContext,
16
+ UpdateDocumentWithPatchesResult,
17
+ } from '@byline/core/services'
15
18
  import { updateDocumentSystemFields, updateDocumentWithPatches } from '@byline/core/services'
16
19
 
17
20
  import { ensureCollection } from '../../integrations/api-utils.js'
21
+ import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
18
22
 
19
23
  // ---------------------------------------------------------------------------
20
24
  // Apply patches (patch-based update — creates a new immutable version)
@@ -59,14 +63,25 @@ export const updateCollectionDocumentWithPatches = createServerFn({ method: 'POS
59
63
  requestContext: await getAdminRequestContext(),
60
64
  }
61
65
 
62
- await updateDocumentWithPatches(ctx, {
63
- documentId: id,
64
- patches,
65
- documentVersionId: versionId,
66
- locale: locale ?? serverConfig.i18n.content.defaultLocale,
67
- })
66
+ let result: UpdateDocumentWithPatchesResult
67
+ try {
68
+ result = await updateDocumentWithPatches(ctx, {
69
+ documentId: id,
70
+ patches,
71
+ documentVersionId: versionId,
72
+ locale: locale ?? serverConfig.i18n.content.defaultLocale,
73
+ })
74
+ } catch (error) {
75
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
76
+ if (committedFailure != null) return committedFailure
77
+ throw error
78
+ }
68
79
 
69
- return { status: 'ok' as const }
80
+ return {
81
+ status: 'ok' as const,
82
+ documentId: result.documentId,
83
+ documentVersionId: result.documentVersionId,
84
+ }
70
85
  })
71
86
 
72
87
  // ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ import { createServerFn } from '@tanstack/react-start'
10
10
 
11
11
  import { getAdminBylineClient } from '@byline/client/server'
12
12
 
13
+ import { toCommittedDocumentHookFailureResponse } from '../collections/save-outcome.js'
13
14
  import { serialise } from '../serialise.js'
14
15
 
15
16
  export const copySingletonToLocale = createServerFn({ method: 'POST' })
@@ -21,12 +22,18 @@ export const copySingletonToLocale = createServerFn({ method: 'POST' })
21
22
  overwrite?: boolean
22
23
  }) => input
23
24
  )
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
- )
32
- )
25
+ .handler(async ({ data }) => {
26
+ try {
27
+ return serialise(
28
+ await getAdminBylineClient().singleton(data.singleton).copyToLocale({
29
+ sourceLocale: data.sourceLocale,
30
+ targetLocale: data.targetLocale,
31
+ overwrite: data.overwrite,
32
+ })
33
+ )
34
+ } catch (error) {
35
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
36
+ if (committedFailure != null) return committedFailure
37
+ throw error
38
+ }
39
+ })
@@ -10,10 +10,19 @@ import { createServerFn } from '@tanstack/react-start'
10
10
 
11
11
  import { getAdminBylineClient } from '@byline/client/server'
12
12
 
13
+ import { toCommittedDocumentHookFailureResponse } from '../collections/save-outcome.js'
13
14
  import { serialise } from '../serialise.js'
14
15
 
15
16
  export const restoreSingletonVersion = createServerFn({ method: 'POST' })
16
17
  .validator((input: { singleton: string; versionId: string }) => input)
17
- .handler(async ({ data }) =>
18
- serialise(await getAdminBylineClient().singleton(data.singleton).restoreVersion(data.versionId))
19
- )
18
+ .handler(async ({ data }) => {
19
+ try {
20
+ return serialise(
21
+ await getAdminBylineClient().singleton(data.singleton).restoreVersion(data.versionId)
22
+ )
23
+ } catch (error) {
24
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
25
+ if (committedFailure != null) return committedFailure
26
+ throw error
27
+ }
28
+ })
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { ERR_DOCUMENT_HOOK_COMMITTED, ErrorCodes } from '@byline/core'
9
10
  import { beforeEach, describe, expect, it, vi } from 'vitest'
10
11
 
11
12
  const mocks = vi.hoisted(() => ({
@@ -233,6 +234,39 @@ describe('singleton server functions', () => {
233
234
  )
234
235
  })
235
236
 
237
+ it.each([
238
+ ['update', updateSingleton, { singleton: 'site-settings', data: { title: 'Changed' } }],
239
+ [
240
+ 'copyToLocale',
241
+ copySingletonToLocale,
242
+ { singleton: 'site-settings', sourceLocale: 'en', targetLocale: 'fr' },
243
+ ],
244
+ [
245
+ 'restoreVersion',
246
+ restoreSingletonVersion,
247
+ { singleton: 'site-settings', versionId: 'version-1' },
248
+ ],
249
+ ] as const)('returns a committed hook outcome for %s', async (method, serverFunction, input) => {
250
+ handle[method].mockRejectedValueOnce(
251
+ ERR_DOCUMENT_HOOK_COMMITTED({
252
+ message: 'write committed',
253
+ details: {
254
+ phase: 'afterSave',
255
+ documentId: 'doc-settings',
256
+ documentVersionId: 'version-next',
257
+ sideEffectCode: ErrorCodes.UNHANDLED,
258
+ },
259
+ })
260
+ )
261
+
262
+ await expect(invoke(serverFunction, input)).resolves.toEqual({
263
+ status: 'committed-hook-failed',
264
+ documentId: 'doc-settings',
265
+ documentVersionId: 'version-next',
266
+ sideEffectFailure: { phase: 'afterSave', code: ErrorCodes.UNHANDLED },
267
+ })
268
+ })
269
+
236
270
  it('preserves authentication failures from the handle', async () => {
237
271
  const error = { code: 'ERR_UNAUTHENTICATED', message: 'sign in required' }
238
272
  handle.get.mockRejectedValueOnce(error)
@@ -10,6 +10,7 @@ import { createServerFn } from '@tanstack/react-start'
10
10
 
11
11
  import { getAdminBylineClient } from '@byline/client/server'
12
12
 
13
+ import { toCommittedDocumentHookFailureResponse } from '../collections/save-outcome.js'
13
14
  import { serialise } from '../serialise.js'
14
15
 
15
16
  export interface UpdateSingletonInput {
@@ -23,9 +24,15 @@ export const updateSingleton = createServerFn({ method: 'POST' })
23
24
  .validator((input: UpdateSingletonInput) => input)
24
25
  .handler(async ({ data }) => {
25
26
  const { singleton, data: fields, locale, expectedVersionId } = data
26
- const result = await getAdminBylineClient().singleton(singleton).update(fields, {
27
- locale,
28
- expectedVersionId,
29
- })
30
- return serialise(result)
27
+ try {
28
+ const result = await getAdminBylineClient().singleton(singleton).update(fields, {
29
+ locale,
30
+ expectedVersionId,
31
+ })
32
+ return serialise(result)
33
+ } catch (error) {
34
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
35
+ if (committedFailure != null) return committedFailure
36
+ throw error
37
+ }
31
38
  })