@byline/host-tanstack-start 4.19.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 (142) hide show
  1. package/dist/admin-shell/collections/edit.d.ts +1 -1
  2. package/dist/admin-shell/collections/edit.js +151 -31
  3. package/dist/admin-shell/collections/history.js +4 -2
  4. package/dist/admin-shell/collections/list.js +30 -6
  5. package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
  6. package/dist/admin-shell/collections/restore-version-modal.js +9 -4
  7. package/dist/admin-shell/collections/tree-list.js +16 -6
  8. package/dist/admin-shell/collections/version-history.d.ts +2 -1
  9. package/dist/admin-shell/collections/version-history.js +11 -2
  10. package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
  11. package/dist/admin-shell/document-mutation-notice.js +53 -0
  12. package/dist/admin-shell/document-mutation-state.d.ts +22 -0
  13. package/dist/admin-shell/document-mutation-state.js +71 -0
  14. package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
  15. package/dist/admin-shell/scheduled-publications/list.js +15 -5
  16. package/dist/admin-shell/singletons/history.js +4 -2
  17. package/dist/admin-shell/singletons/view.d.ts +1 -1
  18. package/dist/admin-shell/singletons/view.js +98 -19
  19. package/dist/integrations/byline-field-services.js +2 -9
  20. package/dist/routes/create-collection-edit-route.js +1 -1
  21. package/dist/routes/create-collection-list-route.d.ts +0 -7
  22. package/dist/routes/create-collection-list-route.js +7 -9
  23. package/dist/routes/create-singleton-route.d.ts +2 -0
  24. package/dist/routes/create-singleton-route.js +4 -3
  25. package/dist/server-fns/collections/copy-to-locale.d.ts +2 -7
  26. package/dist/server-fns/collections/copy-to-locale.js +4 -2
  27. package/dist/server-fns/collections/create.d.ts +1 -7
  28. package/dist/server-fns/collections/create.js +5 -3
  29. package/dist/server-fns/collections/delete-locale.d.ts +2 -7
  30. package/dist/server-fns/collections/delete-locale.js +4 -2
  31. package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
  32. package/dist/server-fns/collections/delete-outcome.js +4 -0
  33. package/dist/server-fns/collections/delete.d.ts +2 -7
  34. package/dist/server-fns/collections/delete.js +5 -3
  35. package/dist/server-fns/collections/duplicate.d.ts +2 -7
  36. package/dist/server-fns/collections/duplicate.js +5 -3
  37. package/dist/server-fns/collections/get.d.ts +1 -7
  38. package/dist/server-fns/collections/get.js +5 -4
  39. package/dist/server-fns/collections/list.d.ts +0 -7
  40. package/dist/server-fns/collections/list.js +11 -3
  41. package/dist/server-fns/collections/reorder.d.ts +7 -8
  42. package/dist/server-fns/collections/reorder.js +20 -76
  43. package/dist/server-fns/collections/restore-version.d.ts +3 -7
  44. package/dist/server-fns/collections/restore-version.js +5 -2
  45. package/dist/server-fns/collections/save-outcome.d.ts +1 -0
  46. package/dist/server-fns/collections/save-outcome.js +1 -0
  47. package/dist/server-fns/collections/scheduled-publication.d.ts +49 -9
  48. package/dist/server-fns/collections/scheduled-publication.js +31 -21
  49. package/dist/server-fns/collections/status.d.ts +8 -7
  50. package/dist/server-fns/collections/status.js +13 -5
  51. package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
  52. package/dist/server-fns/collections/tree-mutation.js +17 -5
  53. package/dist/server-fns/collections/tree.d.ts +14 -8
  54. package/dist/server-fns/collections/tree.js +21 -17
  55. package/dist/server-fns/collections/update.d.ts +12 -11
  56. package/dist/server-fns/collections/update.js +34 -20
  57. package/dist/server-fns/collections/upload.d.ts +1 -0
  58. package/dist/server-fns/collections/upload.js +3 -2
  59. package/dist/server-fns/document-mutation-errors.d.ts +5 -0
  60. package/dist/server-fns/document-mutation-errors.js +50 -0
  61. package/dist/server-fns/editable-revision.d.ts +9 -0
  62. package/dist/server-fns/editable-revision.js +11 -0
  63. package/dist/server-fns/singleton-document-read.d.ts +1 -1
  64. package/dist/server-fns/singleton-document-read.js +7 -3
  65. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +6 -8
  66. package/dist/server-fns/singletons/cancel-scheduled-publish.js +4 -1
  67. package/dist/server-fns/singletons/change-status.d.ts +2 -7
  68. package/dist/server-fns/singletons/change-status.js +4 -1
  69. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +5 -8
  70. package/dist/server-fns/singletons/confirm-scheduled-publish.js +4 -2
  71. package/dist/server-fns/singletons/copy-to-locale.d.ts +2 -7
  72. package/dist/server-fns/singletons/copy-to-locale.js +4 -2
  73. package/dist/server-fns/singletons/get.d.ts +4 -4
  74. package/dist/server-fns/singletons/restore-version.d.ts +2 -7
  75. package/dist/server-fns/singletons/restore-version.js +6 -3
  76. package/dist/server-fns/singletons/schedule-publish.d.ts +5 -8
  77. package/dist/server-fns/singletons/schedule-publish.js +4 -2
  78. package/dist/server-fns/singletons/unpublish.d.ts +2 -7
  79. package/dist/server-fns/singletons/unpublish.js +4 -1
  80. package/dist/server-fns/singletons/update.d.ts +4 -11
  81. package/dist/server-fns/singletons/update.js +5 -4
  82. package/dist/server-fns/structural-receipt.d.ts +4 -0
  83. package/dist/server-fns/structural-receipt.js +31 -0
  84. package/package.json +11 -11
  85. package/src/admin-shell/collections/edit.tsx +173 -54
  86. package/src/admin-shell/collections/history.tsx +11 -2
  87. package/src/admin-shell/collections/list.tsx +22 -6
  88. package/src/admin-shell/collections/restore-version-modal.tsx +12 -3
  89. package/src/admin-shell/collections/save-navigation.test.tsx +156 -8
  90. package/src/admin-shell/collections/tree-list.tsx +16 -6
  91. package/src/admin-shell/collections/version-history.tsx +13 -3
  92. package/src/admin-shell/document-mutation-notice.tsx +45 -0
  93. package/src/admin-shell/document-mutation-state.test.tsx +146 -0
  94. package/src/admin-shell/document-mutation-state.ts +95 -0
  95. package/src/admin-shell/scheduled-publications/list.tsx +16 -6
  96. package/src/admin-shell/singletons/history.test.tsx +3 -1
  97. package/src/admin-shell/singletons/history.tsx +10 -2
  98. package/src/admin-shell/singletons/view.test.tsx +59 -18
  99. package/src/admin-shell/singletons/view.tsx +110 -29
  100. package/src/integrations/byline-field-services.ts +2 -3
  101. package/src/routes/create-collection-edit-route.tsx +1 -0
  102. package/src/routes/create-collection-list-route.tsx +13 -2
  103. package/src/routes/create-singleton-route.test.node.ts +10 -2
  104. package/src/routes/create-singleton-route.tsx +6 -4
  105. package/src/server-fns/collections/copy-to-locale.ts +41 -36
  106. package/src/server-fns/collections/create.ts +54 -50
  107. package/src/server-fns/collections/delete-locale.ts +41 -32
  108. package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
  109. package/src/server-fns/collections/delete-outcome.ts +27 -16
  110. package/src/server-fns/collections/delete.ts +44 -38
  111. package/src/server-fns/collections/duplicate.ts +38 -32
  112. package/src/server-fns/collections/get.ts +8 -4
  113. package/src/server-fns/collections/list.ts +8 -3
  114. package/src/server-fns/collections/reorder.ts +32 -124
  115. package/src/server-fns/collections/restore-version.ts +47 -39
  116. package/src/server-fns/collections/revision-input.test.node.ts +108 -0
  117. package/src/server-fns/collections/save-outcome.test.node.ts +3 -0
  118. package/src/server-fns/collections/save-outcome.ts +2 -0
  119. package/src/server-fns/collections/scheduled-publication.ts +138 -111
  120. package/src/server-fns/collections/status.ts +86 -64
  121. package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
  122. package/src/server-fns/collections/tree-mutation.ts +28 -11
  123. package/src/server-fns/collections/tree.ts +150 -122
  124. package/src/server-fns/collections/update.ts +110 -86
  125. package/src/server-fns/collections/upload-singleton.test.node.ts +3 -7
  126. package/src/server-fns/collections/upload.ts +81 -77
  127. package/src/server-fns/document-mutation-errors.ts +67 -0
  128. package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
  129. package/src/server-fns/editable-revision.test.node.ts +23 -0
  130. package/src/server-fns/editable-revision.ts +18 -0
  131. package/src/server-fns/singleton-document-read.ts +7 -3
  132. package/src/server-fns/singletons/cancel-scheduled-publish.ts +10 -3
  133. package/src/server-fns/singletons/change-status.ts +10 -3
  134. package/src/server-fns/singletons/confirm-scheduled-publish.ts +12 -6
  135. package/src/server-fns/singletons/copy-to-locale.ts +20 -15
  136. package/src/server-fns/singletons/get.ts +3 -3
  137. package/src/server-fns/singletons/restore-version.ts +17 -12
  138. package/src/server-fns/singletons/schedule-publish.ts +18 -7
  139. package/src/server-fns/singletons/singletons.test.node.ts +63 -25
  140. package/src/server-fns/singletons/unpublish.ts +10 -3
  141. package/src/server-fns/singletons/update.ts +21 -16
  142. package/src/server-fns/structural-receipt.ts +41 -0
