@byline/host-tanstack-start 4.16.0 → 4.17.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 (146) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -14
  2. package/dist/admin-shell/chrome/dashboard.js +41 -5
  3. package/dist/admin-shell/chrome/dashboard.test.d.ts +8 -0
  4. package/dist/admin-shell/collections/api.d.ts +2 -2
  5. package/dist/admin-shell/collections/create.d.ts +2 -2
  6. package/dist/admin-shell/collections/create.js +1 -0
  7. package/dist/admin-shell/collections/edit.d.ts +2 -2
  8. package/dist/admin-shell/collections/edit.js +1 -0
  9. package/dist/admin-shell/collections/history.d.ts +4 -12
  10. package/dist/admin-shell/collections/history.js +112 -303
  11. package/dist/admin-shell/collections/reindex-button.d.ts +1 -1
  12. package/dist/admin-shell/collections/reindex-button.js +5 -1
  13. package/dist/admin-shell/collections/restore-version-modal.d.ts +4 -4
  14. package/dist/admin-shell/collections/restore-version-modal.js +8 -24
  15. package/dist/admin-shell/collections/version-history.d.ts +72 -0
  16. package/dist/admin-shell/collections/version-history.js +259 -0
  17. package/dist/admin-shell/singletons/history.d.ts +19 -0
  18. package/dist/admin-shell/singletons/history.js +170 -0
  19. package/dist/admin-shell/singletons/history.test.d.ts +8 -0
  20. package/dist/admin-shell/singletons/preview-link.d.ts +12 -0
  21. package/dist/admin-shell/singletons/preview-link.js +54 -0
  22. package/dist/admin-shell/singletons/view-menu.d.ts +13 -0
  23. package/dist/admin-shell/singletons/view-menu.js +116 -0
  24. package/dist/admin-shell/singletons/view.d.ts +18 -0
  25. package/dist/admin-shell/singletons/view.js +259 -0
  26. package/dist/admin-shell/singletons/view.test.d.ts +8 -0
  27. package/dist/integrations/api-utils.d.ts +18 -4
  28. package/dist/integrations/api-utils.js +10 -3
  29. package/dist/routes/create-admin-dashboard-route.js +2 -2
  30. package/dist/routes/create-collection-api-route.js +3 -3
  31. package/dist/routes/create-collection-create-route.js +4 -3
  32. package/dist/routes/create-collection-edit-route.js +4 -3
  33. package/dist/routes/create-collection-history-route.js +4 -3
  34. package/dist/routes/create-collection-list-route.js +4 -3
  35. package/dist/routes/create-singleton-history-route.d.ts +8 -0
  36. package/dist/routes/create-singleton-history-route.js +99 -0
  37. package/dist/routes/create-singleton-route.d.ts +35 -0
  38. package/dist/routes/create-singleton-route.js +130 -0
  39. package/dist/routes/get-multi-collection-definition.d.ts +10 -0
  40. package/dist/routes/get-multi-collection-definition.js +6 -0
  41. package/dist/routes/get-singleton-definition.d.ts +10 -0
  42. package/dist/routes/get-singleton-definition.js +6 -0
  43. package/dist/routes/index.d.ts +2 -0
  44. package/dist/routes/index.js +2 -0
  45. package/dist/server-fns/actors.d.ts +16 -0
  46. package/dist/server-fns/actors.js +25 -0
  47. package/dist/server-fns/admin-document-presentation.d.ts +30 -0
  48. package/dist/server-fns/admin-document-presentation.js +32 -0
  49. package/dist/server-fns/collections/actors.d.ts +1 -10
  50. package/dist/server-fns/collections/actors.js +1 -25
  51. package/dist/server-fns/collections/get.js +9 -26
  52. package/dist/server-fns/collections/reorder.js +1 -1
  53. package/dist/server-fns/collections/scheduled-publication.js +2 -2
  54. package/dist/server-fns/collections/upload.js +3 -3
  55. package/dist/server-fns/collections/utils.d.ts +1 -7
  56. package/dist/server-fns/collections/utils.js +1 -4
  57. package/dist/server-fns/serialise.d.ts +14 -0
  58. package/dist/server-fns/serialise.js +4 -0
  59. package/dist/server-fns/singleton-document-read.d.ts +20 -0
  60. package/dist/server-fns/singleton-document-read.js +10 -0
  61. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +12 -0
  62. package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -0
  63. package/dist/server-fns/singletons/change-status.d.ts +14 -0
  64. package/dist/server-fns/singletons/change-status.js +7 -0
  65. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +14 -0
  66. package/dist/server-fns/singletons/confirm-scheduled-publish.js +9 -0
  67. package/dist/server-fns/singletons/copy-to-locale.d.ts +18 -0
  68. package/dist/server-fns/singletons/copy-to-locale.js +11 -0
  69. package/dist/server-fns/singletons/find-by-version.d.ts +16 -0
  70. package/dist/server-fns/singletons/find-by-version.js +9 -0
  71. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +12 -0
  72. package/dist/server-fns/singletons/get-scheduled-publish.js +7 -0
  73. package/dist/server-fns/singletons/get.d.ts +24 -0
  74. package/dist/server-fns/singletons/get.js +6 -0
  75. package/dist/server-fns/singletons/history.d.ts +31 -0
  76. package/dist/server-fns/singletons/history.js +22 -0
  77. package/dist/server-fns/singletons/index.d.ts +19 -0
  78. package/dist/server-fns/singletons/index.js +12 -0
  79. package/dist/server-fns/singletons/restore-version.d.ts +14 -0
  80. package/dist/server-fns/singletons/restore-version.js +7 -0
  81. package/dist/server-fns/singletons/schedule-publish.d.ts +16 -0
  82. package/dist/server-fns/singletons/schedule-publish.js +10 -0
  83. package/dist/server-fns/singletons/unpublish.d.ts +12 -0
  84. package/dist/server-fns/singletons/unpublish.js +7 -0
  85. package/dist/server-fns/singletons/update.d.ts +14 -0
  86. package/dist/server-fns/singletons/update.js +14 -0
  87. package/dist/vitest-jsdom.test.d.ts +8 -0
  88. package/package.json +24 -18
  89. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -13
  90. package/src/admin-shell/chrome/dashboard.test.tsx +199 -0
  91. package/src/admin-shell/chrome/dashboard.tsx +43 -9
  92. package/src/admin-shell/collections/api.tsx +2 -2
  93. package/src/admin-shell/collections/create.tsx +6 -2
  94. package/src/admin-shell/collections/edit.tsx +6 -2
  95. package/src/admin-shell/collections/history.module.css +1 -1
  96. package/src/admin-shell/collections/history.tsx +162 -442
  97. package/src/admin-shell/collections/reindex-button.tsx +4 -2
  98. package/src/admin-shell/collections/restore-version-modal.tsx +19 -25
  99. package/src/admin-shell/collections/version-history.tsx +349 -0
  100. package/src/admin-shell/singletons/history.test.tsx +407 -0
  101. package/src/admin-shell/singletons/history.tsx +185 -0
  102. package/src/admin-shell/singletons/preview-link.tsx +75 -0
  103. package/src/admin-shell/singletons/view-menu.tsx +136 -0
  104. package/src/admin-shell/singletons/view.test.tsx +400 -0
  105. package/src/admin-shell/singletons/view.tsx +351 -0
  106. package/src/integrations/api-utils.ts +34 -9
  107. package/src/routes/create-admin-dashboard-route.test.node.ts +70 -0
  108. package/src/routes/create-admin-dashboard-route.tsx +10 -2
  109. package/src/routes/create-collection-api-route.tsx +4 -4
  110. package/src/routes/create-collection-create-route.tsx +5 -8
  111. package/src/routes/create-collection-edit-route.tsx +5 -4
  112. package/src/routes/create-collection-history-route.tsx +5 -8
  113. package/src/routes/create-collection-list-route.tsx +5 -8
  114. package/src/routes/create-singleton-history-route.test.node.ts +148 -0
  115. package/src/routes/create-singleton-history-route.tsx +116 -0
  116. package/src/routes/create-singleton-route.test.node.ts +172 -0
  117. package/src/routes/create-singleton-route.tsx +176 -0
  118. package/src/routes/get-multi-collection-definition.ts +16 -0
  119. package/src/routes/get-singleton-definition.ts +16 -0
  120. package/src/routes/index.ts +2 -0
  121. package/src/server-fns/actors.ts +43 -0
  122. package/src/server-fns/admin-document-presentation.ts +78 -0
  123. package/src/server-fns/collections/actors.ts +1 -52
  124. package/src/server-fns/collections/get.ts +10 -49
  125. package/src/server-fns/collections/reorder.ts +1 -1
  126. package/src/server-fns/collections/scheduled-publication.ts +7 -1
  127. package/src/server-fns/collections/upload-singleton.test.node.ts +164 -0
  128. package/src/server-fns/collections/upload.ts +7 -3
  129. package/src/server-fns/collections/utils.ts +1 -9
  130. package/src/server-fns/serialise.ts +17 -0
  131. package/src/server-fns/singleton-document-read.ts +32 -0
  132. package/src/server-fns/singletons/cancel-scheduled-publish.ts +19 -0
  133. package/src/server-fns/singletons/change-status.ts +19 -0
  134. package/src/server-fns/singletons/confirm-scheduled-publish.ts +23 -0
  135. package/src/server-fns/singletons/copy-to-locale.ts +32 -0
  136. package/src/server-fns/singletons/find-by-version.ts +23 -0
  137. package/src/server-fns/singletons/get-scheduled-publish.ts +19 -0
  138. package/src/server-fns/singletons/get.ts +32 -0
  139. package/src/server-fns/singletons/history.ts +41 -0
  140. package/src/server-fns/singletons/index.ts +20 -0
  141. package/src/server-fns/singletons/restore-version.ts +19 -0
  142. package/src/server-fns/singletons/schedule-publish.ts +24 -0
  143. package/src/server-fns/singletons/singletons.test.node.ts +298 -0
  144. package/src/server-fns/singletons/unpublish.ts +19 -0
  145. package/src/server-fns/singletons/update.ts +31 -0
  146. package/src/vitest-jsdom.test.tsx +27 -0
