@byline/host-tanstack-start 3.21.0 → 4.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 (134) hide show
  1. package/dist/admin-shell/admin-activity/list.js +15 -5
  2. package/dist/admin-shell/admin-roles/delete.js +2 -1
  3. package/dist/admin-shell/admin-roles/list.js +2 -1
  4. package/dist/admin-shell/admin-users/delete.js +2 -1
  5. package/dist/admin-shell/admin-users/list.js +4 -7
  6. package/dist/admin-shell/chrome/admin-app-bar.js +2 -1
  7. package/dist/admin-shell/chrome/admin-menu-drawer.js +9 -8
  8. package/dist/admin-shell/chrome/admin-menu-provider.js +2 -1
  9. package/dist/admin-shell/chrome/dashboard.js +4 -3
  10. package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -2
  11. package/dist/admin-shell/chrome/sign-in-page.js +2 -2
  12. package/dist/admin-shell/collections/create.js +4 -3
  13. package/dist/admin-shell/collections/document-history.js +6 -1
  14. package/dist/admin-shell/collections/edit.js +18 -14
  15. package/dist/admin-shell/collections/history.js +5 -4
  16. package/dist/admin-shell/collections/list.js +8 -7
  17. package/dist/admin-shell/collections/restore-version-modal.js +2 -1
  18. package/dist/admin-shell/collections/tree-list.js +53 -17
  19. package/dist/admin-shell/collections/view-menu.js +9 -8
  20. package/dist/auth/auth-context.d.ts +13 -0
  21. package/dist/auth/auth-context.js +4 -0
  22. package/dist/auth/request-scope.d.ts +16 -0
  23. package/dist/auth/request-scope.js +24 -0
  24. package/dist/integrations/abilities.js +8 -7
  25. package/dist/integrations/byline-public-client.d.ts +38 -0
  26. package/dist/integrations/byline-public-client.js +8 -4
  27. package/dist/integrations/byline-viewer-client.d.ts +6 -0
  28. package/dist/integrations/byline-viewer-client.js +22 -17
  29. package/dist/integrations/start-errors.js +2 -1
  30. package/dist/routes/admin-path.d.ts +22 -0
  31. package/dist/routes/admin-path.js +35 -0
  32. package/dist/routes/create-admin-account-route.js +3 -2
  33. package/dist/routes/create-admin-activity-route.js +3 -2
  34. package/dist/routes/create-admin-dashboard-route.js +2 -1
  35. package/dist/routes/create-admin-layout-route.d.ts +1 -1
  36. package/dist/routes/create-admin-layout-route.js +3 -1
  37. package/dist/routes/create-admin-permissions-route.js +3 -2
  38. package/dist/routes/create-admin-role-edit-route.js +4 -3
  39. package/dist/routes/create-admin-roles-list-route.js +3 -2
  40. package/dist/routes/create-admin-user-edit-route.js +4 -3
  41. package/dist/routes/create-admin-users-list-route.js +3 -2
  42. package/dist/routes/create-collection-api-route.js +5 -4
  43. package/dist/routes/create-collection-create-route.js +4 -3
  44. package/dist/routes/create-collection-edit-route.js +4 -3
  45. package/dist/routes/create-collection-history-route.js +5 -4
  46. package/dist/routes/create-collection-list-route.js +3 -2
  47. package/dist/routes/create-sign-in-route.js +3 -2
  48. package/dist/routes/index.d.ts +3 -0
  49. package/dist/routes/index.js +2 -0
  50. package/dist/routes/sign-in-path.d.ts +13 -0
  51. package/dist/routes/sign-in-path.js +19 -0
  52. package/dist/server-fns/collections/delete-outcome.d.ts +30 -0
  53. package/dist/server-fns/collections/delete-outcome.js +34 -0
  54. package/dist/server-fns/collections/delete.d.ts +1 -4
  55. package/dist/server-fns/collections/delete.js +2 -4
  56. package/dist/server-fns/collections/index.d.ts +1 -0
  57. package/dist/server-fns/collections/index.js +1 -0
  58. package/dist/server-fns/collections/tree-mutation.d.ts +46 -0
  59. package/dist/server-fns/collections/tree-mutation.js +109 -0
  60. package/dist/server-fns/collections/tree-read.d.ts +6 -0
  61. package/dist/server-fns/collections/tree-read.js +15 -0
  62. package/dist/server-fns/collections/tree.d.ts +5 -4
  63. package/dist/server-fns/collections/tree.js +15 -12
  64. package/dist/server-fns/collections/update.js +2 -1
  65. package/dist/vite/client-hook-boundary.d.ts +19 -0
  66. package/dist/vite/client-hook-boundary.js +20 -0
  67. package/dist/vite/index.d.ts +8 -0
  68. package/dist/vite/index.js +1 -0
  69. package/package.json +15 -9
  70. package/src/admin-shell/admin-activity/list.tsx +13 -3
  71. package/src/admin-shell/admin-roles/delete.tsx +2 -1
  72. package/src/admin-shell/admin-roles/list.tsx +2 -1
  73. package/src/admin-shell/admin-users/delete.tsx +3 -2
  74. package/src/admin-shell/admin-users/list.tsx +9 -8
  75. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -1
  76. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -8
  77. package/src/admin-shell/chrome/admin-menu-provider.tsx +2 -1
  78. package/src/admin-shell/chrome/dashboard.tsx +4 -3
  79. package/src/admin-shell/chrome/sign-in-page.tsx +3 -3
  80. package/src/admin-shell/collections/create.tsx +4 -3
  81. package/src/admin-shell/collections/document-history.tsx +5 -0
  82. package/src/admin-shell/collections/edit.tsx +20 -13
  83. package/src/admin-shell/collections/history.tsx +6 -5
  84. package/src/admin-shell/collections/list.tsx +8 -7
  85. package/src/admin-shell/collections/restore-version-modal.tsx +2 -1
  86. package/src/admin-shell/collections/tree-list.tsx +78 -22
  87. package/src/admin-shell/collections/view-menu.tsx +9 -8
  88. package/src/auth/auth-context.test.node.ts +87 -0
  89. package/src/auth/auth-context.ts +18 -0
  90. package/src/auth/request-scope.test.node.ts +93 -0
  91. package/src/auth/request-scope.ts +62 -0
  92. package/src/integrations/abilities.tsx +14 -4
  93. package/src/integrations/byline-client.ts +6 -4
  94. package/src/integrations/byline-public-client.ts +18 -4
  95. package/src/integrations/byline-viewer-client.ts +30 -18
  96. package/src/integrations/request-context-contract.test.node.ts +131 -0
  97. package/src/integrations/request-context-factories.test.node.ts +140 -0
  98. package/src/integrations/start-errors.test.node.ts +22 -0
  99. package/src/integrations/start-errors.ts +1 -0
  100. package/src/routes/admin-path.test.node.ts +129 -0
  101. package/src/routes/admin-path.ts +66 -0
  102. package/src/routes/auth-routes.test.node.ts +39 -0
  103. package/src/routes/create-admin-account-route.tsx +3 -2
  104. package/src/routes/create-admin-activity-route.tsx +3 -2
  105. package/src/routes/create-admin-dashboard-route.tsx +2 -1
  106. package/src/routes/create-admin-layout-route.tsx +4 -4
  107. package/src/routes/create-admin-permissions-route.tsx +3 -2
  108. package/src/routes/create-admin-role-edit-route.tsx +7 -3
  109. package/src/routes/create-admin-roles-list-route.tsx +3 -2
  110. package/src/routes/create-admin-user-edit-route.tsx +7 -3
  111. package/src/routes/create-admin-users-list-route.tsx +3 -2
  112. package/src/routes/create-collection-api-route.tsx +8 -4
  113. package/src/routes/create-collection-create-route.tsx +7 -3
  114. package/src/routes/create-collection-edit-route.tsx +7 -3
  115. package/src/routes/create-collection-history-route.tsx +8 -4
  116. package/src/routes/create-collection-list-route.tsx +3 -2
  117. package/src/routes/create-sign-in-route.tsx +9 -3
  118. package/src/routes/index.ts +11 -0
  119. package/src/routes/sign-in-path.test.node.ts +79 -0
  120. package/src/routes/sign-in-path.ts +36 -0
  121. package/src/server-fns/collections/delete-outcome.test.node.ts +72 -0
  122. package/src/server-fns/collections/delete-outcome.ts +80 -0
  123. package/src/server-fns/collections/delete.ts +2 -1
  124. package/src/server-fns/collections/get.ts +2 -0
  125. package/src/server-fns/collections/index.ts +1 -0
  126. package/src/server-fns/collections/tree-mutation.test.node.ts +225 -0
  127. package/src/server-fns/collections/tree-mutation.ts +165 -0
  128. package/src/server-fns/collections/tree-read.test.node.ts +46 -0
  129. package/src/server-fns/collections/tree-read.ts +26 -0
  130. package/src/server-fns/collections/tree.ts +26 -10
  131. package/src/server-fns/collections/update.ts +3 -0
  132. package/src/vite/client-hook-boundary.test.node.ts +50 -0
  133. package/src/vite/client-hook-boundary.ts +45 -0
  134. package/src/vite/index.ts +9 -0
