@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 { withEditableRevision } from '../editable-revision.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
@@ -131,7 +132,7 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
131
132
  const hasRelations = Object.keys(populateMap).length > 0
132
133
  const populate: PopulateSpec | undefined = hasRelations ? populateMap : undefined
133
134
 
134
- const result = await handle.find({
135
+ const result = await handle.findForEdit({
135
136
  where: Object.keys(where).length > 0 ? where : undefined,
136
137
  sort: viewState.sort,
137
138
  locale: params.locale ?? 'en',
@@ -140,7 +141,6 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
140
141
  select: params.fields,
141
142
  populate,
142
143
  depth: hasRelations ? 1 : undefined,
143
- status: 'any',
144
144
  // Admin list: show the raw per-locale state (untranslated docs render
145
145
  // empty in the active locale's columns) rather than falling back to the
146
146
  // default locale. Consistent with the edit view; overrides the client's
@@ -205,5 +205,10 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
205
205
  return serialised as unknown as ReturnType<typeof list.parse>
206
206
  }
207
207
 
208
- return list.parse(serialised)
208
+ const parsed = list.parse(serialised)
209
+ const observations = new Map(result.docs.map((doc) => [doc.id, doc]))
210
+ return {
211
+ ...parsed,
212
+ docs: parsed.docs.map((doc) => withEditableRevision(doc, observations.get(doc.id))),
213
+ }
209
214
  })
@@ -1,3 +1,4 @@
1
+ import { withDocumentMutationErrors } from '../document-mutation-errors.js'
1
2
  /**
2
3
  * This Source Code is subject to the terms of the Mozilla Public
3
4
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -9,15 +10,7 @@
9
10
  import { createServerFn } from '@tanstack/react-start'
10
11
 
11
12
  import { getAdminRequestContext } from '@byline/client/server'
12
- import {
13
- assertActorCanPerform,
14
- ERR_NOT_FOUND,
15
- ERR_VALIDATION,
16
- generateKeyBetween,
17
- generateNKeysBetween,
18
- getLogger,
19
- getServerConfig,
20
- } from '@byline/core'
13
+ import { ERR_NOT_FOUND, getLogger, getServerConfig, reorderDocument } from '@byline/core'
21
14
 
22
15
  import { ensureCollection } from '../../integrations/api-utils.js'
23
16
 
@@ -27,138 +20,53 @@ import { ensureCollection } from '../../integrations/api-utils.js'
27
20
  // One of `beforeDocumentId` / `afterDocumentId` should be provided — they
28
21
  // identify the neighbours the dragged row should land between. Either may
29
22
  // be null:
30
- // - both null → empty collection or "append-to-end" no-op (writes
31
- // a fresh key)
23
+ // - both null → append to the end; retain the key if already there
32
24
  // - beforeId set, afterId null → append after `beforeId`
33
25
  // - beforeId null, afterId set → prepend before `afterId`
34
26
  //
35
- // Writes a single column on `byline_documents` and does NOT create a new
36
- // document version. Goes through the `collections.<path>.update` ability;
27
+ // Updates ordering and any necessary sibling-key repairs in one guarded
28
+ // transaction, without creating content versions. Uses `collections.<path>.update`;
37
29
  // reordering is metadata-level update, not a new ability slug.
38
30
  // ---------------------------------------------------------------------------
39
31
 
40
32
  export const reorderCollectionDocument = createServerFn({ method: 'POST' })
41
33
  .validator(
42
34
  (input: {
35
+ expectedRevision: number
43
36
  collection: string
44
37
  documentId: string
45
38
  beforeDocumentId?: string | null
46
39
  afterDocumentId?: string | null
47
40
  }) => input
48
41
  )
49
- .handler(async ({ data: input }) => {
50
- const { collection: path, documentId, beforeDocumentId, afterDocumentId } = input
51
- const logger = getLogger()
52
-
53
- const config = await ensureCollection(path)
54
- if (!config) {
55
- throw ERR_NOT_FOUND({
56
- message: 'Collection not found',
57
- details: { collectionPath: path },
58
- }).log(logger)
59
- }
60
-
61
- if (config.definition.orderable !== true) {
62
- throw ERR_VALIDATION({
63
- message: `collection '${path}' is not orderable; set \`orderable: true\` on its collection definition to enable reordering`,
64
- details: { collectionPath: path },
65
- }).log(logger)
66
- }
67
-
68
- const requestContext = await getAdminRequestContext()
69
- assertActorCanPerform(requestContext, config.definition, 'update')
70
-
71
- const serverConfig = getServerConfig()
72
- const collectionId = config.collection.id
73
-
74
- // Read the whole collection in canonical display order. Small by design
75
- // for orderable use cases (bios, FAQs, sections), so a full read is
76
- // cheap — and it gives us a single, consistent snapshot to reason about
77
- // both backfill and recovery in one pass.
78
- const canonical = await serverConfig.db.queries.documents.getCanonicalDocumentOrder({
79
- collection_id: collectionId,
80
- })
81
-
82
- // Detect pathological key state — duplicates or non-ascending runs of
83
- // keyed rows. These can land in the DB from prior buggy code paths
84
- // or interrupted writes; once present, `generateKeyBetween` throws
85
- // with `a >= b`. When detected, re-key the entire collection in the
86
- // editor's current visible order so subsequent operations work from
87
- // a clean baseline.
88
- let corrupted = false
89
- {
90
- const seen = new Set<string>()
91
- let lastKey: string | null = null
92
- for (const doc of canonical) {
93
- if (doc.order_key == null) continue
94
- if (seen.has(doc.order_key)) {
95
- corrupted = true
96
- break
97
- }
98
- if (lastKey != null && doc.order_key <= lastKey) {
99
- corrupted = true
100
- break
101
- }
102
- seen.add(doc.order_key)
103
- lastKey = doc.order_key
42
+ .handler(
43
+ withDocumentMutationErrors(async ({ data: input }) => {
44
+ const { collection: path, documentId, beforeDocumentId, afterDocumentId } = input
45
+ const logger = getLogger()
46
+
47
+ const config = await ensureCollection(path)
48
+ if (!config) {
49
+ throw ERR_NOT_FOUND({
50
+ message: 'Collection not found',
51
+ details: { collectionPath: path },
52
+ }).log(logger)
104
53
  }
105
- }
106
54
 
107
- if (corrupted) {
108
- const allKeys = generateNKeysBetween(null, null, canonical.length)
109
- for (let i = 0; i < canonical.length; i++) {
110
- await serverConfig.db.commands.documents.setOrderKey({
111
- document_id: canonical[i]?.id,
112
- order_key: allKeys[i]!,
113
- })
114
- }
115
- } else {
116
- // Happy path — backfill trailing NULLs after the largest existing
117
- // key. After canonical sort the NULL rows are always contiguous at
118
- // the tail (`NULLS LAST`).
119
- const firstNullIdx = canonical.findIndex((d) => d.order_key == null)
120
- if (firstNullIdx !== -1) {
121
- const nullDocs = canonical.slice(firstNullIdx)
122
- const lastExistingKey = firstNullIdx === 0 ? null : canonical[firstNullIdx - 1]?.order_key
123
- const newKeys = generateNKeysBetween(lastExistingKey, null, nullDocs.length)
124
- for (let i = 0; i < nullDocs.length; i++) {
125
- await serverConfig.db.commands.documents.setOrderKey({
126
- document_id: nullDocs[i]?.id,
127
- order_key: newKeys[i]!,
128
- })
129
- }
130
- }
131
- }
132
-
133
- // Keys are now clean — resolve neighbours and place the moved row.
134
- const { left, right } = await serverConfig.db.queries.documents.getNeighborOrderKeys({
135
- collection_id: collectionId,
136
- before_document_id: beforeDocumentId ?? null,
137
- after_document_id: afterDocumentId ?? null,
138
- })
139
-
140
- let newKey: string
141
- try {
142
- newKey = generateKeyBetween(left, right)
143
- } catch (err) {
144
- throw ERR_VALIDATION({
145
- message: 'cannot generate order_key between supplied neighbors',
146
- details: {
55
+ const serverConfig = getServerConfig()
56
+ const result = await reorderDocument(
57
+ {
58
+ db: serverConfig.db,
59
+ definition: config.definition,
60
+ collectionId: config.collection.id,
61
+ collectionVersion: config.collection.version,
147
62
  collectionPath: path,
148
- documentId,
149
- beforeDocumentId,
150
- afterDocumentId,
151
- left,
152
- right,
153
- cause: err instanceof Error ? err.message : String(err),
63
+ requestContext: await getAdminRequestContext(),
64
+ logger,
65
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
66
+ slugifier: serverConfig.slugifier,
154
67
  },
155
- }).log(logger)
156
- }
157
-
158
- await serverConfig.db.commands.documents.setOrderKey({
159
- document_id: documentId,
160
- order_key: newKey,
68
+ { documentId, expectedRevision: input.expectedRevision, beforeDocumentId, afterDocumentId }
69
+ )
70
+ return { status: 'ok' as const, ...result }
161
71
  })
162
-
163
- return { status: 'ok' as const, orderKey: newKey }
164
- })
72
+ )
@@ -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
@@ -14,46 +15,60 @@ import type { DocumentLifecycleContext } from '@byline/core/services'
14
15
  import { restoreDocumentVersion as restoreDocumentVersionService } from '@byline/core/services'
15
16
 
16
17
  import { ensureCollection } from '../../integrations/api-utils.js'
18
+ import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
17
19
 
18
20
  // ---------------------------------------------------------------------------
19
21
  // Restore a historical document version as the new current version
20
22
  // ---------------------------------------------------------------------------
21
23
 
22
24
  export const restoreDocumentVersion = createServerFn({ method: 'POST' })
23
- .validator((input: { collection: string; id: string; versionId: string }) => input)
24
- .handler(async ({ data: input }) => {
25
- const { collection: path, id, versionId } = input
26
- const logger = getLogger()
27
- const config = await ensureCollection(path)
28
- if (!config) {
29
- throw ERR_NOT_FOUND({
30
- message: 'Collection not found',
31
- details: { collectionPath: path },
32
- }).log(logger)
33
- }
34
-
35
- const serverConfig = getServerConfig()
36
- const ctx: DocumentLifecycleContext = {
37
- db: serverConfig.db,
38
- definition: config.definition,
39
- collectionId: config.collection.id,
40
- collectionVersion: config.collection.version,
41
- collectionPath: path,
42
- logger,
43
- defaultLocale: serverConfig.i18n.content.defaultLocale,
44
- slugifier: serverConfig.slugifier,
45
- requestContext: await getAdminRequestContext(),
46
- }
47
-
48
- const result = await restoreDocumentVersionService(ctx, {
49
- documentId: id,
50
- sourceVersionId: versionId,
51
- })
25
+ .validator(
26
+ (input: { expectedRevision: number; collection: string; id: string; versionId: string }) =>
27
+ input
28
+ )
29
+ .handler(
30
+ withDocumentMutationErrors(async ({ data: input }) => {
31
+ const { collection: path, id, versionId } = input
32
+ const logger = getLogger()
33
+ const config = await ensureCollection(path)
34
+ if (!config) {
35
+ throw ERR_NOT_FOUND({
36
+ message: 'Collection not found',
37
+ details: { collectionPath: path },
38
+ }).log(logger)
39
+ }
40
+
41
+ const serverConfig = getServerConfig()
42
+ const ctx: DocumentLifecycleContext = {
43
+ db: serverConfig.db,
44
+ definition: config.definition,
45
+ collectionId: config.collection.id,
46
+ collectionVersion: config.collection.version,
47
+ collectionPath: path,
48
+ logger,
49
+ defaultLocale: serverConfig.i18n.content.defaultLocale,
50
+ slugifier: serverConfig.slugifier,
51
+ requestContext: await getAdminRequestContext(),
52
+ }
52
53
 
53
- return {
54
- status: 'ok' as const,
55
- documentId: result.documentId,
56
- documentVersionId: result.documentVersionId,
57
- sourceVersionId: result.sourceVersionId,
58
- }
59
- })
54
+ try {
55
+ const result = await restoreDocumentVersionService(ctx, {
56
+ documentId: id,
57
+ expectedRevision: input.expectedRevision,
58
+ sourceVersionId: versionId,
59
+ })
60
+
61
+ return {
62
+ status: 'ok' as const,
63
+ documentId: result.documentId,
64
+ revision: result.revision,
65
+ documentVersionId: result.documentVersionId,
66
+ sourceVersionId: result.sourceVersionId,
67
+ }
68
+ } catch (error) {
69
+ const committedFailure = toCommittedDocumentHookFailureResponse(error)
70
+ if (committedFailure != null) return committedFailure
71
+ throw error
72
+ }
73
+ })
74
+ )
@@ -0,0 +1,108 @@
1
+ import { createSuperAdminContext } from '@byline/auth'
2
+ import {
3
+ defineCollection,
4
+ defineServerConfig,
5
+ getDocumentRevisionValidationDetails,
6
+ } from '@byline/core'
7
+ import { defineLogger } from '@byline/core/logger'
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ import { testAdapter } from '../../../../core/src/storage/db-adapter.test-helper.js'
11
+
12
+ const fixtures = vi.hoisted(() => ({ ensureCollection: vi.fn(), getAdminRequestContext: vi.fn() }))
13
+ // Simulate only the compiler's binding of a validator to a handler. These are
14
+ // the real exported host handlers and core services, not mocked lifecycle calls.
15
+ vi.mock('@tanstack/react-start', () => ({
16
+ createServerFn: () => {
17
+ let validate = (value: unknown) => value
18
+ const chain = {
19
+ validator(fn: (value: unknown) => unknown) {
20
+ validate = fn
21
+ return chain
22
+ },
23
+ handler(fn: (args: { data: unknown }) => Promise<unknown>) {
24
+ return (args: { data: unknown }) => fn({ data: validate(args.data) })
25
+ },
26
+ }
27
+ return chain
28
+ },
29
+ }))
30
+ vi.mock('../../integrations/api-utils.js', () => ({ ensureCollection: fixtures.ensureCollection }))
31
+ vi.mock('@byline/client/server', () => ({
32
+ getAdminRequestContext: fixtures.getAdminRequestContext,
33
+ }))
34
+
35
+ import { deleteDocument } from './delete.js'
36
+ import { unpublishDocument, updateDocumentStatus } from './status.js'
37
+ import {
38
+ updateCollectionDocumentSystemFields,
39
+ updateCollectionDocumentWithPatches,
40
+ } from './update.js'
41
+
42
+ const write = vi.fn((): never => {
43
+ throw new Error('Unexpected document write')
44
+ })
45
+ beforeEach(() => {
46
+ vi.clearAllMocks()
47
+ const silent = () => {}
48
+ defineLogger({
49
+ log: silent,
50
+ fatal: silent,
51
+ error: silent,
52
+ warn: silent,
53
+ info: silent,
54
+ debug: silent,
55
+ trace: silent,
56
+ silent,
57
+ })
58
+ const definition = defineCollection({
59
+ path: 'pages',
60
+ labels: { singular: 'Page', plural: 'Pages' },
61
+ fields: [],
62
+ })
63
+ fixtures.ensureCollection.mockResolvedValue({
64
+ definition,
65
+ collection: { id: 'collection', version: 1 },
66
+ })
67
+ fixtures.getAdminRequestContext.mockResolvedValue(createSuperAdminContext({ id: 'editor' }))
68
+ defineServerConfig({
69
+ collections: [definition],
70
+ db: testAdapter({ withTransaction: write }),
71
+ i18n: {
72
+ admin: { defaultLocale: 'en', locales: [] },
73
+ content: { defaultLocale: 'en', locales: ['en'] },
74
+ },
75
+ })
76
+ })
77
+
78
+ const operations = [
79
+ ['save', updateCollectionDocumentWithPatches, { patches: [] }],
80
+ ['metadata', updateCollectionDocumentSystemFields, { path: 'changed' }],
81
+ ['status', updateDocumentStatus, { status: 'published' }],
82
+ ['unpublish', unpublishDocument, {}],
83
+ ['delete', deleteDocument, {}],
84
+ ] as const
85
+
86
+ describe('raw host revision inputs', () => {
87
+ for (const [name, operation, extra] of operations) {
88
+ for (const revision of [undefined, null, '1', 0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
89
+ it(`${name} rejects ${String(revision)} without writing`, async () => {
90
+ const data = {
91
+ collection: 'pages',
92
+ id: 'document',
93
+ ...extra,
94
+ ...(revision === undefined ? {} : { expectedRevision: revision }),
95
+ }
96
+ const error = await Reflect.apply(operation, undefined, [{ data }]).catch(
97
+ (error: unknown) => error
98
+ )
99
+ expect(error).toMatchObject({ code: 'ERR_VALIDATION' })
100
+ expect(getDocumentRevisionValidationDetails(error)).toEqual({
101
+ reason:
102
+ revision === undefined ? 'missing_document_revision' : 'invalid_document_revision',
103
+ })
104
+ expect(write).not.toHaveBeenCalled()
105
+ })
106
+ }
107
+ }
108
+ })
@@ -0,0 +1,80 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { ERR_DOCUMENT_HOOK_COMMITTED, ErrorCodes } from '@byline/core'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import {
13
+ hasCommittedDocumentHookFailure,
14
+ toCommittedDocumentHookFailureResponse,
15
+ } from './save-outcome.js'
16
+
17
+ describe('collection save outcome transport', () => {
18
+ it('returns only allowlisted committed hook metadata', () => {
19
+ const error = ERR_DOCUMENT_HOOK_COMMITTED({
20
+ message: 'internal message',
21
+ cause: new Error('secret hook failure'),
22
+ details: {
23
+ phase: 'afterCreate',
24
+ documentId: 'doc-1',
25
+ documentVersionId: 'version-1',
26
+ revision: 2,
27
+ sideEffectCode: ErrorCodes.STORAGE,
28
+ privatePath: '/private/cache/key',
29
+ },
30
+ })
31
+
32
+ const response = toCommittedDocumentHookFailureResponse(error)
33
+
34
+ expect(response).toEqual({
35
+ status: 'committed-hook-failed',
36
+ documentId: 'doc-1',
37
+ documentVersionId: 'version-1',
38
+ revision: 2,
39
+ sideEffectFailure: { phase: 'afterCreate', code: 'ERR_STORAGE' },
40
+ })
41
+ expect(JSON.stringify(response)).not.toContain('secret hook failure')
42
+ expect(JSON.stringify(response)).not.toContain('/private/cache/key')
43
+ expect(hasCommittedDocumentHookFailure(response)).toBe(true)
44
+ })
45
+
46
+ it('accepts the singleton afterSave phase exported by core', () => {
47
+ const response = toCommittedDocumentHookFailureResponse(
48
+ ERR_DOCUMENT_HOOK_COMMITTED({
49
+ message: 'singleton committed',
50
+ details: {
51
+ phase: 'afterSave',
52
+ documentId: 'doc-singleton',
53
+ documentVersionId: 'version-singleton',
54
+ revision: 2,
55
+ sideEffectCode: ErrorCodes.UNHANDLED,
56
+ },
57
+ })
58
+ )
59
+
60
+ expect(response?.sideEffectFailure).toEqual({
61
+ phase: 'afterSave',
62
+ code: ErrorCodes.UNHANDLED,
63
+ })
64
+ })
65
+
66
+ it('does not classify ordinary or malformed failures as committed', () => {
67
+ expect(toCommittedDocumentHookFailureResponse(new Error('write failed'))).toBeNull()
68
+ expect(
69
+ toCommittedDocumentHookFailureResponse({
70
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
71
+ message: 'missing ids',
72
+ details: {
73
+ phase: 'afterUpdate',
74
+ sideEffectCode: ErrorCodes.UNHANDLED,
75
+ },
76
+ })
77
+ ).toBeNull()
78
+ expect(hasCommittedDocumentHookFailure({ status: 'ok' })).toBe(false)
79
+ })
80
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import {
10
+ type DocumentHookCommittedPhase,
11
+ type DocumentHookSideEffectCode,
12
+ getDocumentHookCommittedDetails,
13
+ } from '@byline/core/services'
14
+
15
+ export interface CollectionDocumentCommittedHookFailureResponse {
16
+ status: 'committed-hook-failed'
17
+ documentId: string
18
+ documentVersionId: string
19
+ revision: number
20
+ sideEffectFailure: {
21
+ phase: DocumentHookCommittedPhase
22
+ code: DocumentHookSideEffectCode
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Convert only validated core committed-hook errors into safe wire data. Raw
28
+ * hook errors, messages, stack traces, and storage paths remain server-side.
29
+ */
30
+ export function toCommittedDocumentHookFailureResponse(
31
+ error: unknown
32
+ ): CollectionDocumentCommittedHookFailureResponse | null {
33
+ const details = getDocumentHookCommittedDetails(error)
34
+ if (details == null) return null
35
+
36
+ return {
37
+ status: 'committed-hook-failed',
38
+ documentId: details.documentId,
39
+ documentVersionId: details.documentVersionId,
40
+ revision: details.revision,
41
+ sideEffectFailure: {
42
+ phase: details.phase,
43
+ code: details.sideEffectCode,
44
+ },
45
+ }
46
+ }
47
+
48
+ export function hasCommittedDocumentHookFailure(
49
+ result: unknown
50
+ ): result is CollectionDocumentCommittedHookFailureResponse {
51
+ return (
52
+ typeof result === 'object' &&
53
+ result != null &&
54
+ 'status' in result &&
55
+ result.status === 'committed-hook-failed'
56
+ )
57
+ }