@@ -51,3 +51,5 @@ export {
51
51
  type CreateSignInRouteOptions,
52
52
  createSignInRoute,
53
53
  } from './create-sign-in-route.js'
54
+ export { createSingletonHistoryRoute } from './create-singleton-history-route.js'
55
+ export { createSingletonRoute } from './create-singleton-route.js'
@@ -0,0 +1,43 @@
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 type { AdminUserRow } from '@byline/admin/admin-users'
10
+
11
+ import { bylineCore } from '../integrations/byline-core.js'
12
+
13
+ export type ActorLabelMap = Record<string, { label: string }>
14
+
15
+ /** Display label: full name → username → email. */
16
+ function labelFor(row: AdminUserRow): string {
17
+ const name = [row.given_name, row.family_name].filter(Boolean).join(' ')
18
+ return name || row.username || row.email
19
+ }
20
+
21
+ /**
22
+ * Batch-resolve admin-user ids to display labels. The admin server-fn layer
23
+ * owns this presentation concern so both collection and singleton history
24
+ * can share it without teaching document storage about admin-realm tables.
25
+ */
26
+ export async function resolveActorLabels(
27
+ ids: Iterable<string | null | undefined>
28
+ ): Promise<ActorLabelMap> {
29
+ const unique = [
30
+ ...new Set([...ids].filter((id): id is string => typeof id === 'string' && id.length > 0)),
31
+ ]
32
+ if (unique.length === 0) return {}
33
+
34
+ const store = bylineCore().adminStore
35
+ if (store == null) return {}
36
+
37
+ const rows = await store.adminUsers.getByIds(unique)
38
+ const map: ActorLabelMap = {}
39
+ for (const row of rows) {
40
+ map[row.id] = { label: labelFor(row) }
41
+ }
42
+ return map
43
+ }
@@ -0,0 +1,78 @@
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
+ buildRelationSummaryPopulateMap,
11
+ type CollectionDefinition,
12
+ getCollectionAdminConfig,
13
+ getCollectionDefinition,
14
+ type PopulateSpec,
15
+ } from '@byline/core'
16
+
17
+ export interface AdminDocumentReadInput {
18
+ locale?: string
19
+ depth?: number
20
+ populateRelations?: boolean
21
+ }
22
+
23
+ export interface AdminDocumentReadOptions {
24
+ locale: string
25
+ populate?: PopulateSpec
26
+ depth?: number
27
+ status: 'any'
28
+ onMissingLocale: 'empty'
29
+ lenient: true
30
+ }
31
+
32
+ /**
33
+ * Resolve the shared read controls used by collection and singleton editors.
34
+ * Explicit depth is the API-preview mode; otherwise relation-summary mode
35
+ * projects only each target's picker/title fields for first-paint tiles.
36
+ */
37
+ export function resolveAdminDocumentRead(
38
+ definition: CollectionDefinition | null,
39
+ input: AdminDocumentReadInput
40
+ ): { options: AdminDocumentReadOptions; populatedTree: boolean } {
41
+ const populateRequested = typeof input.depth === 'number' && input.depth > 0
42
+ const autoRelationsActive = !populateRequested && input.populateRelations === true
43
+
44
+ let populate: PopulateSpec | undefined
45
+ let depth: number | undefined
46
+ if (populateRequested) {
47
+ populate = '*'
48
+ depth = input.depth
49
+ } else if (autoRelationsActive && definition != null) {
50
+ const populateMap = buildRelationSummaryPopulateMap(definition.fields, (targetPath) => ({
51
+ def: getCollectionDefinition(targetPath),
52
+ admin: getCollectionAdminConfig(targetPath),
53
+ }))
54
+ if (Object.keys(populateMap).length > 0) {
55
+ populate = populateMap
56
+ depth = 1
57
+ }
58
+ }
59
+
60
+ return {
61
+ options: {
62
+ locale: input.locale ?? 'en',
63
+ populate,
64
+ depth,
65
+ status: 'any',
66
+ // Editors need the raw requested-locale values. Untranslated localized
67
+ // fields stay empty instead of falling back, which is the signal that
68
+ // enables the Copy to Locale workflow. Using `omit` would hide the whole
69
+ // resource and make a missing translation look like a missing slot.
70
+ onMissingLocale: 'empty',
71
+ // Older rows may no longer reconstruct exactly against the current
72
+ // schema. Keep the editor loadable and surface those mismatches through
73
+ // `_restoreWarnings`, which the form renders as an alert.
74
+ lenient: true,
75
+ },
76
+ populatedTree: populateRequested || autoRelationsActive,
77
+ }
78
+ }
@@ -6,55 +6,4 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- /**
10
- * Audit actor-label resolution (docs/07-auth-and-security/02-auditability.md — Workstream 1).
11
- *
12
- * Shaped documents carry only the raw `createdBy` uuid; turning ids into
13
- * display labels is an **admin-realm** concern, so it happens here — in
14
- * the admin server-fn layer, which already holds the admin actor and the
15
- * `AdminStore` — never as a JOIN inside the document storage adapter
16
- * (which must stay ignorant of admin-realm tables for the future
17
- * `UserAuth` writer realm).
18
- *
19
- * Responses attach the result as an `actors` map alongside the page of
20
- * documents; the UI joins by id. Ids with no matching admin-user row
21
- * (deleted users) are absent from the map — consumers render a tombstone
22
- * label.
23
- */
24
-
25
- import type { AdminUserRow } from '@byline/admin/admin-users'
26
-
27
- import { bylineCore } from '../../integrations/byline-core.js'
28
-
29
- export type ActorLabelMap = Record<string, { label: string }>
30
-
31
- /** Display label: full name → username → email. */
32
- function labelFor(row: AdminUserRow): string {
33
- const name = [row.given_name, row.family_name].filter(Boolean).join(' ')
34
- return name || row.username || row.email
35
- }
36
-
37
- /**
38
- * Batch-resolve admin-user ids to display labels. Accepts the raw
39
- * `createdBy` values straight off a page of shaped documents — nullish
40
- * and duplicate entries are tolerated and deduplicated. Returns `{}`
41
- * when there is nothing to resolve or no admin store is configured.
42
- */
43
- export async function resolveActorLabels(
44
- ids: Iterable<string | null | undefined>
45
- ): Promise<ActorLabelMap> {
46
- const unique = [
47
- ...new Set([...ids].filter((id): id is string => typeof id === 'string' && id.length > 0)),
48
- ]
49
- if (unique.length === 0) return {}
50
-
51
- const store = bylineCore().adminStore
52
- if (store == null) return {}
53
-
54
- const rows = await store.adminUsers.getByIds(unique)
55
- const map: ActorLabelMap = {}
56
- for (const row of rows) {
57
- map[row.id] = { label: labelFor(row) }
58
- }
59
- return map
60
- }
9
+ export { type ActorLabelMap, resolveActorLabels } from '../actors.js'
@@ -11,19 +11,16 @@ import { createServerFn } from '@tanstack/react-start'
11
11
  import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