@@ -1,3 +1,4 @@
1
+ import { withDocumentMutationErrors } from '../document-mutation-errors.js'
1
2
  /**
2
3
  * This Source Code is subject to the terms of the Mozilla Public
3
4
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -66,6 +67,7 @@ export type SerializedDocumentPublishSchedule = Omit<
66
67
  }
67
68
 
68
69
  export interface ScheduledPublicationListItem extends SerializedDocumentPublishSchedule {
70
+ revision: number
69
71
  collectionPath: string
70
72
  collectionLabel: string
71
73
  documentPath: string | null
@@ -137,49 +139,75 @@ async function scheduledPublicationRuntime(): Promise<ScheduledPublicationRuntim
137
139
 
138
140
  /** Authenticated feature discovery for the admin shell and health warning. */
139
141
  export const getScheduledPublicationRuntime = createServerFn({ method: 'GET' }).handler(
140
- async () => {
142
+ withDocumentMutationErrors(async () => {
141
143
  await getAdminRequestContext()
142
144
  return scheduledPublicationRuntime()
143
- }
145
+ })
144
146
  )
145
147
 
146
148
  export const scheduleCollectionDocumentPublish = createServerFn({ method: 'POST' })
147
149
  .validator(
148
- (input: { collection: string; id: string; publishAt: string; expectedVersionId: string }) =>
149
- input
150
+ (input: {
151
+ expectedRevision: number
152
+ collection: string
153
+ id: string
154
+ publishAt: string
155
+ expectedVersionId: string
156
+ }) => input
157
+ )
158
+ .handler(
159
+ withDocumentMutationErrors(async ({ data }) => {
160
+ assertFeatureEnabled()
161
+ const schedule = await getAdminBylineClient()
162
+ .collection(data.collection)
163
+ .schedulePublish(data.id, {
164
+ publishAt: data.publishAt,
165
+ expectedRevision: data.expectedRevision,
166
+ expectedVersionId: data.expectedVersionId,
167
+ })
168
+ return { ...serializeSchedule(schedule), revision: schedule.revision }
169
+ })
150
170
  )
