@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,5 +1,14 @@
1
1
  import type { CollectionHandle, PlaceTreeNodeOptions, RemoveFromTreeOptions } from '@byline/client'
2
- import { ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER } from '@byline/core'
2
+ import {
3
+ ErrorCodes,
4
+ getDocumentRevisionValidationDetails,
5
+ getDocumentStaleDetails,
6
+ getLockConflictDetails,
7
+ TREE_HOOK_COMMITTED_MARKER,
8
+ TREE_PLACEMENT_STALE_MARKER,
9
+ } from '@byline/core'
10
+
11
+ import { structuralReceipt } from '../structural-receipt.js'
3
12
 
4
13
  function hasErrorCode(error: unknown, code: string): boolean {
5
14
  return (
@@ -27,11 +36,11 @@ export function isCommittedTreeHookFailure(error: unknown): boolean {
27
36
  }
28
37
 
29
38
  export type AdminTreeMoveOutcome =
30
- | { status: 'ok' }
39
+ | { status: 'ok'; receipt?: ReturnType<typeof structuralReceipt> }
31
40
  | { status: 'suppressed' }
32
41
  | { status: 'mutation-failed'; error: unknown; refreshError?: unknown }
33
42
  | { status: 'committed-hook-failed'; error: unknown; refreshError?: unknown }
34
- | { status: 'refresh-failed'; error: unknown }
43
+ | { status: 'refresh-failed'; error: unknown; receipt?: ReturnType<typeof structuralReceipt> }
35
44
 
36
45
  export interface AdminTreeMoveUiEffects {
37
46
  rollback: boolean
@@ -93,9 +102,17 @@ export async function executeAdminTreeMove(
93
102
  mutate: () => Promise<unknown>,
94
103
  refresh: () => Promise<unknown>
95
104
  ): Promise<AdminTreeMoveOutcome> {
105
+ let receipt: ReturnType<typeof structuralReceipt>
96
106
  try {
97
- await mutate()
107
+ receipt = structuralReceipt(await mutate())
98
108
  } catch (error) {
109
+ if (
110
+ getDocumentStaleDetails(error) ||
111
+ getDocumentRevisionValidationDetails(error) ||
112
+ getLockConflictDetails(error) ||
113
+ hasErrorCode(error, ErrorCodes.NOT_FOUND)
114
+ )
115
+ return { status: 'mutation-failed', error }
99
116
  const status = isCommittedTreeHookFailure(error)
100
117
  ? ('committed-hook-failed' as const)
101
118
  : ('mutation-failed' as const)
@@ -109,9 +126,9 @@ export async function executeAdminTreeMove(
109
126
 
110
127
  try {
111
128
  await refresh()
112
- return { status: 'ok' }
129
+ return { status: 'ok', ...(receipt ? { receipt } : {}) }
113
130
  } catch (error) {
114
- return { status: 'refresh-failed', error }
131
+ return { status: 'refresh-failed', error, ...(receipt ? { receipt } : {}) }
115
132
  }
116
133
  }
117
134
 
@@ -140,24 +157,24 @@ export function createAdminTreeMoveController(): AdminTreeMoveController {
140
157
  }
141
158
  }
142
159
 
143
- /** Admin retries reconcile post-commit tree hooks unless explicitly disabled. */
160
+ /** Request reconciliation for explicit caller actions; never retry or replace their revision. */
144
161
  export function placeAdminTreeNode(
145
162
  handle: Pick<CollectionHandle, 'placeTreeNode'>,
146
163
  documentId: string,
147
164
  options: PlaceTreeNodeOptions
148
- ): Promise<{ orderKey: string }> {
165
+ ): ReturnType<CollectionHandle['placeTreeNode']> {
149
166
  return handle.placeTreeNode(documentId, {
150
167
  ...options,
151
168
  reconcile: options.reconcile ?? true,
152
169
  })
153
170
  }
154
171
 
155
- /** Admin retries reconcile post-commit tree hooks unless explicitly disabled. */
172
+ /** Request reconciliation for explicit caller actions; never retry or replace their revision. */
156
173
  export function removeAdminTreeNode(
157
174
  handle: Pick<CollectionHandle, 'removeFromTree'>,
158
175
  documentId: string,
159
- options: RemoveFromTreeOptions = {}
160
- ): Promise<void> {
176
+ options: RemoveFromTreeOptions
177
+ ): ReturnType<CollectionHandle['removeFromTree']> {
161
178
  return handle.removeFromTree(documentId, {
162
179
  ...options,
163
180
  reconcile: options.reconcile ?? true,
@@ -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
@@ -21,7 +22,7 @@ import { createReadContext, ERR_NOT_FOUND, getLogger } from '@byline/core'
21
22
 
22
23
  import { ensureCollection } from '../../integrations/api-utils.js'
23
24
  import { placeAdminTreeNode, removeAdminTreeNode } from './tree-mutation.js'
24
- import { getAdminTreeParent, getAdminUnplacedTreeDocuments } from './tree-read.js'
25
+ import { getAdminTreeParent } from './tree-read.js'
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Place / move a node within the tree (place / reorder / re-parent)
@@ -30,6 +31,7 @@ import { getAdminTreeParent, getAdminUnplacedTreeDocuments } from './tree-read.j
30
31
  export const placeTreeNode = createServerFn({ method: 'POST' })
31
32
  .validator(
32
33
  (input: {
34
+ expectedRevision: number
33
35
  collection: string
34
36
  documentId: string
35
37
  parentDocumentId: string | null
@@ -38,45 +40,62 @@ export const placeTreeNode = createServerFn({ method: 'POST' })
38
40
  reconcile?: boolean
39
41
  }) => input
40
42
  )
41
- .handler(async ({ data }) => {
42
- const { collection: path, documentId } = data
43
- const config = await ensureCollection(path)
44
- if (!config) {
45
- throw ERR_NOT_FOUND({
46
- message: 'Collection not found',
47
- details: { collectionPath: path },
48
- }).log(getLogger())
49
- }
50
- const handle = getAdminBylineClient().collection(path)
51
- const result = await placeAdminTreeNode(handle, documentId, {
52
- parentDocumentId: data.parentDocumentId,
53
- beforeDocumentId: data.beforeDocumentId ?? null,
54
- afterDocumentId: data.afterDocumentId ?? null,
55
- reconcile: data.reconcile,
43
+ .handler(
44
+ withDocumentMutationErrors(async ({ data }) => {
45
+ const { collection: path, documentId } = data
46
+ const config = await ensureCollection(path)
47
+ if (!config) {
48
+ throw ERR_NOT_FOUND({
49
+ message: 'Collection not found',
50
+ details: { collectionPath: path },
51
+ }).log(getLogger())
52
+ }
53
+ const handle = getAdminBylineClient().collection(path)
54
+ const result = await placeAdminTreeNode(handle, documentId, {
55
+ expectedRevision: data.expectedRevision,
56
+ parentDocumentId: data.parentDocumentId,
57
+ beforeDocumentId: data.beforeDocumentId ?? null,
58
+ afterDocumentId: data.afterDocumentId ?? null,
59
+ reconcile: data.reconcile,
60
+ })
61
+ return { status: 'ok' as const, ...result }
56
62
  })
57
- return { status: 'ok' as const, orderKey: result.orderKey }
58
- })
63
+ )
59
64
 
60
65
  // ---------------------------------------------------------------------------
61
66
  // Remove a node from the tree (back to the unplaced state)
62
67
  // ---------------------------------------------------------------------------
63
68
 
64
69
  export const removeFromTree = createServerFn({ method: 'POST' })
65
- .validator((input: { collection: string; documentId: string; reconcile?: boolean }) => input)
66
- .handler(async ({ data }) => {
67
- const { collection: path, documentId } = data
68
- const config = await ensureCollection(path)
69
- if (!config) {
70
- throw ERR_NOT_FOUND({
71
- message: 'Collection not found',
72
- details: { collectionPath: path },
73
- }).log(getLogger())
74
- }
75
- await removeAdminTreeNode(getAdminBylineClient().collection(path), documentId, {
76
- reconcile: data.reconcile,
70
+ .validator(
71
+ (input: {
72
+ expectedRevision: number
73
+ collection: string
74
+ documentId: string
75
+ reconcile?: boolean
76
+ }) => input
77
+ )
78
+ .handler(
79
+ withDocumentMutationErrors(async ({ data }) => {
80
+ const { collection: path, documentId } = data
81
+ const config = await ensureCollection(path)
82
+ if (!config) {
83
+ throw ERR_NOT_FOUND({
84
+ message: 'Collection not found',
85
+ details: { collectionPath: path },
86
+ }).log(getLogger())
87
+ }
88
+ const result = await removeAdminTreeNode(
89
+ getAdminBylineClient().collection(path),
90
+ documentId,
91
+ {
92
+ expectedRevision: data.expectedRevision,
93
+ reconcile: data.reconcile,
94
+ }
95
+ )
96
+ return { status: 'ok' as const, ...result }
77
97
  })
78
- return { status: 'ok' as const }
79
- })
98
+ )
80
99
 
81
100
  // ---------------------------------------------------------------------------
82
101
  // Resolve a document's ancestor chain (root-first), hydrated with titles
@@ -84,30 +103,32 @@ export const removeFromTree = createServerFn({ method: 'POST' })
84
103
 
85
104
  export const getTreeAncestors = createServerFn({ method: 'GET' })
86
105
  .validator((input: { collection: string; documentId: string }) => input)
87
- .handler(async ({ data }) => {
88
- const { collection: path, documentId } = data
89
- const config = await ensureCollection(path)
90
- if (!config) {
91
- throw ERR_NOT_FOUND({
92
- message: 'Collection not found',
93
- details: { collectionPath: path },
94
- }).log(getLogger())
95
- }
96
- const useAsTitle = config.definition.useAsTitle
97
- // Admin context: read with `status: 'any'` so draft ancestors still show.
98
- const ancestors = await getAdminBylineClient()
99
- .collection(path)
100
- .getAncestors(documentId, { status: 'any' })
101
- return ancestors.map((doc) => {
102
- const fields = doc.fields as Record<string, any> | undefined
103
- const title = useAsTitle ? fields?.[useAsTitle] : undefined
104
- return {
105
- id: doc.id,
106
- title: typeof title === 'string' && title.length > 0 ? title : doc.path,
107
- path: doc.path,
106
+ .handler(
107
+ withDocumentMutationErrors(async ({ data }) => {
108
+ const { collection: path, documentId } = data
109
+ const config = await ensureCollection(path)
110
+ if (!config) {
111
+ throw ERR_NOT_FOUND({
112
+ message: 'Collection not found',
113
+ details: { collectionPath: path },
114
+ }).log(getLogger())
108
115
  }
116
+ const useAsTitle = config.definition.useAsTitle
117
+ // Admin context: read with `status: 'any'` so draft ancestors still show.
118
+ const ancestors = await getAdminBylineClient()
119
+ .collection(path)
120
+ .getAncestors(documentId, { status: 'any' })
121
+ return ancestors.map((doc) => {
122
+ const fields = doc.fields as Record<string, any> | undefined
123
+ const title = useAsTitle ? fields?.[useAsTitle] : undefined
124
+ return {
125
+ id: doc.id,
126
+ title: typeof title === 'string' && title.length > 0 ? title : doc.path,
127
+ path: doc.path,
128
+ }
129
+ })
109
130
  })
110
- })
131
+ )
111
132
 
112
133
  // ---------------------------------------------------------------------------
113
134
  // Resolve a document's placement state in the tree (unplaced / root / child)
@@ -115,17 +136,19 @@ export const getTreeAncestors = createServerFn({ method: 'GET' })
115
136
 
116
137
  export const getTreeParent = createServerFn({ method: 'GET' })
117
138
  .validator((input: { collection: string; documentId: string }) => input)
118
- .handler(async ({ data }) => {
119
- const { collection: path, documentId } = data
120
- const config = await ensureCollection(path)
121
- if (!config) {
122
- throw ERR_NOT_FOUND({
123
- message: 'Collection not found',
124
- details: { collectionPath: path },
125
- }).log(getLogger())
126
- }
127
- return getAdminTreeParent(getAdminBylineClient().collection(path), documentId)
128
- })
139
+ .handler(
140
+ withDocumentMutationErrors(async ({ data }) => {
141
+ const { collection: path, documentId } = data
142
+ const config = await ensureCollection(path)
143
+ if (!config) {
144
+ throw ERR_NOT_FOUND({
145
+ message: 'Collection not found',
146
+ details: { collectionPath: path },
147
+ }).log(getLogger())
148
+ }
149
+ return getAdminTreeParent(getAdminBylineClient().collection(path), documentId)
150
+ })
151
+ )
129
152
 
130
153
  // ---------------------------------------------------------------------------
131
154
  // Read the whole collection tree as ordered, depth-tagged rows for the built-in
@@ -135,6 +158,7 @@ export const getTreeParent = createServerFn({ method: 'GET' })
135
158
  // ---------------------------------------------------------------------------
136
159
 
137
160
  export interface CollectionTreeRow {
161
+ revision: number
138
162
  id: string
139
163
  /** Parent document id, or `null` for a root or unplaced node. Drives the
140
164
  * client-side tree reconstruction for drag-to-reorder / re-parent. */
@@ -150,70 +174,74 @@ export interface CollectionTreeRow {
150
174
 
151
175
  export const getCollectionTree = createServerFn({ method: 'GET' })
152
176
  .validator((input: { collection: string; locale?: string }) => input)
153
- .handler(async ({ data }) => {
154
- const { collection: path } = data
155
- const config = await ensureCollection(path)
156
- if (!config) {
157
- throw ERR_NOT_FOUND({
158
- message: 'Collection not found',
159
- details: { collectionPath: path },
160
- }).log(getLogger())
161
- }
162
- const handle = getAdminBylineClient().collection(path)
163
- // Shared deliberately across the structural and unplaced reads: the
164
- // beforeRead predicate cache is per-ReadContext, so sharing gives both
165
- // halves one predicate snapshot — a time-dependent predicate (embargo
166
- // cutoff) evaluated twice could otherwise disagree between the placed
167
- // forest and the unplaced list. The request-memoized context factory
168
- // keeps this sharing safe (one request authority per request).
169
- const readContext = createReadContext()
170
-
171
- const forest = await handle.getSubtree({
172
- status: 'any',
173
- locale: data.locale,
174
- _readContext: readContext,
175
- })
176
- const rows: CollectionTreeRow[] = []
177
- const placed = new Set<string>()
178
- const walk = (nodes: typeof forest, depth: number, parentId: string | null): void => {
179
- for (const node of nodes) {
180
- const doc = node.document
177
+ .handler(
178
+ withDocumentMutationErrors(async ({ data }) => {
179
+ const { collection: path } = data
180
+ const config = await ensureCollection(path)
181
+ if (!config) {
182
+ throw ERR_NOT_FOUND({
183
+ message: 'Collection not found',
184
+ details: { collectionPath: path },
185
+ }).log(getLogger())
186
+ }
187
+ const handle = getAdminBylineClient().collection(path)
188
+ // Shared deliberately across the structural and unplaced reads: the
189
+ // beforeRead predicate cache is per-ReadContext, so sharing gives both
190
+ // halves one predicate snapshot — a time-dependent predicate (embargo
191
+ // cutoff) evaluated twice could otherwise disagree between the placed
192
+ // forest and the unplaced list. The request-memoized context factory
193
+ // keeps this sharing safe (one request authority per request).
194
+ const readContext = createReadContext()
195
+
196
+ const { forest, unplaced } = await handle.getTreeForEdit({
197
+ locale: data.locale,
198
+ _readContext: readContext,
199
+ })
200
+ const rows: CollectionTreeRow[] = []
201
+ const placed = new Set<string>()
202
+ const walk = (nodes: typeof forest, depth: number, parentId: string | null): void => {
203
+ for (const node of nodes) {
204
+ const doc = node.document
205
+ rows.push({
206
+ id: doc.id,
207
+ revision: doc.revision,
208
+ parentId,
209
+ depth,
210
+ unplaced: false,
211
+ status: doc.status,
212
+ path: doc.path,
213
+ createdAt: doc.createdAt,
214
+ updatedAt: doc.updatedAt,
215
+ fields: doc.fields as Record<string, any>,
216
+ })
217
+ placed.add(doc.id)
218
+ walk(node.children, depth + 1, doc.id)
219
+ }
220
+ }
221
+ walk(forest, 0, null)
222
+
223
+ // Surface documents not yet in the tree (e.g. freshly created) so they
224
+ // remain reachable. Trees are small by design, so a single wide read is fine.
225
+ for (const doc of unplaced) {
181
226
  rows.push({
182
227
  id: doc.id,
183
- parentId,
184
- depth,
185
- unplaced: false,
228
+ revision: doc.revision,
229
+ parentId: null,
230
+ depth: 0,
231
+ unplaced: true,
186
232
  status: doc.status,
187
233
  path: doc.path,
188
234
  createdAt: doc.createdAt,
189
235
  updatedAt: doc.updatedAt,
190
236
  fields: doc.fields as Record<string, any>,
191
237
  })
192
- placed.add(doc.id)
193
- walk(node.children, depth + 1, doc.id)
194
238
  }
195
- }
196
- walk(forest, 0, null)
197
-
198
- // Surface documents not yet in the tree (e.g. freshly created) so they
199
- // remain reachable. Trees are small by design, so a single wide read is fine.
200
- const unplaced = await getAdminUnplacedTreeDocuments(handle, placed, readContext, data.locale)
201
- for (const doc of unplaced) {
202
- rows.push({
203
- id: doc.id,
204
- parentId: null,
205
- depth: 0,
206
- unplaced: true,
207
- status: doc.status,
208
- path: doc.path,
209
- createdAt: doc.createdAt,
210
- updatedAt: doc.updatedAt,
211
- fields: doc.fields as Record<string, any>,
212
- })
213
- }
214
239
 
215
- return {
216
- rows,
217
- included: { collection: { path: config.collection.path, labels: config.definition.labels } },
218
- }
219
- })
240
+ return {
241
+ rows,
242
+ included: {
243
+ collection: { path: config.collection.path, labels: config.definition.labels },
244
+ },
245
+ }
246
+ })
247
+ )
@@ -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
@@ -12,17 +13,14 @@ import { getAdminRequestContext } from '@byline/client/server'
12
13
  import { ERR_NOT_FOUND, getLogger, getServerConfig } from '@byline/core'
13
14
  import type { DocumentPatch } from '@byline/core/patches'
14
15
  import type { DocumentLifecycleContext } from '@byline/core/services'
15
- import { updateDocumentSystemFields, updateDocumentWithPatches } from '@byline/core/services'
16
+ import { saveDocument, updateDocumentSystemFields } from '@byline/core/services'
16
17
 
17
18
  import { ensureCollection } from '../../integrations/api-utils.js'
19
+ import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
- // Apply patches (patch-based update — creates a new immutable version)
21
- //
22
- // Document-grain system fields (`path`, `availableLocales`) are deliberately
23
- // NOT handled here: they are written through their own non-versioned path
24
- // (`updateCollectionDocumentSystemFields` below) so that editing them does not
25
- // mint a new version or reset workflow status. See docs/08-internationalization/index.md.
22
+ // Save content patches and optional metadata in one guarded transaction.
23
+ // Empty patches retain the non-versioned metadata-only behavior.
26
24
  // ---------------------------------------------------------------------------
27
25
 
28
26
  export const updateCollectionDocumentWithPatches = createServerFn({ method: 'POST' })
@@ -31,43 +29,69 @@ export const updateCollectionDocumentWithPatches = createServerFn({ method: 'POS
31
29
  collection: string
32
30
  id: string
33
31
  patches: DocumentPatch[]
34
- versionId?: string
32
+ expectedRevision: number
33
+ path?: string | null
34
+ availableLocales?: string[]
35
35
  locale?: string
36
36
  }) => input
37
37
  )
38
- .handler(async ({ data: input }) => {
39
- const { collection: path, id, patches, versionId, locale } = input
40
- const logger = getLogger()
41
- const config = await ensureCollection(path)
42
- if (!config) {
43
- throw ERR_NOT_FOUND({
44
- message: 'Collection not found',
45
- details: { collectionPath: path },
46
- }).log(logger)
47
- }
38
+ .handler(
39
+ withDocumentMutationErrors(async ({ data: input }) => {
40
+ const {
41
+ collection: path,
42
+ id,
43
+ patches,
44
+ expectedRevision,
45
+ locale,
46
+ path: explicitPath,
47
+ availableLocales,
48
+ } = input
49
+ const logger = getLogger()
50
+ const config = await ensureCollection(path)
51
+ if (!config) {
52
+ throw ERR_NOT_FOUND({
53
+ message: 'Collection not found',
54
+ details: { collectionPath: path },
55
+ }).log(logger)
56
+ }
48
57
 
49
- const serverConfig = getServerConfig()
50
- const ctx: DocumentLifecycleContext = {
51
- db: serverConfig.db,
52
- definition: config.definition,
53
- collectionId: config.collection.id,
54
- collectionVersion: config.collection.version,
55
- collectionPath: path,
56
- logger,
57
- defaultLocale: serverConfig.i18n.content.defaultLocale,
58
- slugifier: serverConfig.slugifier,
59
- requestContext: await getAdminRequestContext(),
60
- }
58
+ const serverConfig = getServerConfig()
59
+ const ctx: DocumentLifecycleContext = {
60
+ db: serverConfig.db,
61
+ definition: config.definition,
62
+ collectionId: config.collection.id,
63
+ collectionVersion: config.collection.version,
64
+ collectionPath: path,
65
+ logger,
66
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
67
+ slugifier: serverConfig.slugifier,
68
+ requestContext: await getAdminRequestContext(),
69
+ }
61
70
 
62
- await updateDocumentWithPatches(ctx, {
63
- documentId: id,
64
- patches,
65
- documentVersionId: versionId,
66
- locale: locale ?? serverConfig.i18n.content.defaultLocale,
67
- })
71
+ let result: Awaited<ReturnType<typeof saveDocument>>
72
+ try {
73
+ result = await saveDocument(ctx, {
74
+ documentId: id,
75
+ patches,
76
+ expectedRevision,
77
+ path: explicitPath ?? undefined,
78
+ availableLocales,
79
+ locale: locale ?? serverConfig.i18n.content.defaultLocale,
80
+ })
81
+ } catch (error) {
82
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
83
+ if (committedFailure != null) return committedFailure
84
+ throw error
85
+ }
68
86
 
69
- return { status: 'ok' as const }
70
- })
87
+ return {
88
+ status: 'ok' as const,
89
+ documentId: result.documentId,
90
+ ...('documentVersionId' in result ? { documentVersionId: result.documentVersionId } : {}),
91
+ revision: result.revision,
92
+ }
93
+ })
94
+ )
71
95
 
72
96
  // ---------------------------------------------------------------------------
73
97
  // System-managed, document-grain fields (path + advertised locales)
@@ -82,6 +106,7 @@ export const updateCollectionDocumentSystemFields = createServerFn({ method: 'PO
82
106
  (input: {
83
107
  collection: string
84
108
  id: string
109
+ expectedRevision: number
85
110
  locale?: string
86
111
  /** Path override; `null`/omitted means no path write. */
87
112
  path?: string | null
@@ -89,39 +114,53 @@ export const updateCollectionDocumentSystemFields = createServerFn({ method: 'PO
89
114
  availableLocales?: string[]
90
115
  }) => input
91
116
  )
92
- .handler(async ({ data: input }) => {
93
- const { collection: path, id, locale, path: explicitPath, availableLocales } = input
94
- const logger = getLogger()
95
- const config = await ensureCollection(path)
96
- if (!config) {
97
- throw ERR_NOT_FOUND({
98
- message: 'Collection not found',
99
- details: { collectionPath: path },
100
- }).log(logger)
101
- }
117
+ .handler(
118
+ withDocumentMutationErrors(async ({ data: input }) => {
119
+ const {
120
+ collection: path,
121
+ id,
122
+ expectedRevision,
123
+ locale,
124
+ path: explicitPath,
125
+ availableLocales,
126
+ } = input
127
+ const logger = getLogger()
128
+ const config = await ensureCollection(path)
129
+ if (!config) {
130
+ throw ERR_NOT_FOUND({
131
+ message: 'Collection not found',
132
+ details: { collectionPath: path },
133
+ }).log(logger)
134
+ }
102
135
 
103
- const serverConfig = getServerConfig()
104
- const ctx: DocumentLifecycleContext = {
105
- db: serverConfig.db,
106
- definition: config.definition,
107
- collectionId: config.collection.id,
108
- collectionVersion: config.collection.version,
109
- collectionPath: path,
110
- logger,
111
- defaultLocale: serverConfig.i18n.content.defaultLocale,
112
- slugifier: serverConfig.slugifier,
113
- requestContext: await getAdminRequestContext(),
114
- }
136
+ const serverConfig = getServerConfig()
137
+ const ctx: DocumentLifecycleContext = {
138
+ db: serverConfig.db,
139
+ definition: config.definition,
140
+ collectionId: config.collection.id,
141
+ collectionVersion: config.collection.version,
142
+ collectionPath: path,
143
+ logger,
144
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
145
+ slugifier: serverConfig.slugifier,
146
+ requestContext: await getAdminRequestContext(),
147
+ }
115
148
 
116
- await updateDocumentSystemFields(ctx, {
117
- documentId: id,
118
- locale: locale ?? serverConfig.i18n.content.defaultLocale,
119
- path: explicitPath,
120
- availableLocales,
121
- // A failed after-hook happens after commit. Retrying this same admin save
122
- // must re-run cache/search reconciliation when DB values are now current.
123
- reconcile: true,
149
+ try {
150
+ const result = await updateDocumentSystemFields(ctx, {
151
+ expectedRevision,
152
+ documentId: id,
153
+ locale: locale ?? serverConfig.i18n.content.defaultLocale,
154
+ path: explicitPath,
155
+ availableLocales,
156
+ // Reconciliation still requires a current observation.
157
+ reconcile: true,
158
+ })
159
+ return { status: 'ok' as const, revision: result.revision }
160
+ } catch (error) {
161
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
162
+ if (committedFailure != null) return committedFailure
163
+ throw error
164
+ }
124
165
  })
125
-
126
- return { status: 'ok' as const }
127
- })
166
+ )