12
12
  import {
13
13
  BylineError,
14
- buildRelationSummaryPopulateMap,
15
- collectionAbilityKey,
14
+ documentAbilityKey,
16
15
  ERR_NOT_FOUND,
17
16
  ErrorCodes,
18
- getCollectionAdminConfig,
19
- getCollectionDefinition,
20
17
  getCollectionSchemasForPath,
21
18
  getLogger,
22
19
  getServerConfig,
23
- type PopulateSpec,
24
20
  } from '@byline/core'
25
21
 
26
22
  import { ensureCollection } from '../../integrations/api-utils.js'
23
+ import { resolveAdminDocumentRead } from '../admin-document-presentation.js'
27
24
  import { serialise } from './utils'
28
25
 
29
26
  // ---------------------------------------------------------------------------
@@ -54,49 +51,14 @@ const getDocumentFn = createServerFn({ method: 'GET' })
54
51
  const client = getAdminBylineClient()
55
52
  const handle = client.collection(path)
56
53
 
57
- // Determine populate strategy. The admin API-preview path passes an
58
- // explicit `depth > 0` and uses `'*'` to walk every relation with the
59
- // full document projection — useful for the debug/preview UI but
60
- // wasteful elsewhere. The admin edit loader sets `populateRelations`
61
- // to auto-build a depth-1 projection from the schema's relation
62
- // fields, so each target gets just its picker columns + useAsTitle
63
- // and relation-summary tiles render on first paint without per-tile
64
- // fetches.
65
- const populateRequested = typeof depth === 'number' && depth > 0
66
- const autoRelationsActive = !populateRequested && populateRelations === true
67
-
68
- let populate: PopulateSpec | undefined
69
- let resolvedDepth: number | undefined
70
- if (populateRequested) {
71
- populate = '*'
72
- resolvedDepth = depth
73
- } else if (autoRelationsActive) {
74
- const populateMap = buildRelationSummaryPopulateMap(
75
- config.definition.fields,
76
- (targetPath) => ({
77
- def: getCollectionDefinition(targetPath),
78
- admin: getCollectionAdminConfig(targetPath),
79
- })
80
- )
81
- if (Object.keys(populateMap).length > 0) {
82
- populate = populateMap
83
- resolvedDepth = 1
84
- }
85
- }
54
+ const { options: readOptions, populatedTree } = resolveAdminDocumentRead(config.definition, {
55
+ locale,
56
+ depth,
57
+ populateRelations,
58
+ })
86
59
 