151
- .handler(async ({ data }) => {
152
- assertFeatureEnabled()
153
- const schedule = await getAdminBylineClient()
154
- .collection(data.collection)
155
- .schedulePublish(data.id, {
156
- publishAt: data.publishAt,
157
- expectedVersionId: data.expectedVersionId,
158
- })
159
- return serializeSchedule(schedule)
160
- })
161
171
 
162
172
  export const confirmCollectionDocumentScheduledPublish = createServerFn({ method: 'POST' })
163
- .validator((input: { collection: string; id: string; expectedVersionId: string }) => input)
164
- .handler(async ({ data }) => {
165
- assertFeatureEnabled()
166
- const schedule = await getAdminBylineClient()
167
- .collection(data.collection)
168
- .confirmScheduledPublish(data.id, { expectedVersionId: data.expectedVersionId })
169
- return serializeSchedule(schedule)
170
- })
173
+ .validator(
174
+ (input: {
175
+ expectedRevision: number
176
+ collection: string
177
+ id: string
178
+ expectedVersionId: string
179
+ }) => input
180
+ )
181
+ .handler(
182
+ withDocumentMutationErrors(async ({ data }) => {
183
+ assertFeatureEnabled()
184
+ const schedule = await getAdminBylineClient()
185
+ .collection(data.collection)
186
+ .confirmScheduledPublish(data.id, {
187
+ expectedRevision: data.expectedRevision,
188
+ expectedVersionId: data.expectedVersionId,
189
+ })
190
+ return { ...serializeSchedule(schedule), revision: schedule.revision }
191
+ })
192
+ )
171
193
 