@@ -19,6 +19,7 @@ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs
19
19
  import { useNavigate } from '../admin-shell/chrome/loose-router.js'
20
20
  import { EditView } from '../admin-shell/collections/edit.js'
21
21
  import { getCollectionDocument } from '../server-fns/collections/index.js'
22
+ import { getAdminRoutePath } from './admin-path.js'
22
23
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
23
24
 
24
25
  const searchSchema = z.object({
@@ -116,11 +117,14 @@ export function createCollectionEditRoute(path: string) {
116
117
  <>
117
118
  <BreadcrumbsClient
118
119
  breadcrumbs={[
119
- { label: t('chrome.menu.dashboard'), href: `/admin` },
120
- { label: collectionDef.labels.plural, href: `/admin/collections/${collection}` },
120
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
121
+ {
122
+ label: collectionDef.labels.plural,
123
+ href: getAdminRoutePath('collections', collection),
124
+ },
121
125
  {
122
126
  label: t('common.actions.edit'),
123
- href: `/admin/collections/${collection}/${id}`,
127
+ href: getAdminRoutePath('collections', collection, id),
124
128
  },
125
129
  ]}
126
130
  />
@@ -24,6 +24,7 @@ import {
24
24
  getCollectionDocumentAuditLog,
25
25
  getCollectionDocumentHistory,
26
26
  } from '../server-fns/collections/index.js'
27
+ import { getAdminRoutePath } from './admin-path.js'
27
28
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
28
29
 
29
30
  const searchSchema = z.object({
@@ -119,15 +120,18 @@ export function createCollectionHistoryRoute(path: string) {
119
120
  <>
120
121
  <BreadcrumbsClient
121
122
  breadcrumbs={[
122
- { label: t('chrome.menu.dashboard'), href: `/admin` },
123
- { label: collectionDef.labels.plural, href: `/admin/collections/${collection}` },
123
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
124
+ {
125
+ label: collectionDef.labels.plural,
126
+ href: getAdminRoutePath('collections', collection),
127
+ },
124
128
  {
125
129
  label: t('common.actions.edit'),
126
- href: `/admin/collections/${collection}/${Route.useParams().id}`,
130
+ href: getAdminRoutePath('collections', collection, Route.useParams().id),
127
131
  },
128
132
  {
129
133
  label: t('collections.breadcrumbs.history'),
130
- href: `/admin/collections/${collection}/${Route.useParams().id}/history`,
134
+ href: getAdminRoutePath('collections', collection, Route.useParams().id, 'history'),
131
135
  },
132
136
  ]}
133
137
  />
@@ -30,6 +30,7 @@ import {
30
30
  placeTreeNode,
31
31
  reorderCollectionDocument,
32
32
  } from '../server-fns/collections/index.js'
33
+ import { getAdminRoutePath } from './admin-path.js'
33
34
  import type { CollectionTreeRow } from '../server-fns/collections/tree.js'
34
35
 
35
36
  const searchSchema = z.object({
@@ -167,10 +168,10 @@ export function createCollectionListRoute(path: string) {
167
168
  <>
168
169
  <BreadcrumbsClient
169
170
  breadcrumbs={[
170
- { label: t('chrome.menu.dashboard'), href: `/admin` },
171
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
171
172
  {
172
173
  label: data.included.collection.labels.plural,
173
- href: `/admin/collections/${collection}`,
174
+ href: getAdminRoutePath('collections', collection),
174
175
  },
175
176
  ]}
176
177
  />
@@ -9,7 +9,7 @@
9
9
  /**
10
10
  * Sign-in page route factory.
11
11
  *
12
- * Lives outside the `/admin` subtree so the authenticated-admin layout
12
+ * Lives outside the configured admin subtree so the authenticated-admin layout
13
13
  * (and its `beforeLoad` guard) does not apply. Preserves a `callbackUrl`
14
14
  * query param so sign-ins triggered by the admin guard redirect back to
15
15
  * the originally-requested path.
@@ -21,6 +21,7 @@ import type { LocaleCode } from '@byline/i18n'
21
21
 
22
22
  import { SignInPage } from '../admin-shell/chrome/sign-in-page.js'
23
23
  import { getActiveLocaleFn } from '../server-fns/i18n/index.js'
24
+ import { resolveAdminCallbackPath, resolveAdminSignInRedirect } from './admin-path.js'
24
25
 
25
26
  interface SignInSearch {
26
27
  callbackUrl?: string
@@ -30,7 +31,7 @@ export function createSignInRoute(path: string) {
30
31
  // biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
31
32
  const Route: any = createFileRoute(path as never)({
32
33
  validateSearch: (search: Record<string, unknown>): SignInSearch => {
33
- const callbackUrl = typeof search.callbackUrl === 'string' ? search.callbackUrl : undefined
34
+ const callbackUrl = resolveAdminCallbackPath(search.callbackUrl)
34
35
  return { callbackUrl }
35
36
  },
36
37
  beforeLoad: async () => {
@@ -45,7 +46,12 @@ export function createSignInRoute(path: string) {
45
46
  component: function SignInRouteComponent() {
46
47
  const { callbackUrl } = Route.useSearch() as SignInSearch
47
48
  const { activeLocale } = Route.useRouteContext() as { activeLocale: LocaleCode }
48
- return <SignInPage callbackUrl={callbackUrl} activeLocale={activeLocale} />
49
+ return (
50
+ <SignInPage
51
+ redirectTo={resolveAdminSignInRedirect(callbackUrl)}
52
+ activeLocale={activeLocale}
53
+ />
54
+ )
49
55
  },
50
56
  })
51
57
  return Route
@@ -6,6 +6,16 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ export {
10
+ getAdminRouteId,
11
+ getAdminRoutePath,
12
+ isAdminRoutePathActive,
13
+ isRoutePathWithin,
14
+ resolveAdminCallbackPath,
15
+ resolveAdminSignInRedirect,
16
+ } from './admin-path.js'
17
+ export { configureSignInRoutePath, getSignInRoutePath } from './sign-in-path.js'
18
+
9
19
  /**
10
20
  * Route factories for the Byline admin UI on TanStack Start.
11
21
  *
@@ -14,6 +24,7 @@
14
24
  * `createFileRoute(path)({...})`. The host's route file collapses to:
15
25
  *
16
26
  * import { createCollectionListRoute } from '@byline/host-tanstack-start/routes'
27
+ * // Default admin mount example:
17
28
  * export const Route = createCollectionListRoute('/_byline/admin/collections/$collection/')
18
29
  *
19
30
  * Routes that need host-specific data (i18n bridge component, content
@@ -0,0 +1,79 @@
1
+ import { defineClientConfig } from '@byline/core'
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+
4
+ import {
5
+ configureSignInRoutePath,
6
+ createSignInRoutePathResolver,
7
+ getSignInRoutePath,
8
+ } from './sign-in-path.js'
9
+
10
+ describe('sign-in route configuration', () => {
11
+ beforeEach(() => {
12
+ defineClientConfig({
13
+ serverURL: 'http://localhost:3000',
14
+ routes: { signIn: '/staff/login/' },
15
+ collections: [],
16
+ admin: [],
17
+ i18n: {
18
+ interface: { defaultLocale: 'en', locales: [] },
19
+ content: { defaultLocale: 'en', locales: [] },
20
+ },
21
+ })
22
+ })
23
+
24
+ it('reads and normalizes routes.signIn', () => {
25
+ expect(getSignInRoutePath()).toBe('/staff/login')
26
+ })
27
+
28
+ it('allows a deprecated signInPath override when it matches canonical config', () => {
29
+ expect(configureSignInRoutePath('staff/login/')).toBe('/staff/login')
30
+ })
31
+
32
+ it('rejects an invalid or divergent deprecated override when resolved', () => {
33
+ expect(() => configureSignInRoutePath('/auth/login/')).toThrow(/must match routes\.signIn/)
34
+ expect(() => configureSignInRoutePath('/auth/../login')).toThrow(/routes\.signIn/)
35
+ })
36
+
37
+ it('validates an override against configured rather than default route trees', () => {
38
+ defineClientConfig({
39
+ serverURL: 'http://localhost:3000',
40
+ routes: { admin: '/cms', api: '/rpc', signIn: '/admin/login' },
41
+ collections: [],
42
+ admin: [],
43
+ i18n: {
44
+ interface: { defaultLocale: 'en', locales: [] },
45
+ content: { defaultLocale: 'en', locales: [] },
46
+ },
47
+ })
48
+
49
+ expect(configureSignInRoutePath('/admin/login/')).toBe('/admin/login')
50
+ expect(() => configureSignInRoutePath('/cms/login')).toThrow(/routes\.signIn/)
51
+ })
52
+
53
+ it('defers a deprecated override until parent beforeLoad config registration', () => {
54
+ defineClientConfig({
55
+ serverURL: 'http://localhost:3000',
56
+ routes: { signIn: '/old/login' },
57
+ collections: [],
58
+ admin: [],
59
+ i18n: {
60
+ interface: { defaultLocale: 'en', locales: [] },
61
+ content: { defaultLocale: 'en', locales: [] },
62
+ },
63
+ })
64
+ const resolveSignInPath = createSignInRoutePathResolver('/staff/login/')
65
+
66
+ defineClientConfig({
67
+ serverURL: 'http://localhost:3000',
68
+ routes: { signIn: '/staff/login' },
69
+ collections: [],
70
+ admin: [],
71
+ i18n: {
72
+ interface: { defaultLocale: 'en', locales: [] },
73
+ content: { defaultLocale: 'en', locales: [] },
74
+ },
75
+ })
76
+
77
+ expect(resolveSignInPath()).toBe('/staff/login')
78
+ })
79
+ })
@@ -0,0 +1,36 @@
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 { getClientConfig, resolveRoutes } from '@byline/core'
10
+
11
+ /** Read the canonical client-safe sign-in destination. */
12
+ export function getSignInRoutePath(): string {
13
+ return getClientConfig().routes.signIn
14
+ }
15
+
16
+ /** @deprecated Configure `routes.signIn`; retained for route-factory input validation. */
17
+ export function configureSignInRoutePath(legacyOverride?: string): string {
18
+ const routes = getClientConfig().routes
19
+ const configured = routes.signIn
20
+ if (legacyOverride === undefined) return configured
21
+
22
+ const normalizedOverride = resolveRoutes({ ...routes, signIn: legacyOverride }).signIn
23
+ if (normalizedOverride !== configured) {
24
+ throw new Error(
25
+ `createAdminLayoutRoute signInPath (${normalizedOverride}) must match routes.signIn (${configured})`
26
+ )
27
+ }
28
+ return configured
29
+ }
30
+
31
+ /** Defer deprecated override validation until the admin route's beforeLoad boundary. */
32
+ export function createSignInRoutePathResolver(legacyOverride?: string): () => string {
33
+ return legacyOverride === undefined
34
+ ? getSignInRoutePath
35
+ : () => configureSignInRoutePath(legacyOverride)
36
+ }
@@ -0,0 +1,72 @@
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 { DeleteDocumentResult, DeleteDocumentSideEffectPhase } from '@byline/core'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import { hasDeleteSideEffectFailures, toDeleteDocumentResponse } from './delete-outcome.js'
13
+
14
+ describe('delete outcome transport and admin interpretation', () => {
15
+ it('returns only allowlisted phase/code data for a committed warning', () => {
16
+ // Simulate malformed runtime data from an older or compromised source. The
17
+ // core type rejects this shape, but the transport still sanitizes it.
18
+ const unsafeResult = {
19
+ deletedVersionCount: 2,
20
+ outcome: 'committed-with-side-effect-failures',
21
+ sideEffectFailures: [
22
+ { phase: 'afterDelete', message: 'secret hook details', code: 'ERR_SEARCH' },
23
+ { phase: 'storageCleanup', message: 'private/path.pdf', code: 'ERR_STORAGE' },
24
+ {
25
+ phase: 'internalOnly' as DeleteDocumentSideEffectPhase,
26
+ message: 'internal phase details',
27
+ code: 'ERR_INTERNAL',
28
+ },
29
+ ],
30
+ } as unknown as DeleteDocumentResult
31
+ const response = toDeleteDocumentResponse(unsafeResult)
32
+
33
+ expect(response).toEqual({
34
+ status: 'ok',
35
+ deletedVersionCount: 2,
36
+ outcome: 'committed-with-side-effect-failures',
37
+ sideEffectFailures: [
38
+ { phase: 'afterDelete', code: 'ERR_UNHANDLED' },
39
+ { phase: 'storageCleanup', code: 'ERR_STORAGE' },
40
+ { phase: 'unknown', code: 'ERR_UNHANDLED' },
41
+ ],
42
+ })
43
+ expect(JSON.parse(JSON.stringify(response))).toEqual(response)
44
+ expect(JSON.stringify(response)).not.toContain('secret hook details')
45
+ expect(JSON.stringify(response)).not.toContain('private/path.pdf')
46
+ expect(JSON.stringify(response)).not.toContain('internal phase details')
47
+ expect(hasDeleteSideEffectFailures(response)).toBe(true)
48
+ })
49
+
50
+ it('preserves the committed success discriminant and empty failures', () => {
51
+ const response = toDeleteDocumentResponse({
52
+ deletedVersionCount: 2,
53
+ outcome: 'committed',
54
+ sideEffectFailures: [],
55
+ })
56
+
57
+ expect(response).toEqual({
58
+ status: 'ok',
59
+ deletedVersionCount: 2,
60
+ outcome: 'committed',
61
+ sideEffectFailures: [],
62
+ })
63
+ expect(hasDeleteSideEffectFailures(response)).toBe(false)
64
+ })
65
+
66
+ it('classifies only committed side-effect failures as an admin warning', () => {
67
+ expect(hasDeleteSideEffectFailures({ outcome: 'committed' })).toBe(false)
68
+ expect(hasDeleteSideEffectFailures({ outcome: 'committed-with-side-effect-failures' })).toBe(
69
+ true
70
+ )
71
+ })
72
+ })
@@ -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 type {
10
+ DeleteDocumentResult,
11
+ DeleteDocumentSideEffectCode,
12
+ DeleteDocumentSideEffectPhase,
13
+ } from '@byline/core'
14
+ import { ErrorCodes } from '@byline/core'
15
+
16
+ export type DeleteDocumentPublicSideEffectPhase = DeleteDocumentSideEffectPhase | 'unknown'
17
+ export type DeleteDocumentPublicSideEffectCode = DeleteDocumentSideEffectCode
18
+
19
+ export interface DeleteDocumentPublicSideEffectFailure {
20
+ phase: DeleteDocumentPublicSideEffectPhase
21
+ code: DeleteDocumentPublicSideEffectCode
22
+ }
23
+
24
+ export type DeleteDocumentResponse =
25
+ | {
26
+ status: 'ok'
27
+ deletedVersionCount: number
28
+ outcome: 'committed'
29
+ sideEffectFailures: []
30
+ }
31
+ | {
32
+ status: 'ok'
33
+ deletedVersionCount: number
34
+ outcome: 'committed-with-side-effect-failures'
35
+ sideEffectFailures: [
36
+ DeleteDocumentPublicSideEffectFailure,
37
+ ...DeleteDocumentPublicSideEffectFailure[],
38
+ ]
39
+ }
40
+
41
+ function sanitizePhase(phase: DeleteDocumentSideEffectPhase): DeleteDocumentPublicSideEffectPhase {
42
+ if (phase === 'storageCleanup' || phase === 'afterTreeChange' || phase === 'afterDelete') {
43
+ return phase
44
+ }
45
+ return 'unknown'
46
+ }
47
+
48
+ function sanitizeCode(code: string): DeleteDocumentPublicSideEffectCode {
49
+ return code === ErrorCodes.STORAGE ? ErrorCodes.STORAGE : ErrorCodes.UNHANDLED
50
+ }
51
+
52
+ function sanitizeFailure({
53
+ phase,
54
+ code,
55
+ }: {
56
+ phase: DeleteDocumentSideEffectPhase
57
+ code: string
58
+ }): DeleteDocumentPublicSideEffectFailure {
59
+ return { phase: sanitizePhase(phase), code: sanitizeCode(code) }
60
+ }
61
+
62
+ export function toDeleteDocumentResponse(result: DeleteDocumentResult): DeleteDocumentResponse {
63
+ if (result.outcome === 'committed') {
64
+ return { status: 'ok', ...result }
65
+ }
66
+
67
+ const [firstFailure, ...remainingFailures] = result.sideEffectFailures
68
+ return {
69
+ status: 'ok',
70
+ deletedVersionCount: result.deletedVersionCount,
71
+ outcome: result.outcome,
72
+ sideEffectFailures: [sanitizeFailure(firstFailure), ...remainingFailures.map(sanitizeFailure)],
73
+ }
74
+ }
75
+
76
+ export function hasDeleteSideEffectFailures(
77
+ result: Pick<DeleteDocumentResult, 'outcome'>
78
+ ): boolean {
79
+ return result.outcome === 'committed-with-side-effect-failures'
80
+ }
@@ -14,6 +14,7 @@ import { deleteDocument as deleteDocumentService } from '@byline/core/services'
14
14
 
15
15
  import { getAdminRequestContext } from '../../auth/auth-context.js'
16
16
  import { ensureCollection } from '../../integrations/api-utils.js'
17
+ import { toDeleteDocumentResponse } from './delete-outcome.js'
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Delete document (soft-delete — marks all versions as deleted)
@@ -58,5 +59,5 @@ export const deleteDocument = createServerFn({ method: 'POST' })
58
59
  }
59
60
 
60
61
  const result = await deleteDocumentService(ctx, { documentId: id })
61
- return { status: 'ok' as const, deletedVersionCount: result.deletedVersionCount }
62
+ return toDeleteDocumentResponse(result)
62
63
  })
@@ -197,6 +197,8 @@ const getDocumentByVersionFn = createServerFn({ method: 'GET' })
197
197
  }).log(logger)
198
198
  }
199
199
 
200
+ // The client binds the historical row to `path` and applies its beforeRead
201
+ // predicate; unknown and cross-collection version ids both resolve null.
200
202
  const document = await getAdminBylineClient()
201
203
  .collection(path)
202
204
  .findByVersion(versionId, { locale: resolvedLocale })
@@ -10,6 +10,7 @@ export * from './copy-to-locale'
10
10
  export * from './create'
11
11
  export * from './delete'
12
12
  export * from './delete-locale'
13
+ export * from './delete-outcome'
13
14
  export * from './duplicate'
14
15
  export * from './get'
15
16
  export * from './history'
@@ -0,0 +1,225 @@
1
+ import { TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER } from '@byline/core'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import {
5
+ createAdminTreeMoveController,
6
+ executeAdminTreeMove,
7
+ getAdminTreeMoveUiEffects,
8
+ isCommittedTreeHookFailure,
9
+ isTreeMutationConflict,
10
+ placeAdminTreeNode,
11
+ removeAdminTreeNode,
12
+ shouldSyncAdminTreeRows,
13
+ } from './tree-mutation.js'
14
+
15
+ describe('admin tree mutations', () => {
16
+ it('recognises a coded stale-tree conflict', () => {
17
+ expect(isTreeMutationConflict({ code: 'ERR_CONFLICT' })).toBe(true)
18
+ expect(isTreeMutationConflict(new Error('failed'))).toBe(false)
19
+ })
20
+
21
+ it('recognises a code-less stale-tree conflict by its stable marker', () => {
22
+ expect(
23
+ isTreeMutationConflict(
24
+ new Error(`${TREE_PLACEMENT_STALE_MARKER} completely reworded diagnostic`)
25
+ )
26
+ ).toBe(true)
27
+ expect(isTreeMutationConflict(new Error('tree placement is stale: neighbours moved'))).toBe(
28
+ false
29
+ )
30
+ })
31
+
32
+ it('recognises a coded committed tree-hook rejection', () => {
33
+ expect(isCommittedTreeHookFailure({ code: 'ERR_TREE_HOOK_COMMITTED' })).toBe(true)
34
+ expect(isCommittedTreeHookFailure({ code: 'ERR_CONFLICT' })).toBe(false)
35
+ })
36
+
37
+ it('recognises a code-less committed tree-hook rejection by its stable marker', () => {
38
+ expect(
39
+ isCommittedTreeHookFailure(
40
+ new Error(`${TREE_HOOK_COMMITTED_MARKER} completely reworded diagnostic`)
41
+ )
42
+ ).toBe(true)
43
+ expect(isCommittedTreeHookFailure(new Error('tree mutation committed; hook failed'))).toBe(
44
+ false
45
+ )
46
+ })
47
+
48
+ it('refreshes canonical tree data after a rejected mutation', async () => {
49
+ const mutationError = new Error(`${TREE_PLACEMENT_STALE_MARKER} neighbours moved`)
50
+ const refresh = vi.fn().mockResolvedValue(undefined)
51
+
52
+ await expect(
53
+ executeAdminTreeMove(vi.fn().mockRejectedValue(mutationError), refresh)
54
+ ).resolves.toEqual({ status: 'mutation-failed', error: mutationError })
55
+ expect(refresh).toHaveBeenCalledOnce()
56
+ })
57
+
58
+ it('reports a refresh failure separately after a successful mutation', async () => {
59
+ const refreshError = new Error('router unavailable')
60
+
61
+ await expect(
62
+ executeAdminTreeMove(
63
+ vi.fn().mockResolvedValue(undefined),
64
+ vi.fn().mockRejectedValue(refreshError)
65
+ )
66
+ ).resolves.toEqual({ status: 'refresh-failed', error: refreshError })
67
+ })
68
+
69
+ it('keeps a committed hook rejection separate from mutation failure', async () => {
70
+ const hookError = { code: 'ERR_TREE_HOOK_COMMITTED', message: 'hook failed' }
71
+ const refresh = vi.fn().mockResolvedValue(undefined)
72
+
73
+ const outcome = await executeAdminTreeMove(vi.fn().mockRejectedValue(hookError), refresh)
74
+
75
+ expect(outcome).toEqual({ status: 'committed-hook-failed', error: hookError })
76
+ expect(getAdminTreeMoveUiEffects(outcome)).toEqual({
77
+ rollback: false,
78
+ preserveOptimistic: false,
79
+ structuralFailure: null,
80
+ committedHookWarning: true,
81
+ refreshWarning: false,
82
+ })
83
+ expect(refresh).toHaveBeenCalledOnce()
84
+ })
85
+
86
+ it('preserves optimistic UI when committed-hook refresh also fails', async () => {
87
+ const hookError = { code: 'ERR_TREE_HOOK_COMMITTED', message: 'hook failed' }
88
+ const refreshError = new Error('refresh failed')
89
+ const outcome = await executeAdminTreeMove(
90
+ vi.fn().mockRejectedValue(hookError),
91
+ vi.fn().mockRejectedValue(refreshError)
92
+ )
93
+
94
+ expect(outcome).toEqual({
95
+ status: 'committed-hook-failed',
96
+ error: hookError,
97
+ refreshError,
98
+ })
99
+ expect(getAdminTreeMoveUiEffects(outcome)).toEqual({
100
+ rollback: false,
101
+ preserveOptimistic: true,
102
+ structuralFailure: null,
103
+ committedHookWarning: true,
104
+ refreshWarning: true,
105
+ })
106
+ })
107
+
108
+ it('keeps ordinary mutation failure rollback policy', () => {
109
+ expect(
110
+ getAdminTreeMoveUiEffects({
111
+ status: 'mutation-failed',
112
+ error: { code: 'ERR_CONFLICT' },
113
+ })
114
+ ).toEqual({
115
+ rollback: true,
116
+ preserveOptimistic: false,
117
+ structuralFailure: 'conflict',
118
+ committedHookWarning: false,
119
+ refreshWarning: false,
120
+ })
121
+ })
122
+
123
+ it('preserves optimistic rows after refresh failure until canonical rows change', () => {
124
+ const staleRows = [{ id: 'old' }]
125
+ expect(shouldSyncAdminTreeRows(staleRows, staleRows, false)).toBe(false)
126
+ expect(shouldSyncAdminTreeRows([{ id: 'canonical' }], staleRows, false)).toBe(true)
127
+ expect(shouldSyncAdminTreeRows([{ id: 'canonical' }], null, true)).toBe(false)
128
+ })
129
+
130
+ it('suppresses a second move while the first move is in flight', async () => {
131
+ const controller = createAdminTreeMoveController()
132
+ let resolveMutation: (() => void) | undefined
133
+ const mutate = vi.fn(
134
+ () =>
135
+ new Promise<void>((resolve) => {
136
+ resolveMutation = resolve
137
+ })
138
+ )
139
+ const refresh = vi.fn().mockResolvedValue(undefined)
140
+
141
+ const first = controller.execute(mutate, refresh)
142
+ expect(controller.isMoving()).toBe(true)
143
+ await expect(controller.execute(mutate, refresh)).resolves.toEqual({ status: 'suppressed' })
144
+ expect(mutate).toHaveBeenCalledOnce()
145
+
146
+ resolveMutation?.()
147
+ await expect(first).resolves.toEqual({ status: 'ok' })
148
+ expect(refresh).toHaveBeenCalledOnce()
149
+ expect(controller.isMoving()).toBe(false)
150
+ })
151
+
152
+ it('refreshes and unlocks after a rejected mutation', async () => {
153
+ const controller = createAdminTreeMoveController()
154
+ const mutationError = new Error('rejected')
155
+ const refresh = vi.fn().mockResolvedValue(undefined)
156
+
157
+ await expect(
158
+ controller.execute(vi.fn().mockRejectedValue(mutationError), refresh)
159
+ ).resolves.toEqual({ status: 'mutation-failed', error: mutationError })
160
+ expect(refresh).toHaveBeenCalledOnce()
161
+ expect(controller.isMoving()).toBe(false)
162
+ })
163
+
164
+ it('unlocks and reports only refresh failure after a successful commit', async () => {
165
+ const controller = createAdminTreeMoveController()
166
+ const refreshError = new Error('refresh failed')
167
+
168
+ const outcome = await controller.execute(
169
+ vi.fn().mockResolvedValue(undefined),
170
+ vi.fn().mockRejectedValue(refreshError)
171
+ )
172
+
173
+ expect(outcome).toEqual({ status: 'refresh-failed', error: refreshError })
174
+ expect(outcome.status).not.toBe('mutation-failed')
175
+ expect(controller.isMoving()).toBe(false)
176
+ })
177
+
178
+ it('opts ordinary placement retries into lifecycle reconciliation', async () => {
179
+ const placeTreeNode = vi
180
+ .fn()
181
+ .mockRejectedValueOnce(new Error('post-commit hook failed'))
182
+ .mockResolvedValue({ orderKey: 'key-1' })
183
+
184
+ const retry = () =>
185
+ placeAdminTreeNode({ placeTreeNode } as any, 'doc-1', {
186
+ parentDocumentId: 'parent-1',
187
+ beforeDocumentId: 'left-1',
188
+ })
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
+ )
199
+ })
200
+
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 })
207
+ })
208
+
209
+ 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', {
214
+ parentDocumentId: null,
215
+ reconcile: false,
216
+ })
217
+ await removeAdminTreeNode({ removeFromTree } as any, 'doc-1', { reconcile: false })
218
+
219
+ expect(placeTreeNode).toHaveBeenCalledWith('doc-1', {
220
+ parentDocumentId: null,
221
+ reconcile: false,
222
+ })
223
+ expect(removeFromTree).toHaveBeenCalledWith('doc-1', { reconcile: false })
224
+ })
225
+ })