@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 { 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
@@ -7,12 +8,7 @@
7
8
  */
8
9
 
9
10
  import { AdminAuth, createRequestContext } from '@byline/auth'
10
- import {
11
- defineServerConfig,
12
- defineSingleton,
13
- type IDbAdapter,
14
- type IStorageProvider,
15
- } from '@byline/core'
11
+ import { defineServerConfig, defineSingleton, type IStorageProvider } from '@byline/core'
16
12
  import { defineLogger } from '@byline/core/logger'
17
13
  import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
18
14
 
@@ -112,7 +108,7 @@ beforeAll(() => {
112
108
  )
113
109
  defineServerConfig({
114
110
  collections: [singleton],
115
- db: {} as IDbAdapter,
111
+ db: testAdapter(),
116
112
  storage,
117
113
  i18n: {
118
114
  admin: { defaultLocale: 'en', locales: ['en'] },
@@ -14,6 +14,7 @@ import { getLogger, withLogContext } from '@byline/core/logger'
14
14
  import { uploadField as coreUploadField } from '@byline/core/services'
15
15
 
16
16
  import { ensureDocumentResource } from '../../integrations/api-utils.js'
17
+ import { withDocumentMutationErrors } from '../document-mutation-errors.js'
17
18
 
18
19
  /**
19
20
  * Result of an upload through the host transport. The legacy top-level
@@ -25,6 +26,7 @@ export interface UploadFieldResult {
25
26
  /** Present when the upload endpoint created a document (createDocument=true, the default). */
26
27
  documentId?: string
27
28
  documentVersionId?: string
29
+ revision?: number
28
30
  storedFile: StoredFileValue
29
31
  }
30
32
 
@@ -149,88 +151,90 @@ function resolveUploadField(
149
151
  */
150
152
  export const uploadCollectionField = createServerFn({ method: 'POST' })
151
153
  .validator(parseUploadFormData)
152
- .handler(async ({ data }) => {
153
- const { collectionPath, shouldCreateDocument, fieldName, file, fields } = data
154
- const logger = getLogger()
154
+ .handler(
155
+ withDocumentMutationErrors(async ({ data }) => {
156
+ const { collectionPath, shouldCreateDocument, fieldName, file, fields } = data
157
+ const logger = getLogger()
155
158
 
156
- return withLogContext(
157
- { domain: 'api', module: 'upload', function: 'uploadCollectionField' },
158
- async () => {
159
- // Upload is the one field transport shared by both document-resource
160
- // kinds. The core service selects the kind-aware ability and rejects
161
- // singleton document creation before hooks or storage; the admin form
162
- // always requests the field-only branch (`createDocument=false`).
163
- const config = await ensureDocumentResource(collectionPath)
164
- if (config == null) {
165
- throw ERR_NOT_FOUND(
166
- {
167
- message: 'Document resource not found.',
168
- details: { collectionPath },
169
- },
170
- uploadCollectionField
171
- ).log(logger)
172
- }
159
+ return withLogContext(
160
+ { domain: 'api', module: 'upload', function: 'uploadCollectionField' },
161
+ async () => {
162
+ // Upload is the one field transport shared by both document-resource
163
+ // kinds. The core service selects the kind-aware ability and rejects
164
+ // singleton document creation before hooks or storage; the admin form
165
+ // always requests the field-only branch (`createDocument=false`).
166
+ const config = await ensureDocumentResource(collectionPath)
167
+ if (config == null) {
168
+ throw ERR_NOT_FOUND(
169
+ {
170
+ message: 'Document resource not found.',
171
+ details: { collectionPath },
172
+ },
173
+ uploadCollectionField
174
+ ).log(logger)
175
+ }
173
176
 
174
- const serverConfig = getServerConfig()
175
- const targetField = resolveUploadField(config.definition, collectionPath, fieldName)
176
- // Per-field storage routing falls through to the site-wide default.
177
- const storage = targetField.upload?.storage ?? serverConfig.storage
178
- if (!storage) {
179
- throw new Error(
180
- `No storage provider configured for field '${targetField.name}' on collection ` +
181
- `'${collectionPath}'. Set either field.upload.storage or the site-wide ` +
182
- 'ServerConfig.storage.'
183
- )
184
- }
177
+ const serverConfig = getServerConfig()
178
+ const targetField = resolveUploadField(config.definition, collectionPath, fieldName)
179
+ // Per-field storage routing falls through to the site-wide default.
180
+ const storage = targetField.upload?.storage ?? serverConfig.storage
181
+ if (!storage) {
182
+ throw new Error(
183
+ `No storage provider configured for field '${targetField.name}' on collection ` +
184
+ `'${collectionPath}'. Set either field.upload.storage or the site-wide ` +
185
+ 'ServerConfig.storage.'
186
+ )
187
+ }
185
188
 
186
- let buffer: Buffer
187
- try {
188
- buffer = Buffer.from(await file.arrayBuffer())
189
- } catch (err: unknown) {
190
- logger.error({ err, collectionPath }, 'failed to read file buffer')
191
- throw new Error('Failed to read uploaded file.')
192
- }
189
+ let buffer: Buffer
190
+ try {
191
+ buffer = Buffer.from(await file.arrayBuffer())
192
+ } catch (err: unknown) {
193
+ logger.error({ err, collectionPath }, 'failed to read file buffer')
194
+ throw new Error('Failed to read uploaded file.')
195
+ }
193
196
 
194
- const ctx: FieldUploadContext = {
195
- db: serverConfig.db,
196
- definition: config.definition,
197
- collectionId: config.collection.id,
198
- collectionVersion: config.collection.version,
199
- collectionPath,
200
- fieldName: targetField.name,
201
- storage,
202
- logger,
203
- defaultLocale: serverConfig.i18n.content.defaultLocale,
204
- slugifier: serverConfig.slugifier,
205
- filenameSlugifier: serverConfig.uploads?.filenameSlugifier,
206
- requestContext: await getAdminRequestContext(),
207
- imageProcessor: {
208
- extractMeta: extractImageMeta,
209
- isBypassMimeType,
210
- generateVariants: ({ buffer, mimeType, storedFile, storage, upload, logger }) =>
211
- generateImageVariants(
212
- buffer,
213
- mimeType,
214
- storedFile,
215
- storage,
216
- upload.sizes ?? [],
217
- logger
218
- ),
219
- },
220
- }
197
+ const ctx: FieldUploadContext = {
198
+ db: serverConfig.db,
199
+ definition: config.definition,
200
+ collectionId: config.collection.id,
201
+ collectionVersion: config.collection.version,
202
+ collectionPath,
203
+ fieldName: targetField.name,
204
+ storage,
205
+ logger,
206
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
207
+ slugifier: serverConfig.slugifier,
208
+ filenameSlugifier: serverConfig.uploads?.filenameSlugifier,
209
+ requestContext: await getAdminRequestContext(),
210
+ imageProcessor: {
211
+ extractMeta: extractImageMeta,
212
+ isBypassMimeType,
213
+ generateVariants: ({ buffer, mimeType, storedFile, storage, upload, logger }) =>
214
+ generateImageVariants(
215
+ buffer,
216
+ mimeType,
217
+ storedFile,
218
+ storage,
219
+ upload.sizes ?? [],
220
+ logger
221
+ ),
222
+ },
223
+ }
221
224
 
222
- return coreUploadField(ctx, {
223
- buffer,
224
- originalFilename: file.name || 'upload',
225
- mimeType: file.type || 'application/octet-stream',
226
- fileSize: file.size,
227
- fields,
228
- shouldCreateDocument,
229
- locale: serverConfig.i18n.content.defaultLocale,
230
- })
231
- }
232
- )
233
- })
225
+ return coreUploadField(ctx, {
226
+ buffer,
227
+ originalFilename: file.name || 'upload',
228
+ mimeType: file.type || 'application/octet-stream',
229
+ fileSize: file.size,
230
+ fields,
231
+ shouldCreateDocument,
232
+ locale: serverConfig.i18n.content.defaultLocale,
233
+ })
234
+ }
235
+ )
236
+ })
237
+ )
234
238
 
235
239
  /**
236
240
  * Upload a file to an upload-enabled collection.
@@ -0,0 +1,67 @@
1
+ import {
2
+ BylineError,
3
+ ErrorCodes,
4
+ getDocumentRevisionValidationDetails,
5
+ getDocumentStaleDetails,
6
+ getLockConflictDetails,
7
+ } from '@byline/core'
8
+ import { getDocumentHookCommittedDetails } from '@byline/core/services'
9
+
10
+ import { committedTreeReceipt } from './structural-receipt.js'
11
+
12
+ /** Error subclasses lose custom properties in the server-function serializer.
13
+ * Throw only validated plain data for document failures; never transport causes,
14
+ * SQL, hook messages or an unverified current revision. */
15
+ export function documentMutationError(error: unknown): unknown {
16
+ const stale = getDocumentStaleDetails(error)
17
+ if (stale)
18
+ return {
19
+ code: ErrorCodes.DOCUMENT_STALE,
20
+ message: 'This document has changed. Reload before making changes.',
21
+ details: stale,
22
+ }
23
+ const validation = getDocumentRevisionValidationDetails(error)
24
+ if (validation)
25
+ return {
26
+ code: ErrorCodes.VALIDATION,
27
+ message: 'Reload this document before making changes.',
28
+ details: validation,
29
+ }
30
+ const lock = getLockConflictDetails(error)
31
+ if (lock)
32
+ return {
33
+ code: ErrorCodes.LOCK_CONFLICT,
34
+ message:
35
+ 'This change could not be saved because another operation was using the document. Reload before trying again.',
36
+ details: lock,
37
+ }
38
+ const tree = committedTreeReceipt(error)
39
+ if (tree)
40
+ return {
41
+ code: ErrorCodes.TREE_HOOK_COMMITTED,
42
+ message: 'The structure was saved, but a follow-up action failed.',
43
+ details: tree,
44
+ }
45
+ const committed = getDocumentHookCommittedDetails(error)
46
+ if (committed)
47
+ return {
48
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
49
+ message: 'The document was saved, but a follow-up action failed.',
50
+ details: committed,
51
+ }
52
+ if (error instanceof BylineError && error.code === ErrorCodes.NOT_FOUND)
53
+ return { code: ErrorCodes.NOT_FOUND, message: 'This document is no longer available.' }
54
+ return error
55
+ }
56
+
57
+ export function withDocumentMutationErrors<T, R>(
58
+ handler: (input: T) => Promise<R>
59
+ ): (input: T) => Promise<R> {
60
+ return async (input) => {
61
+ try {
62
+ return await handler(input)
63
+ } catch (error) {
64
+ throw documentMutationError(error)
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,211 @@
1
+ import { createRequire } from 'node:module'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import {
5
+ ERR_CONFLICT,
6
+ ERR_DOCUMENT_HOOK_COMMITTED,
7
+ ERR_DOCUMENT_STALE,
8
+ ERR_LOCK_CONFLICT,
9
+ ERR_NOT_FOUND,
10
+ ERR_TREE_HOOK_COMMITTED,
11
+ ERR_VALIDATION,
12
+ getDocumentRevisionValidationDetails,
13
+ getDocumentStaleDetails,
14
+ getLockConflictDetails,
15
+ } from '@byline/core'
16
+ import { getDocumentHookCommittedDetails } from '@byline/core/services'
17
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
18
+
19
+ import { documentMutationError, withDocumentMutationErrors } from './document-mutation-errors.js'
20
+
21
+ // Exercise the installed Start HTTP handler and its real Seroval protocol. Only
22
+ // manifest lookup and request-local response state are supplied by this harness;
23
+ // the production application's compiled actions are covered by browser tests.
24
+ const require = createRequire(import.meta.url)
25
+ const startRequire = createRequire(require.resolve('@tanstack/react-start/package.json'))
26
+ const serverPackage = startRequire.resolve('@tanstack/start-server-core/package.json')
27
+ const esm = join(dirname(serverPackage), 'src')
28
+ const serverRequire = createRequire(serverPackage)
29
+ let handler: (input: { request: Request; context: object; serverFnId: string }) => Promise<Response>
30
+ let seroval: {
31
+ toJSONAsync(value: unknown): Promise<unknown>
32
+ fromCrossJSON(value: unknown, options: object): unknown
33
+ }
34
+ let runWithStartContext: (context: object, fn: () => Promise<Response>) => Promise<Response>
35
+ let action: (input: { data: unknown }) => Promise<unknown>
36
+
37
+ beforeAll(async () => {
38
+ vi.doMock(join(esm, 'getServerFnById.ts'), () => ({ getServerFnById: async () => action }))
39
+ vi.doMock(join(esm, 'request-response.ts'), () => ({
40
+ getResponse: () => ({ status: 200, headers: new Headers() }),
41
+ }))
42
+ handler = (await import(join(esm, 'server-functions-handler.ts'))).handleServerAction
43
+ seroval = await import(serverRequire.resolve('seroval'))
44
+ runWithStartContext = (
45
+ await import(
46
+ join(
47
+ dirname(serverRequire.resolve('@tanstack/start-storage-context/package.json')),
48
+ 'dist/esm/index.js'
49
+ )
50
+ )
51
+ ).runWithStartContext
52
+ })
53
+
54
+ async function roundTrip(data: unknown, operation: (data: unknown) => Promise<unknown>) {
55
+ const guarded = withDocumentMutationErrors(operation)
56
+ action = async ({ data }) => {
57
+ try {
58
+ return { result: await guarded(data) }
59
+ } catch (error) {
60
+ return { error }
61
+ }
62
+ }
63
+ const request = new Request('http://localhost/_serverFn/document-mutation', {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json', 'x-tsr-serverFn': 'true' },
66
+ body: JSON.stringify(await seroval.toJSONAsync({ data })),
67
+ })
68
+ const response = await runWithStartContext(
69
+ {
70
+ request,
71
+ startOptions: {},
72
+ contextAfterGlobalMiddlewares: {},
73
+ executedRequestMiddlewares: new Set(),
74
+ handlerType: 'serverFn',
75
+ getRouter: () => {
76
+ throw new Error('Router not used')
77
+ },
78
+ },
79
+ () => handler({ request, context: {}, serverFnId: 'document-mutation' })
80
+ )
81
+ const wire = await response.text()
82
+ const decoded = seroval.fromCrossJSON(JSON.parse(wire), {})
83
+ if (typeof decoded !== 'object' || decoded === null) throw new Error('Expected wire envelope')
84
+ return {
85
+ value: { error: Reflect.get(decoded, 'error'), result: Reflect.get(decoded, 'result') },
86
+ wire,
87
+ }
88
+ }
89
+
90
+ const staleCases = [
91
+ { reason: 'revision_mismatch', documentId: 'doc', expectedRevision: 1, currentRevision: 2 },
92
+ {
93
+ reason: 'version_parent_mismatch',
94
+ documentId: 'doc',
95
+ previousVersionId: 'old',
96
+ currentVersionId: 'new',
97
+ },
98
+ {
99
+ reason: 'singleton_slot_changed',
100
+ singletonPath: 'settings',
101
+ expectedState: 'empty',
102
+ currentState: 'document',
103
+ },
104
+ ] as const
105
+
106
+ describe('document failures over Start HTTP serialization', () => {
107
+ for (const details of staleCases) {
108
+ it(`retains ${details.reason} and excludes diagnostics`, async () => {
109
+ const { value, wire } = await roundTrip({}, async () => {
110
+ throw ERR_DOCUMENT_STALE({
111
+ message: 'private driver detail',
112
+ details,
113
+ cause: new Error('SELECT secret'),
114
+ logExtra: { private: true },
115
+ })
116
+ })
117
+ expect(getDocumentStaleDetails(value.error)).toEqual(details)
118
+ expect(wire).not.toMatch(/private driver|SELECT secret|logExtra/)
119
+ })
120
+ }
121
+ for (const reason of ['missing_document_revision', 'invalid_document_revision'] as const) {
122
+ it(`retains reload-required validation: ${reason}`, async () => {
123
+ const { value } = await roundTrip({}, async () => {
124
+ throw ERR_VALIDATION({ message: 'internal', details: { reason } })
125
+ })
126
+ expect(getDocumentRevisionValidationDetails(value.error)).toEqual({ reason })
127
+ expect(getDocumentStaleDetails(value.error)).toBeNull()
128
+ })
129
+ }
130
+ it('retains a confirmed rollback without relabelling it stale', async () => {
131
+ const details = { reason: 'lock_conflict', rolledBack: true, retryable: true }
132
+ const { value, wire } = await roundTrip({}, async () => {
133
+ throw ERR_LOCK_CONFLICT({
134
+ message: 'driver message',
135
+ details,
136
+ cause: new Error('SQL private'),
137
+ })
138
+ })
139
+ expect(getLockConflictDetails(value.error)).toEqual(details)
140
+ expect(getDocumentStaleDetails(value.error)).toBeNull()
141
+ expect(wire).not.toMatch(/driver message|SQL private/)
142
+ })
143
+ it('keeps committed hook warnings separate and preserves their committed revision', async () => {
144
+ const details = {
145
+ phase: 'afterUpdate',
146
+ documentId: 'doc',
147
+ documentVersionId: 'version',
148
+ revision: 3,
149
+ sideEffectCode: 'ERR_UNHANDLED',
150
+ }
151
+ const { value, wire } = await roundTrip({}, async () => {
152
+ throw ERR_DOCUMENT_HOOK_COMMITTED({
153
+ message: 'private hook failure',
154
+ details,
155
+ cause: new Error('secret hook'),
156
+ })
157
+ })
158
+ expect(getDocumentHookCommittedDetails(value.error)).toEqual(details)
159
+ expect(getDocumentStaleDetails(value.error)).toBeNull()
160
+ expect(getLockConflictDetails(value.error)).toBeNull()
161
+ expect(wire).not.toMatch(/private hook failure|secret hook/)
162
+ })
163
+ it('round-trips safe integer observations and receipts as numbers', async () => {
164
+ const { value } = await roundTrip(
165
+ { expectedRevision: Number.MAX_SAFE_INTEGER - 1 },
166
+ async (data) => {
167
+ expect(data).toEqual({ expectedRevision: Number.MAX_SAFE_INTEGER - 1 })
168
+ return { documentId: 'doc', revision: Number.MAX_SAFE_INTEGER }
169
+ }
170
+ )
171
+ expect(value.result).toEqual({ documentId: 'doc', revision: Number.MAX_SAFE_INTEGER })
172
+ })
173
+ it('does not classify unrelated or malformed conflicts by message', () => {
174
+ const conflict = ERR_CONFLICT({ message: 'This document has changed.' })
175
+ expect(documentMutationError(conflict)).toBe(conflict)
176
+ const malformed = { code: 'ERR_DOCUMENT_STALE', details: { reason: 'revision_mismatch' } }
177
+ expect(documentMutationError(malformed)).toBe(malformed)
178
+ })
179
+ })
180
+
181
+ it('transports the authorized structural receipt on committed tree hook failure without diagnostics', async () => {
182
+ const receipt = {
183
+ documentId: 'doc',
184
+ revision: 3,
185
+ affectedDocuments: [{ documentId: 'allowed-sibling', revision: 4 }],
186
+ scheduledPublicationsNeedReconfirmation: true,
187
+ }
188
+ const { value, wire } = await roundTrip({}, async () => {
189
+ throw ERR_TREE_HOOK_COMMITTED({
190
+ message: 'private hook diagnostics',
191
+ cause: new Error('SQL secret'),
192
+ details: { ...receipt, change: 'reorder', affectedDocumentIds: ['private'], internal: true },
193
+ })
194
+ })
195
+ expect(value.error).toEqual({
196
+ code: 'ERR_TREE_HOOK_COMMITTED',
197
+ message: 'The structure was saved, but a follow-up action failed.',
198
+ details: receipt,
199
+ })
200
+ expect(wire).not.toMatch(/private|SQL secret|internal/)
201
+ })
202
+ it('transports unavailable-document errors without provider details', async () => {
203
+ const { value, wire } = await roundTrip({}, async () => {
204
+ throw ERR_NOT_FOUND({ message: 'private unavailable reason', details: { internal: true } })
205
+ })
206
+ expect(value.error).toEqual({
207
+ code: 'ERR_NOT_FOUND',
208
+ message: 'This document is no longer available.',
209
+ })
210
+ expect(wire).not.toMatch(/private|internal/)
211
+ })
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { withEditableRevision } from './editable-revision.js'
4
+
5
+ describe('editable response revision binding', () => {
6
+ const observed = { id: 'document', versionId: 'current', revision: 7 }
7
+ it('restores only the source revision without changing populated targets', () => {
8
+ const related = { id: 'related', versionId: 'published', fields: {} }
9
+ expect(
10
+ withEditableRevision({ id: 'document', versionId: 'current', fields: { related } }, observed)
11
+ ).toEqual({ id: 'document', versionId: 'current', revision: 7, fields: { related } })
12
+ expect(related).not.toHaveProperty('revision')
13
+ })
14
+ it.each([
15
+ { id: 'document', versionId: 'historical' },
16
+ { id: 'another-document', versionId: 'current' },
17
+ ])('rejects a transferred observation: %o', (document) => {
18
+ expect(() => withEditableRevision(document, observed)).toThrow('source observation')
19
+ })
20
+ it('rejects a row removed from the editable result', () => {
21
+ expect(() => withEditableRevision(observed, undefined)).toThrow('source observation')
22
+ })
23
+ })
@@ -0,0 +1,18 @@
1
+ import { ERR_DATABASE, isDocumentRevision } from '@byline/core'
2
+
3
+ /** Restore metadata stripped by schema parsing only onto the exact source version.
4
+ * Never attach by array position: filtering/reordering must not transfer tokens. */
5
+ export function withEditableRevision<T extends object>(
6
+ document: T,
7
+ observation: { id: string; versionId: string; revision: number } | undefined
8
+ ): T & { revision: number } {
9
+ if (
10
+ !observation ||
11
+ !isDocumentRevision(observation.revision) ||
12
+ Reflect.get(document, 'id') !== observation.id ||
13
+ Reflect.get(document, 'versionId') !== observation.versionId
14
+ ) {
15
+ throw ERR_DATABASE({ message: 'Editable response no longer matches its source observation' })
16
+ }
17
+ return { ...document, revision: observation.revision }
18
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { getAdminBylineClient } from '@byline/client/server'
10
- import { getCollectionDefinition } from '@byline/core'
10
+ import { ERR_NOT_FOUND, getCollectionDefinition } from '@byline/core'
11
11
 
12
12
  import { resolveAdminDocumentRead } from './admin-document-presentation.js'
13
13
  import { serialise } from './serialise.js'
@@ -27,6 +27,10 @@ export interface SingletonDocumentReadInput {
27
27
  */
28
28
  export async function readSingletonDocument(data: SingletonDocumentReadInput) {
29
29
  const handle = getAdminBylineClient().singleton(data.singleton)
30
- const { options } = resolveAdminDocumentRead(getCollectionDefinition(data.singleton), data)
31
- return serialise(await handle.get(options))
30
+ const {
31
+ options: { status: _status, ...options },
32
+ } = resolveAdminDocumentRead(getCollectionDefinition(data.singleton), data)
33
+ const snapshot = await handle.getForEdit(options)
34
+ if (snapshot == null) throw ERR_NOT_FOUND({ message: 'Singleton document is unavailable' })
35
+ return snapshot.state === 'empty' ? null : serialise(snapshot.document)
32
36
  }
@@ -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 cancelSingletonScheduledPublish = createServerFn({ method: 'POST' })
16
- .validator((input: { singleton: string }) => input)
17
- .handler(async ({ data }) =>
18
- serialise(await getAdminBylineClient().singleton(data.singleton).cancelScheduledPublish())
17
+ .validator((input: { expectedRevision: number; singleton: string }) => input)
18
+ .handler(
19
+ withDocumentMutationErrors(async ({ data }) =>
20
+ serialise(
21
+ await getAdminBylineClient()
22
+ .singleton(data.singleton)
23
+ .cancelScheduledPublish({ 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
@@ -13,7 +14,13 @@ import { getAdminBylineClient } from '@byline/client/server'
13
14
  import { serialise } from '../serialise.js'
14
15
 
15
16
  export const changeSingletonStatus = createServerFn({ method: 'POST' })
16
- .validator((input: { singleton: string; status: string }) => input)
17
- .handler(async ({ data }) =>
18
- serialise(await getAdminBylineClient().singleton(data.singleton).changeStatus(data.status))
17
+ .validator((input: { expectedRevision: number; singleton: string; status: string }) => input)
18
+ .handler(
19
+ withDocumentMutationErrors(async ({ data }) =>
20
+ serialise(
21
+ await getAdminBylineClient()
22
+ .singleton(data.singleton)
23
+ .changeStatus(data.status, { 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
@@ -13,11 +14,16 @@ import { getAdminBylineClient } from '@byline/client/server'
13
14
  import { serialise } from '../serialise.js'
14
15
 
15
16
  export const confirmSingletonScheduledPublish = createServerFn({ method: 'POST' })
16
- .validator((input: { singleton: string; expectedVersionId: string }) => input)
17
- .handler(async ({ data }) =>
18
- serialise(
19
- await getAdminBylineClient()
20
- .singleton(data.singleton)
21
- .confirmScheduledPublish({ expectedVersionId: data.expectedVersionId })
17
+ .validator(
18
+ (input: { expectedRevision: number; singleton: string; expectedVersionId: string }) => input
19
+ )
20
+ .handler(
21
+ withDocumentMutationErrors(async ({ data }) =>
22
+ serialise(
23
+ await getAdminBylineClient().singleton(data.singleton).confirmScheduledPublish({
24
+ expectedRevision: data.expectedRevision,
25
+ expectedVersionId: data.expectedVersionId,
26
+ })
27
+ )
22
28
  )
23
29
  )