172
194
  export const cancelCollectionDocumentScheduledPublish = createServerFn({ method: 'POST' })
173
- .validator((input: { collection: string; id: string }) => input)
174
- .handler(async ({ data }) => {
175
- assertFeatureEnabled()
176
- const schedule = await getAdminBylineClient()
177
- .collection(data.collection)
178
- .cancelScheduledPublish(data.id)
179
- return schedule == null
180
- ? { status: 'not_found' as const }
181
- : { status: 'cancelled' as const, schedule: serializeSchedule(schedule) }
182
- })
195
+ .validator((input: { expectedRevision: number; collection: string; id: string }) => input)
196
+ .handler(
197
+ withDocumentMutationErrors(async ({ data }) => {
198
+ assertFeatureEnabled()
199
+ const schedule = await getAdminBylineClient()
200
+ .collection(data.collection)
201
+ .cancelScheduledPublish(data.id, { expectedRevision: data.expectedRevision })
202
+ return schedule.schedule == null
203
+ ? { status: 'not_found' as const, revision: schedule.revision }
204
+ : {
205
+ status: 'cancelled' as const,
206
+ schedule: serializeSchedule(schedule.schedule),
207
+ revision: schedule.revision,
208
+ }
209
+ })
210
+ )
183
211
 
184
212
  export const listScheduledPublications = createServerFn({ method: 'GET' })
185
213
  .validator(
@@ -190,87 +218,86 @@ export const listScheduledPublications = createServerFn({ method: 'GET' })
190
218
  pageSize?: number
191
219
  }) => input
192
220
  )