87
60
  const document = await handle.findById(id, {
88
- locale: locale ?? 'en',
89
- populate,
90
- depth: resolvedDepth,
91
- status: 'any',
92
- // Admin edit path: show the RAW per-locale values — untranslated localized
93
- // fields stay empty (the signal to use "Copy to Locale"), never falling
94
- // back to the default locale. Overrides the client's `'fallback'` default.
95
- onMissingLocale: 'empty',
96
- // Admin edit path: tolerate schema-mismatch warnings rather than
97
- // hard-failing the load. Warnings (if any) come back on the document
98
- // as `_restoreWarnings` and the edit form surfaces them via an Alert.
99
- lenient: true,
61
+ ...readOptions,
100
62
  })
101
63
 
102
64
  if (!document) {
@@ -114,7 +76,6 @@ const getDocumentFn = createServerFn({ method: 'GET' })
114
76
  // don't match the raw relation-ref shape the schema expects —
115
77
  // applies equally to the depth-based preview path and the
116
78
  // admin-edit relation-summary path)
117
- const populatedTree = populateRequested || autoRelationsActive
118
79
  const parsed =
119
80
  locale === 'all' || populatedTree
120
81
  ? (serialised as Record<string, any>)
@@ -177,8 +138,8 @@ const getDocumentFn = createServerFn({ method: 'GET' })
177
138
  if (scheduledPublicationEnabled) {
178
139
  const actor = (await getAdminRequestContext()).actor
179
140
  canSchedulePublication =
180
- actor?.hasAbility(collectionAbilityKey(path, 'changeStatus')) === true &&
181
- actor.hasAbility(collectionAbilityKey(path, 'publish'))
141
+ actor?.hasAbility(documentAbilityKey(config.definition, 'changeStatus')) === true &&
142
+ actor.hasAbility(documentAbilityKey(config.definition, 'publish'))
182
143
  if (canSchedulePublication) {
183
144
  scheduledPublish = await handle.getScheduledPublish(id)
184
145
  }
@@ -66,7 +66,7 @@ export const reorderCollectionDocument = createServerFn({ method: 'POST' })
66
66
  }
67
67
 
68
68
  const requestContext = await getAdminRequestContext()
69
- assertActorCanPerform(requestContext, path, 'update')
69
+ assertActorCanPerform(requestContext, config.definition, 'update')
70
70
 
71
71
  const serverConfig = getServerConfig()
72
72
  const collectionId = config.collection.id
@@ -15,6 +15,7 @@ import {
15
15
  type DocumentPublishScheduleState,
16
16
  ERR_NOT_FOUND,
17
17
  ERR_VALIDATION,
18
+ isSingleton,
18
19
  type RecurringTaskHealth,
19
20
  } from '@byline/core'
20
21
  import { listDocumentPublishSchedules } from '@byline/core/services'
@@ -247,7 +248,12 @@ export const listScheduledPublications = createServerFn({ method: 'GET' })
247
248
  return {
248
249
  ...serializeSchedule(schedule),
249
250
  collectionPath: definition?.path ?? schedule.collectionId,
250
- collectionLabel: definition?.labels.plural ?? definition?.path ?? schedule.collectionId,
251
+ collectionLabel:
252
+ definition == null
253
+ ? schedule.collectionId
254
+ : isSingleton(definition)
255
+ ? definition.label
256
+ : definition.labels.plural,
251
257
  documentPath,
252
258
  lastAuthorizedByName:
253
259
  schedule.lastAuthorizedBy == null
@@ -0,0 +1,164 @@
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 { AdminAuth, createRequestContext } from '@byline/auth'
10
+ import {
11
+ defineServerConfig,
12
+ defineSingleton,
13
+ type IDbAdapter,
14
+ type IStorageProvider,
15
+ } from '@byline/core'
16
+ import { defineLogger } from '@byline/core/logger'
17
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
18
+
19
+ const mocks = vi.hoisted(() => ({
20
+ getAdminRequestContext: vi.fn(),
21
+ getCollectionRecord: vi.fn(),
22
+ }))
23
+
24
+ vi.mock('@tanstack/react-start', () => ({
25
+ createServerFn: () => {
26
+ let validate = (input: unknown) => input
27
+ const chain = {
28
+ validator(validator: (input: unknown) => unknown) {
29
+ validate = validator
30
+ return chain
31
+ },
32
+ handler(handler: (options: { data: any }) => Promise<unknown>) {
33
+ return async (options: { data: unknown }) => handler({ data: validate(options.data) })
34
+ },
35
+ }
36
+ return chain
37
+ },
38
+ }))
39
+
40
+ vi.mock('@byline/client/server', () => ({
41
+ getAdminRequestContext: mocks.getAdminRequestContext,
42
+ }))
43
+
44
+ vi.mock('@byline/core/image', () => ({
45
+ extractImageMeta: vi.fn(async () => ({ width: null, height: null, format: null })),
46
+ generateImageVariants: vi.fn(async () => []),
47
+ isBypassMimeType: vi.fn(() => false),
48
+ }))
49
+
50
+ vi.mock('../../integrations/byline-core.js', () => ({
51
+ bylineCore: () => ({ getCollectionRecord: mocks.getCollectionRecord }),
52
+ }))
53
+
54
+ import { ensureCollection, ensureDocumentResource } from '../../integrations/api-utils.js'
55
+ import { uploadField } from './upload.js'
56
+
57
+ const SERVER_CONFIG = Symbol.for('__byline_server_config__')
58
+ const BYLINE_LOGGER = Symbol.for('__byline_logger__')
59
+ const previousServer = (globalThis as Record<PropertyKey, unknown>)[SERVER_CONFIG]
60
+ const previousLogger = (globalThis as Record<PropertyKey, unknown>)[BYLINE_LOGGER]
61
+
62
+ const singleton = defineSingleton({
63
+ path: 'site-settings',
64
+ label: 'Site settings',
65
+ fields: [
66
+ {
67
+ name: 'hero',
68
+ label: 'Hero image',
69
+ type: 'file',
70
+ upload: { mimeTypes: ['text/plain'], requireSavedDocument: true },
71
+ },
72
+ ],
73
+ })
74
+
75
+ const storageUpload = vi.fn(async () => ({
76
+ storageProvider: 'test',
77
+ storagePath: 'site-settings/hero.txt',
78
+ storageUrl: '/uploads/site-settings/hero.txt',
79
+ }))
80
+
81
+ const storage: IStorageProvider = {
82
+ providerName: 'test',
83
+ upload: storageUpload,
84
+ delete: vi.fn(async () => {}),
85
+ getUrl: (path) => `/uploads/${path}`,
86
+ }
87
+
88
+ beforeAll(() => {
89
+ const silent = () => {}
90
+ defineLogger({
91
+ log: silent,
92
+ fatal: silent,
93
+ error: silent,
94
+ warn: silent,
95
+ info: silent,
96
+ debug: silent,
97
+ trace: silent,
98
+ silent,
99
+ })
100
+ mocks.getCollectionRecord.mockReturnValue({
101
+ collectionId: 'collection-settings',
102
+ version: 1,
103
+ schemaHash: 'singleton-hash',
104
+ })
105
+ mocks.getAdminRequestContext.mockResolvedValue(
106
+ createRequestContext({
107
+ actor: new AdminAuth({
108
+ id: 'settings-editor',
109
+ abilities: ['singletons.site-settings.update'],
110
+ }),
111
+ })
112
+ )
113
+ defineServerConfig({
114
+ collections: [singleton],
115
+ db: {} as IDbAdapter,
116
+ storage,
117
+ i18n: {
118
+ admin: { defaultLocale: 'en', locales: ['en'] },
119
+ content: { defaultLocale: 'en', locales: ['en'] },
120
+ },
121
+ })
122
+ })
123
+
124
+ afterAll(() => {
125
+ const globals = globalThis as Record<PropertyKey, unknown>
126
+ if (previousServer === undefined) delete globals[SERVER_CONFIG]
127
+ else globals[SERVER_CONFIG] = previousServer
128
+ if (previousLogger === undefined) delete globals[BYLINE_LOGGER]
129
+ else globals[BYLINE_LOGGER] = previousLogger
130
+ })
131
+
132
+ describe('singleton field upload host transport', () => {
133
+ it('keeps collection routes closed while resolving the shared upload resource', async () => {
134
+ await expect(ensureCollection('site-settings')).resolves.toBeNull()
135
+ await expect(ensureDocumentResource('site-settings')).resolves.toMatchObject({
136
+ definition: { singleton: true, path: 'site-settings' },
137
+ collection: { id: 'collection-settings', version: 1 },
138
+ })
139
+ })
140
+
141
+ it('stores a singleton field without taking the collection create branch', async () => {
142
+ const formData = new FormData()
143
+ formData.set('field', 'hero')
144
+ formData.set('file', new File(['hero'], 'Hero.txt', { type: 'text/plain' }))
145
+
146
+ const result = await uploadField('site-settings', formData, false)
147
+
148
+ expect(mocks.getCollectionRecord).toHaveBeenCalledWith('site-settings')
149
+ expect(storageUpload).toHaveBeenCalledWith(
150
+ Buffer.from('hero'),
151
+ expect.objectContaining({
152
+ collection: 'site-settings',
153
+ filename: 'hero.txt',
154
+ mimeType: 'text/plain',
155
+ })
156
+ )
157
+ expect(result.documentId).toBeUndefined()
158
+ expect(result.storedFile).toMatchObject({
159
+ filename: 'hero.txt',
160
+ storageProvider: 'test',
161
+ storagePath: 'site-settings/hero.txt',
162
+ })
163
+ })
164
+ })
@@ -13,7 +13,7 @@ import { extractImageMeta, generateImageVariants, isBypassMimeType } from '@byli
13
13
  import { getLogger, withLogContext } from '@byline/core/logger'
14
14
  import { uploadField as coreUploadField } from '@byline/core/services'
15
15
 
16
- import { ensureCollection } from '../../integrations/api-utils.js'
16
+ import { ensureDocumentResource } from '../../integrations/api-utils.js'
17
17
 
18
18
  /**
19
19
  * Result of an upload through the host transport. The legacy top-level
@@ -156,11 +156,15 @@ export const uploadCollectionField = createServerFn({ method: 'POST' })
156
156
  return withLogContext(
157
157
  { domain: 'api', module: 'upload', function: 'uploadCollectionField' },
158
158
  async () => {
159
- const config = await ensureCollection(collectionPath)
159
+ // Upload is the one field transport shared by both document-resource
160
+ // kinds. The core service selects the kind-aware ability and rejects
161
+ // singleton document creation before hooks or storage; the admin form
162
+ // always requests the field-only branch (`createDocument=false`).
163
+ const config = await ensureDocumentResource(collectionPath)
160
164
  if (config == null) {
161
165
  throw ERR_NOT_FOUND(
162
166
  {
163
- message: 'Collection not found.',
167
+ message: 'Document resource not found.',
164
168
  details: { collectionPath },
165
169
  },
166
170
  uploadCollectionField
@@ -6,12 +6,4 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- /**
10
- * Deep-serialise a value through JSON to convert Date objects to ISO strings.
11
- * TanStack Start server functions pass return values directly during SSR
12
- * (no automatic JSON round-trip), so Date instances from the DB driver would
13
- * fail downstream Zod z.string() checks without this explicit step.
14
- */
15
- export function serialise<T>(value: T): T {
16
- return JSON.parse(JSON.stringify(value))
17
- }
9
+ export { serialise } from '../serialise.js'
@@ -0,0 +1,17 @@
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
+ /**
10
+ * Deep-serialise a value through JSON to convert Date objects to ISO strings.
11
+ * TanStack Start server functions pass return values directly during SSR
12
+ * (no automatic JSON round-trip), so Date instances from the DB driver would
13
+ * fail downstream string checks without this explicit step.
14
+ */
15
+ export function serialise<T>(value: T): T {
16
+ return JSON.parse(JSON.stringify(value))
17
+ }
@@ -0,0 +1,32 @@
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 { getAdminBylineClient } from '@byline/client/server'
10
+ import { getCollectionDefinition } from '@byline/core'
11
+
12
+ import { resolveAdminDocumentRead } from './admin-document-presentation.js'
13
+ import { serialise } from './serialise.js'
14
+
15
+ export interface SingletonDocumentReadInput {
16
+ singleton: string
17
+ locale?: string
18
+ depth?: number
19
+ populateRelations?: boolean
20
+ }
21
+
22
+ /**
23
+ * Read the singleton document with the presentation controls shared by the
24
+ * editor route and the public server function. Route-only lifecycle metadata
25
+ * is deliberately composed one layer up, where host database and actor
26
+ * context are available without widening the singleton transport contract.
27
+ */
28
+ export async function readSingletonDocument(data: SingletonDocumentReadInput) {
29
+ const handle = getAdminBylineClient().singleton(data.singleton)
30
+ const { options } = resolveAdminDocumentRead(getCollectionDefinition(data.singleton), data)
31
+ return serialise(await handle.get(options))
32
+ }
@@ -0,0 +1,19 @@
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 { createServerFn } from '@tanstack/react-start'
10
+
11
+ import { getAdminBylineClient } from '@byline/client/server'
12
+
13
+ import { serialise } from '../serialise.js'
14
+
15
+ export const cancelSingletonScheduledPublish = createServerFn({ method: 'POST' })
16
+ .validator((input: { singleton: string }) => input)
17
+ .handler(async ({ data }) =>
18
+ serialise(await getAdminBylineClient().singleton(data.singleton).cancelScheduledPublish())
19
+ )
@@ -0,0 +1,19 @@
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 { createServerFn } from '@tanstack/react-start'
10
+
11
+ import { getAdminBylineClient } from '@byline/client/server'
12
+
13
+ import { serialise } from '../serialise.js'
14
+
15
+ export const changeSingletonStatus = createServerFn({ method: 'POST' })
16
+ .validator((input: { singleton: string; status: string }) => input)
17
+ .handler(async ({ data }) =>
18
+ serialise(await getAdminBylineClient().singleton(data.singleton).changeStatus(data.status))
19
+ )
@@ -0,0 +1,23 @@
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 { createServerFn } from '@tanstack/react-start'
10
+
11
+ import { getAdminBylineClient } from '@byline/client/server'
12
+
13
+ import { serialise } from '../serialise.js'
14
+
15
+ export const confirmSingletonScheduledPublish = createServerFn({ method: 'POST' })
16
+ .validator((input: { singleton: string; expectedVersionId: string }) => input)
17
+ .handler(async ({ data }) =>
18
+ serialise(
19
+ await getAdminBylineClient()
20
+ .singleton(data.singleton)
21
+ .confirmScheduledPublish({ expectedVersionId: data.expectedVersionId })
22
+ )
23
+ )
@@ -0,0 +1,32 @@
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 { createServerFn } from '@tanstack/react-start'
10
+
11
+ import { getAdminBylineClient } from '@byline/client/server'
12
+
13
+ import { serialise } from '../serialise.js'
14
+
15
+ export const copySingletonToLocale = createServerFn({ method: 'POST' })
16
+ .validator(
17
+ (input: {
18
+ singleton: string
19
+ sourceLocale: string
20
+ targetLocale: string
21
+ overwrite?: boolean
22
+ }) => input
23
+ )
24
+ .handler(async ({ data }) =>
25
+ serialise(
26
+ await getAdminBylineClient().singleton(data.singleton).copyToLocale({
27
+ sourceLocale: data.sourceLocale,
28
+ targetLocale: data.targetLocale,
29
+ overwrite: data.overwrite,
30
+ })
31
+ )
32
+ )