193
- .handler(async ({ data }) => {
194
- assertFeatureEnabled()
195
- if (data.states?.some((state) => state !== 'armed' && state !== 'needs_reconfirm') === true) {
196
- throw ERR_VALIDATION({ message: 'state must be armed or needs_reconfirm' })
197
- }
198
- if (data.lastAuthorizedBy != null && !UUID_RE.test(data.lastAuthorizedBy)) {
199
- throw ERR_VALIDATION({ message: 'lastAuthorizedBy must be a UUID' })
200
- }
201
- const core = bylineCore()
202
- const page = data.page ?? 1
203
- const pageSize = data.pageSize ?? 25
204
- const requestContext = await getAdminRequestContext()
205
- const result = await listDocumentPublishSchedules(core, requestContext, {
206
- states: data.states,
207
- lastAuthorizedBy: data.lastAuthorizedBy,
208
- page,
209
- pageSize,
210
- })
211
- const collections = new Map(
212
- core.collections.map((definition) => {
213
- const record = core.getCollectionRecord(definition.path)
214
- return [record.collectionId, definition] as const
221
+ .handler(
222
+ withDocumentMutationErrors(async ({ data }) => {
223
+ assertFeatureEnabled()
224
+ if (data.states?.some((state) => state !== 'armed' && state !== 'needs_reconfirm') === true) {
225
+ throw ERR_VALIDATION({ message: 'state must be armed or needs_reconfirm' })
226
+ }
227
+ if (data.lastAuthorizedBy != null && !UUID_RE.test(data.lastAuthorizedBy)) {
228
+ throw ERR_VALIDATION({ message: 'lastAuthorizedBy must be a UUID' })
229
+ }
230
+ const core = bylineCore()
231
+ const page = data.page ?? 1
232
+ const pageSize = data.pageSize ?? 25
233
+ const requestContext = await getAdminRequestContext()
234
+ const result = await listDocumentPublishSchedules(core, requestContext, {
235
+ states: data.states,
236
+ lastAuthorizedBy: data.lastAuthorizedBy,
237
+ page,
238
+ pageSize,
215
239
  })
216
- )
217
- // Resolve authorizer names in one query for the whole page rather than one
218
- // per row. `last_authorized_by` is deliberately not a foreign key — deleting
219
- // an admin account must not revoke a schedule it authorized — so an id here
220
- // may simply have no user row left, and the caller falls back to the id.
221
- const authorizerIds = [
222
- ...new Set(
223
- result.schedules
224
- .map((schedule) => schedule.lastAuthorizedBy)
225
- .filter((id): id is string => id != null)
226
- ),
227
- ]
228
- const authorizerNames = new Map<string, string>()
229
- // `adminStore` is optional on core — an installation can run without the
230
- // admin subsystem — so a missing store simply means no names to resolve and
231
- // the column falls back to ids.
232
- const adminUsers = core.adminStore?.adminUsers
233
- if (authorizerIds.length > 0 && adminUsers != null) {
234
- const users = await adminUsers.getByIds(authorizerIds)
235
- for (const user of users) {
236
- const fullName = [user.given_name, user.family_name].filter(Boolean).join(' ').trim()
237
- authorizerNames.set(user.id, fullName.length > 0 ? fullName : user.email)
240
+ const collections = new Map(
241
+ core.collections.map((definition) => {
242
+ const record = core.getCollectionRecord(definition.path)
243
+ return [record.collectionId, definition] as const
244
+ })
245
+ )
246
+ // Resolve authorizer names in one query for the whole page rather than one
247
+ // per row. `last_authorized_by` is deliberately not a foreign key — deleting
248
+ // an admin account must not revoke a schedule it authorized — so an id here
249
+ // may simply have no user row left, and the caller falls back to the id.
250
+ const authorizerIds = [
251
+ ...new Set(
252
+ result.schedules
253
+ .map((schedule) => schedule.lastAuthorizedBy)
254
+ .filter((id): id is string => id != null)
255
+ ),
256
+ ]
257
+ const authorizerNames = new Map<string, string>()
258
+ // `adminStore` is optional on core — an installation can run without the
259
+ // admin subsystem — so a missing store simply means no names to resolve and
260
+ // the column falls back to ids.
261
+ const adminUsers = core.adminStore?.adminUsers
262
+ if (authorizerIds.length > 0 && adminUsers != null) {
263
+ const users = await adminUsers.getByIds(authorizerIds)
264
+ for (const user of users) {
265
+ const fullName = [user.given_name, user.family_name].filter(Boolean).join(' ').trim()
266
+ authorizerNames.set(user.id, fullName.length > 0 ? fullName : user.email)
267
+ }
238
268
  }
239
- }
240
269
 
241
- const schedules = await Promise.all(
242
- result.schedules.map(async (schedule): Promise<ScheduledPublicationListItem> => {
243
- const definition = collections.get(schedule.collectionId)
244
- const documentPath = await core.db.queries.documents.getCurrentPath({
245
- collection_id: schedule.collectionId,
246
- document_id: schedule.documentId,
270
+ const schedules = await Promise.all(
271
+ result.schedules.map(async (schedule): Promise<ScheduledPublicationListItem> => {
272
+ const definition = collections.get(schedule.collectionId)
273
+ return {
274
+ ...serializeSchedule(schedule),
275
+ collectionPath: definition?.path ?? schedule.collectionId,
276
+ collectionLabel:
277
+ definition == null
278
+ ? schedule.collectionId
279
+ : isSingleton(definition)
280
+ ? definition.label
281
+ : definition.labels.plural,
282
+ documentPath: schedule.documentPath,
283
+ revision: schedule.revision,
284
+ lastAuthorizedByName:
285
+ schedule.lastAuthorizedBy == null
286
+ ? null
287
+ : (authorizerNames.get(schedule.lastAuthorizedBy) ?? null),
288
+ }
247
289
  })
248
- return {
249
- ...serializeSchedule(schedule),
250
- collectionPath: definition?.path ?? schedule.collectionId,
251
- collectionLabel:
252
- definition == null
253
- ? schedule.collectionId
254
- : isSingleton(definition)
255
- ? definition.label
256
- : definition.labels.plural,
257
- documentPath,
258
- lastAuthorizedByName:
259
- schedule.lastAuthorizedBy == null
260
- ? null
261
- : (authorizerNames.get(schedule.lastAuthorizedBy) ?? null),
262
- }
263
- })
264
- )
290
+ )
265
291
 
266
- return {
267
- schedules,
268
- meta: {
269
- page,
270
- pageSize,
271
- total: result.total,
272
- totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
273
- },
274
- runtime: await scheduledPublicationRuntime(),
275
- } satisfies ScheduledPublicationListResponse
276
- })
292
+ return {
293
+ schedules,
294
+ meta: {
295
+ page,
296
+ pageSize,
297
+ total: result.total,
298
+ totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
299
+ },
300
+ runtime: await scheduledPublicationRuntime(),
301
+ } satisfies ScheduledPublicationListResponse
302
+ })
303
+ )
@@ -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
@@ -23,80 +24,101 @@ import { ensureCollection } from '../../integrations/api-utils.js'
23
24
  // ---------------------------------------------------------------------------
24
25
 
25
26
  export const updateDocumentStatus = createServerFn({ method: 'POST' })
26
- .validator((input: { collection: string; id: string; status: string }) => input)
27
- .handler(async ({ data: input }) => {
28
- const { collection: path, id, status: nextStatus } = input
29
- const logger = getLogger()
30
- const config = await ensureCollection(path)
31
- if (!config) {
32
- throw ERR_NOT_FOUND({
33
- message: 'Collection not found',
34
- details: { collectionPath: path },
35
- }).log(logger)
36
- }
27
+ .validator(
28
+ (input: { expectedRevision: number; collection: string; id: string; status: string }) => input
29
+ )
30
+ .handler(
31
+ withDocumentMutationErrors(async ({ data: input }) => {
32
+ const { collection: path, id, status: nextStatus } = input
33
+ const logger = getLogger()
34
+ const config = await ensureCollection(path)
35
+ if (!config) {
36
+ throw ERR_NOT_FOUND({
37
+ message: 'Collection not found',
38
+ details: { collectionPath: path },
39
+ }).log(logger)
40
+ }
37
41
 
38
- const serverConfig = getServerConfig()
39
- const ctx: DocumentLifecycleContext = {
40
- db: serverConfig.db,
41
- definition: config.definition,
42
- collectionId: config.collection.id,
43
- collectionVersion: config.collection.version,
44
- collectionPath: path,
45
- logger,
46
- defaultLocale: serverConfig.i18n.content.defaultLocale,
47
- slugifier: serverConfig.slugifier,
48
- requestContext: await getAdminRequestContext(),
49
- }
42
+ const serverConfig = getServerConfig()
43
+ const ctx: DocumentLifecycleContext = {
44
+ db: serverConfig.db,
45
+ definition: config.definition,
46
+ collectionId: config.collection.id,
47
+ collectionVersion: config.collection.version,
48
+ collectionPath: path,
49
+ logger,
50
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
51
+ slugifier: serverConfig.slugifier,
52
+ requestContext: await getAdminRequestContext(),
53
+ }
50
54
 
51
- const result = await changeDocumentStatus(ctx, {
52
- documentId: id,
53
- nextStatus,
55
+ const result = await changeDocumentStatus(ctx, {
56
+ documentId: id,
57
+ expectedRevision: input.expectedRevision,
58
+ nextStatus,
59
+ })
60
+ return {
61
+ status: 'ok' as const,
62
+ documentId: result.documentId,
63
+ revision: result.revision,
64
+ previousStatus: result.previousStatus,
65
+ newStatus: result.newStatus,
66
+ }
54
67
  })
55
- return {
56
- status: 'ok' as const,
57
- previousStatus: result.previousStatus,
58
- newStatus: result.newStatus,
59
- }
60
- })
68
+ )
61
69
 
62
70
  // ---------------------------------------------------------------------------
63
71
  // Unpublish document (archive the live published version)
64
72
  // ---------------------------------------------------------------------------
65
73
 
66
74
  export const unpublishDocument = createServerFn({ method: 'POST' })
67
- .validator((input: { collection: string; id: string }) => input)
68
- .handler(async ({ data: input }) => {
69
- const { collection: path, id } = input
70
- const logger = getLogger()
71
- const config = await ensureCollection(path)
72
- if (!config) {
73
- throw ERR_NOT_FOUND({
74
- message: 'Collection not found',
75
- details: { collectionPath: path },
76
- }).log(logger)
77
- }
75
+ .validator((input: { expectedRevision: number; collection: string; id: string }) => input)
76
+ .handler(
77
+ withDocumentMutationErrors(async ({ data: input }) => {
78
+ const { collection: path, id } = input
79
+ const logger = getLogger()
80
+ const config = await ensureCollection(path)
81
+ if (!config) {
82
+ throw ERR_NOT_FOUND({
83
+ message: 'Collection not found',
84
+ details: { collectionPath: path },
85
+ }).log(logger)
86
+ }
78
87
 
79
- const serverConfig = getServerConfig()
80
- const ctx: DocumentLifecycleContext = {
81
- db: serverConfig.db,
82
- definition: config.definition,
83
- collectionId: config.collection.id,
84
- collectionVersion: config.collection.version,
85
- collectionPath: path,
86
- logger,
87
- defaultLocale: serverConfig.i18n.content.defaultLocale,
88
- slugifier: serverConfig.slugifier,
89
- requestContext: await getAdminRequestContext(),
90
- }
88
+ const serverConfig = getServerConfig()
89
+ const ctx: DocumentLifecycleContext = {
90
+ db: serverConfig.db,
91
+ definition: config.definition,
92
+ collectionId: config.collection.id,
93
+ collectionVersion: config.collection.version,
94
+ collectionPath: path,
95
+ logger,
96
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
97
+ slugifier: serverConfig.slugifier,
98
+ requestContext: await getAdminRequestContext(),
99
+ }
91
100
 
92
- const result = await unpublishDocumentService(ctx, { documentId: id })
101
+ const result = await unpublishDocumentService(ctx, {
102
+ documentId: id,
103
+ expectedRevision: input.expectedRevision,
104
+ })
93
105
 
94
- if (result.archivedCount === 0) {
95
- throw ERR_NOT_FOUND({
96
- message: 'No published version found for this document',
97
- details: { documentId: id, collectionPath: path },
98
- }).log(logger)
99
- }
106
+ if (result.archivedCount === 0) {
107
+ throw ERR_NOT_FOUND({
108
+ message: 'No published version found for this document',
109
+ details: {
110
+ documentId: id,
111
+ expectedRevision: input.expectedRevision,
112
+ collectionPath: path,
113
+ },
114
+ }).log(logger)
115
+ }
100
116
 
101
- return { status: 'ok' as const, archivedCount: result.archivedCount }
102
- })
117
+ return {
118
+ status: 'ok' as const,
119
+ documentId: result.documentId,
120
+ revision: result.revision,
121
+ archivedCount: result.archivedCount,
122
+ }
123
+ })
124
+ )
@@ -175,51 +175,109 @@ describe('admin tree mutations', () => {
175
175
  expect(controller.isMoving()).toBe(false)
176
176
  })
177
177
 
178
- it('opts ordinary placement retries into lifecycle reconciliation', async () => {
178
+ const receipt = {
179
+ documentId: 'doc-1',
180
+ revision: 2,
181
+ affectedDocuments: [{ documentId: 'doc-1', revision: 2 }],
182
+ scheduledPublicationsNeedReconfirmation: false,
183
+ }
184
+
185
+ it('forwards each caller observation and requests reconciliation without retrying', async () => {
179
186
  const placeTreeNode = vi
180
- .fn()
187
+ .fn<Parameters<typeof placeAdminTreeNode>[0]['placeTreeNode']>()
181
188
  .mockRejectedValueOnce(new Error('post-commit hook failed'))
182
- .mockResolvedValue({ orderKey: 'key-1' })
183
-
184
- const retry = () =>
185
- placeAdminTreeNode({ placeTreeNode } as any, 'doc-1', {
189
+ .mockResolvedValue({ orderKey: 'key-1', ...receipt })
190
+ const handle = { placeTreeNode }
191
+ await expect(
192
+ placeAdminTreeNode(handle, 'doc-1', {
193
+ expectedRevision: 1,
186
194
  parentDocumentId: 'parent-1',
187
195
  beforeDocumentId: 'left-1',
188
196
  })
189
-
190
- await expect(retry()).rejects.toThrow('post-commit hook failed')
191
- await expect(retry()).resolves.toEqual({ orderKey: 'key-1' })
192
-
193
- expect(placeTreeNode).toHaveBeenCalledTimes(2)
194
- expect(placeTreeNode).toHaveBeenNthCalledWith(
195
- 2,
196
- 'doc-1',
197
- expect.objectContaining({ reconcile: true })
198
- )
197
+ ).rejects.toThrow('post-commit hook failed')
198
+ expect(placeTreeNode).toHaveBeenCalledTimes(1)
199
+ // A separate caller action carries its newly observed receipt, never an implicit retry.
200
+ await expect(
201
+ placeAdminTreeNode(handle, 'doc-1', {
202
+ expectedRevision: 2,
203
+ parentDocumentId: 'parent-1',
204
+ beforeDocumentId: 'left-1',
205
+ })
206
+ ).resolves.toEqual({ orderKey: 'key-1', ...receipt })
207
+ expect(placeTreeNode).toHaveBeenNthCalledWith(2, 'doc-1', {
208
+ expectedRevision: 2,
209
+ parentDocumentId: 'parent-1',
210
+ beforeDocumentId: 'left-1',
211
+ reconcile: true,
212
+ })
199
213
  })
200
214
 
201
- it('opts ordinary removal retries into lifecycle reconciliation', async () => {
202
- const removeFromTree = vi.fn().mockResolvedValue(undefined)
203
-
204
- await removeAdminTreeNode({ removeFromTree } as any, 'doc-1')
205
-
206
- expect(removeFromTree).toHaveBeenCalledWith('doc-1', { reconcile: true })
215
+ it('requests reconciliation for removal and preserves the caller revision', async () => {
216
+ const removeFromTree = vi
217
+ .fn<Parameters<typeof removeAdminTreeNode>[0]['removeFromTree']>()
218
+ .mockResolvedValue(receipt)
219
+ await expect(
220
+ removeAdminTreeNode({ removeFromTree }, 'doc-1', { expectedRevision: 1 })
221
+ ).resolves.toEqual(receipt)
222
+ expect(removeFromTree).toHaveBeenCalledWith('doc-1', { expectedRevision: 1, reconcile: true })
207
223
  })
208
224
 
209
225
  it('preserves an explicit reconciliation override', async () => {
210
- const placeTreeNode = vi.fn().mockResolvedValue({ orderKey: 'key-1' })
211
- const removeFromTree = vi.fn().mockResolvedValue(undefined)
212
-
213
- await placeAdminTreeNode({ placeTreeNode } as any, 'doc-1', {
226
+ const placeTreeNode = vi
227
+ .fn<Parameters<typeof placeAdminTreeNode>[0]['placeTreeNode']>()
228
+ .mockResolvedValue({ orderKey: 'key-1', ...receipt })
229
+ const removeFromTree = vi
230
+ .fn<Parameters<typeof removeAdminTreeNode>[0]['removeFromTree']>()
231
+ .mockResolvedValue(receipt)
232
+ await placeAdminTreeNode({ placeTreeNode }, 'doc-1', {
233
+ expectedRevision: 1,
214
234
  parentDocumentId: null,
215
235
  reconcile: false,
216
236
  })
217
- await removeAdminTreeNode({ removeFromTree } as any, 'doc-1', { reconcile: false })
218
-
237
+ await removeAdminTreeNode({ removeFromTree }, 'doc-1', {
238
+ expectedRevision: 2,
239
+ reconcile: false,
240
+ })
219
241
  expect(placeTreeNode).toHaveBeenCalledWith('doc-1', {
242
+ expectedRevision: 1,
220
243
  parentDocumentId: null,
221
244
  reconcile: false,
222
245
  })
223
- expect(removeFromTree).toHaveBeenCalledWith('doc-1', { reconcile: false })
246
+ expect(removeFromTree).toHaveBeenCalledWith('doc-1', { expectedRevision: 2, reconcile: false })
224
247
  })
225
248
  })
249
+
250
+ it('does not refresh or replace the observation after a typed stale tree move', async () => {
251
+ const error = {
252
+ code: 'ERR_DOCUMENT_STALE',
253
+ details: {
254
+ reason: 'revision_mismatch',
255
+ documentId: 'doc',
256
+ expectedRevision: 2,
257
+ currentRevision: 3,
258
+ },
259
+ }
260
+ const refresh = vi.fn()
261
+ const mutate = vi.fn().mockRejectedValue(error)
262
+ expect(await executeAdminTreeMove(mutate, refresh)).toEqual({ status: 'mutation-failed', error })
263
+ expect(mutate).toHaveBeenCalledTimes(1)
264
+ expect(refresh).not.toHaveBeenCalled()
265
+ })
266
+
267
+ it('retains the authorized schedule summary when refresh fails after a committed move', async () => {
268
+ const receipt = {
269
+ documentId: 'doc',
270
+ revision: 3,
271
+ affectedDocuments: [{ documentId: 'sibling', revision: 4 }],
272
+ scheduledPublicationsNeedReconfirmation: true,
273
+ }
274
+ const error = new Error('refresh unavailable')
275
+ expect(
276
+ await executeAdminTreeMove(
277
+ async () => receipt,
278
+ async () => {
279
+ throw error
280
+ }
281
+ )
282
+ ).toEqual({ status: 'refresh-failed', error, receipt })
283
+ })