musora-content-services 2.160.4 → 2.161.2

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 (40) hide show
  1. package/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
  2. package/CHANGELOG.md +19 -0
  3. package/package.json +1 -1
  4. package/src/contentTypeConfig.js +13 -15
  5. package/src/index.d.ts +6 -2
  6. package/src/index.js +6 -2
  7. package/src/infrastructure/sanity/README.md +230 -0
  8. package/src/infrastructure/sanity/SanityClient.ts +105 -0
  9. package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
  10. package/src/infrastructure/sanity/examples/usage.ts +101 -0
  11. package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
  12. package/src/infrastructure/sanity/index.ts +19 -0
  13. package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
  14. package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
  15. package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
  16. package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
  17. package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
  18. package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
  19. package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
  20. package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
  21. package/src/lib/sanity/decorators/base.ts +142 -0
  22. package/src/lib/sanity/decorators/examples.ts +229 -0
  23. package/src/lib/sanity/decorators/navigate-to.ts +139 -0
  24. package/src/lib/sanity/decorators/need-access.ts +40 -0
  25. package/src/lib/sanity/decorators/page-type.ts +35 -0
  26. package/src/services/awards/award-query.js +71 -0
  27. package/src/services/contentAggregator.js +1 -1
  28. package/src/services/multi-user-accounts/multi-user-accounts.ts +11 -7
  29. package/src/services/user/memberships.ts +46 -34
  30. package/src/services/user/profile.ts +66 -0
  31. package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
  32. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
  33. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
  34. package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
  35. package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
  36. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
  37. package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
  38. package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
  39. package/.claude/settings.local.json +0 -23
  40. package/src/services/user/profile.js +0 -43
@@ -0,0 +1,164 @@
1
+ import { SanityClient } from '../SanityClient'
2
+ import { FetchByIdOptions } from '../interfaces/FetchByIdOptions'
3
+ import { ConfigProvider } from '../interfaces/ConfigProvider'
4
+ import { QueryExecutor } from '../interfaces/QueryExecutor'
5
+
6
+ /**
7
+ * ContentClient extends SanityClient with content-specific methods
8
+ * for easier content fetching and management
9
+ */
10
+ export class ContentClient extends SanityClient {
11
+ constructor(configProvider?: ConfigProvider, queryExecutor?: QueryExecutor) {
12
+ super(configProvider, queryExecutor)
13
+ }
14
+
15
+ /**
16
+ * Fetch content by type and ID (similar to fetchByRailContentId)
17
+ */
18
+ public async fetchById<T>(options: FetchByIdOptions): Promise<T | null> {
19
+ try {
20
+ const { type, id, fields, includeChildren = false } = options
21
+
22
+ // Build the base query
23
+ let query = `*[railcontent_id == ${id} && _type == '${type}']`
24
+
25
+ // Build fields string
26
+ let fieldsString = this.buildFieldsString(type, fields, includeChildren)
27
+
28
+ // Complete the query
29
+ query += `{${fieldsString}}[0]`
30
+
31
+ return await this.fetchSingle<T>(query)
32
+ } catch (error: any) {
33
+ this.handleContentError(error, `fetchById(${JSON.stringify(options)})`)
34
+ return null
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Fetch multiple content items by their IDs
40
+ */
41
+ public async fetchByIds<T>(
42
+ ids: (number | string)[],
43
+ type?: string,
44
+ brand?: string,
45
+ fields?: string[]
46
+ ): Promise<T[]> {
47
+ try {
48
+ if (!ids || ids.length === 0) {
49
+ return []
50
+ }
51
+
52
+ const idsString = ids.join(',')
53
+ const typeFilter = type ? ` && _type == '${type}'` : ''
54
+ const brandFilter = brand ? ` && brand == "${brand}"` : ''
55
+ const fieldsString = this.buildFieldsString(type || '', fields, false)
56
+
57
+ const query = `*[railcontent_id in [${idsString}]${typeFilter}${brandFilter}]{${fieldsString}}`
58
+
59
+ const results = await this.fetchList<T>(query)
60
+
61
+ // Sort results to match the order of input IDs
62
+ return results.sort((a: any, b: any) => {
63
+ const indexA = ids.indexOf(a.id || a.railcontent_id)
64
+ const indexB = ids.indexOf(b.id || b.railcontent_id)
65
+ return indexA - indexB
66
+ })
67
+ } catch (error: any) {
68
+ this.handleContentError(error, `fetchByIds([${ids.join(',')}])`)
69
+ return []
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Fetch content by brand and type with basic filtering
75
+ */
76
+ public async fetchByBrandAndType<T>(
77
+ brand: string,
78
+ type: string,
79
+ options: {
80
+ limit?: number
81
+ offset?: number
82
+ sortBy?: string
83
+ fields?: string[]
84
+ } = {}
85
+ ): Promise<T[]> {
86
+ try {
87
+ const { limit = 10, offset = 0, sortBy = 'published_on desc', fields } = options
88
+ const fieldsString = this.buildFieldsString(type, fields, false)
89
+
90
+ const query = `*[brand == "${brand}" && _type == "${type}"] | order(${sortBy})[${offset}...${offset + limit}]{${fieldsString}}`
91
+
92
+ return await this.fetchList<T>(query)
93
+ } catch (error: any) {
94
+ this.handleContentError(error, `fetchByBrandAndType(${brand}, ${type})`)
95
+ return []
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Build fields string for queries based on content type and options
101
+ */
102
+ private buildFieldsString(type: string, customFields?: string[], includeChildren: boolean = false): string {
103
+ // Default fields that are commonly used
104
+ const defaultFields = [
105
+ "'sanity_id': _id",
106
+ "'id': railcontent_id",
107
+ 'railcontent_id',
108
+ 'title',
109
+ "'image': thumbnail.asset->url",
110
+ "'thumbnail': thumbnail.asset->url",
111
+ 'difficulty',
112
+ 'difficulty_string',
113
+ 'web_url_path',
114
+ "'url': web_url_path",
115
+ 'published_on',
116
+ "'type': _type",
117
+ 'brand',
118
+ 'status',
119
+ "'slug': slug.current",
120
+ "'permission_id': permission[]->railcontent_id",
121
+ 'length_in_seconds',
122
+ "'artist': artist->name",
123
+ "'instructors': instructor[]->name"
124
+ ]
125
+
126
+ // Use custom fields if provided, otherwise use defaults
127
+ let fields = customFields || defaultFields
128
+
129
+ // Add children-related fields if requested
130
+ if (includeChildren) {
131
+ fields = [
132
+ ...fields,
133
+ "'child_count': coalesce(count(child[]->), 0)",
134
+ `"lessons": child[]->{
135
+ "id": railcontent_id,
136
+ title,
137
+ "image": thumbnail.asset->url,
138
+ "instructors": instructor[]->name,
139
+ length_in_seconds,
140
+ web_url_path
141
+ }`
142
+ ]
143
+ }
144
+
145
+ return fields.join(',\n ')
146
+ }
147
+
148
+ /**
149
+ * Handle and rethrow errors with additional context
150
+ */
151
+ private handleContentError(error: any, context: string): never {
152
+ if ('message' in error && 'query' in error) {
153
+ // This is already a SanityError
154
+ throw error
155
+ }
156
+
157
+ // Convert to SanityError with context
158
+ throw {
159
+ message: error.message || `ContentClient operation failed: ${context}`,
160
+ query: context,
161
+ originalError: error,
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,101 @@
1
+ import { SanityClient, ContentClient } from '../index'
2
+
3
+ /**
4
+ * Example usage of the SanityClient and ContentClient
5
+ *
6
+ * This demonstrates how to use both the base SanityClient for raw queries
7
+ * and the ContentClient for content-specific operations.
8
+ * Both clients automatically use the global configuration from the config service.
9
+ */
10
+
11
+ // Create client instances
12
+ const sanityClient = new SanityClient()
13
+ const contentClient = new ContentClient()
14
+
15
+ // Example: Fetch a single song by ID using the ContentClient
16
+ export async function fetchSongExample(songId: number) {
17
+ return await contentClient.fetchById({
18
+ type: 'song',
19
+ id: songId
20
+ })
21
+ }
22
+
23
+ // Example: Fetch a single song by ID with custom fields
24
+ export async function fetchSongWithCustomFieldsExample(songId: number) {
25
+ return await contentClient.fetchById({
26
+ type: 'song',
27
+ id: songId,
28
+ fields: [
29
+ "'id': railcontent_id",
30
+ 'title',
31
+ "'artist': artist->name",
32
+ "'thumbnail': thumbnail.asset->url",
33
+ 'difficulty_string',
34
+ 'published_on',
35
+ 'album',
36
+ 'soundslice'
37
+ ]
38
+ })
39
+ }
40
+
41
+ // Example: Fetch a course with its children/lessons
42
+ export async function fetchCourseWithLessonsExample(courseId: number) {
43
+ return await contentClient.fetchById({
44
+ type: 'course',
45
+ id: courseId,
46
+ includeChildren: true
47
+ })
48
+ }
49
+
50
+ // Example: Fetch multiple content items by IDs
51
+ export async function fetchMultipleSongsExample(songIds: number[]) {
52
+ return await contentClient.fetchByIds(songIds, 'song', 'drumeo')
53
+ }
54
+
55
+ // Example: Fetch content by brand and type
56
+ export async function fetchDrumeoSongsExample() {
57
+ return await contentClient.fetchByBrandAndType('drumeo', 'song', {
58
+ limit: 20,
59
+ sortBy: 'published_on desc'
60
+ })
61
+ }
62
+
63
+ // Example: Fetch multiple songs with pagination
64
+ export async function fetchSongsExample(brand: string, page: number = 1, limit: number = 10) {
65
+ const start = (page - 1) * limit
66
+ const end = start + limit
67
+
68
+ const query = `*[_type == "song" && brand == "${brand}"] | order(published_on desc)[${start}...${end}]{
69
+ "id": railcontent_id,
70
+ title,
71
+ "artist": artist->name,
72
+ "thumbnail": thumbnail.asset->url,
73
+ difficulty_string,
74
+ published_on
75
+ }`
76
+
77
+ return await sanityClient.fetchList(query)
78
+ }
79
+
80
+ // Example: Execute a complex query that returns custom structure
81
+ export async function fetchSongsWithCountExample(brand: string) {
82
+ const query = `{
83
+ "songs": *[_type == "song" && brand == "${brand}"] | order(published_on desc)[0...10]{
84
+ "id": railcontent_id,
85
+ title,
86
+ "artist": artist->name
87
+ },
88
+ "total": count(*[_type == "song" && brand == "${brand}"])
89
+ }`
90
+
91
+ return await sanityClient.executeQuery(query)
92
+ }
93
+
94
+ // Example: Using with parameters (though GROQ doesn't support parameters like SQL)
95
+ export async function fetchSongWithParamsExample(songId: number) {
96
+ // Note: Sanity GROQ doesn't support parameterized queries like SQL
97
+ // Parameters would be used for client-side processing if needed
98
+ const query = `*[_type == "song" && railcontent_id == ${songId}][0]`
99
+
100
+ return await sanityClient.fetchSingle(query, { songId })
101
+ }
@@ -0,0 +1,110 @@
1
+ import { QueryExecutor } from '../interfaces/QueryExecutor'
2
+ import { SanityQuery } from '../interfaces/SanityQuery'
3
+ import { SanityResponse } from '../interfaces/SanityResponse'
4
+ import { SanityConfig } from '../interfaces/SanityConfig'
5
+ import { SanityError } from '../interfaces/SanityError'
6
+
7
+ const GET_URL_MAX_LENGTH = 8000
8
+
9
+ export class FetchQueryExecutor implements QueryExecutor {
10
+ async execute<T>(query: SanityQuery, config: SanityConfig): Promise<SanityResponse<T>> {
11
+ const baseUrl = this.buildUrl(config)
12
+ const { url, options } = this.buildRequest(baseUrl, query, config)
13
+
14
+ if (config.debug) {
15
+ console.log('Sanity Query:', query.query)
16
+ }
17
+
18
+ try {
19
+ const response = await fetch(url, options)
20
+
21
+ if (!response.ok) {
22
+ throw await this.createSanityError(response, query)
23
+ }
24
+
25
+ const result = await response.json()
26
+
27
+ if (config.debug) {
28
+ console.log('Sanity Results:', result)
29
+ }
30
+
31
+ return result as SanityResponse<T>
32
+ } catch (error: any) {
33
+ if ('message' in error && 'query' in error) {
34
+ throw error as SanityError
35
+ }
36
+
37
+ throw {
38
+ message: error.message || 'Sanity query execution failed',
39
+ query: query.query,
40
+ params: query.params,
41
+ originalError: error,
42
+ } as SanityError
43
+ }
44
+ }
45
+
46
+ private buildUrl(config: SanityConfig): string {
47
+ const perspective = config.perspective ?? 'published'
48
+ const api = config.useCachedAPI ? 'apicdn' : 'api'
49
+ return `https://${config.projectId}.${api}.sanity.io/v${config.version}/data/query/${config.dataset}?perspective=${perspective}`
50
+ }
51
+
52
+ private buildRequest(
53
+ baseUrl: string,
54
+ query: SanityQuery,
55
+ config: SanityConfig
56
+ ): { url: string; options: RequestInit } {
57
+ const authHeader: Record<string, string> = config.token
58
+ ? { Authorization: `Bearer ${config.token}` }
59
+ : {}
60
+
61
+ const hasParams = query.params && Object.keys(query.params).length > 0
62
+ const encodedQuery = encodeURIComponent(query.query)
63
+ const getUrl = `${baseUrl}&query=${encodedQuery}`
64
+ const canUseGet = !hasParams && getUrl.length < GET_URL_MAX_LENGTH
65
+
66
+ if (canUseGet) {
67
+ return {
68
+ url: getUrl,
69
+ options: {
70
+ method: 'GET',
71
+ headers: authHeader,
72
+ },
73
+ }
74
+ }
75
+
76
+ return {
77
+ url: baseUrl,
78
+ options: {
79
+ method: 'POST',
80
+ headers: {
81
+ ...authHeader,
82
+ 'Content-Type': 'application/json',
83
+ },
84
+ body: JSON.stringify(query),
85
+ },
86
+ }
87
+ }
88
+
89
+ private async createSanityError(
90
+ response: Response,
91
+ query: SanityQuery
92
+ ): Promise<SanityError> {
93
+ let errorMessage = `Sanity API error: ${response.status} - ${response.statusText}`
94
+
95
+ try {
96
+ const errorBody = await response.json()
97
+ if (errorBody.message) {
98
+ errorMessage = errorBody.message
99
+ }
100
+ } catch (e) {
101
+ // If we can't parse the error body, use the default message
102
+ }
103
+
104
+ return {
105
+ message: errorMessage,
106
+ query: query.query,
107
+ params: query.params,
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,19 @@
1
+ // Main clients
2
+ export { SanityClient } from './SanityClient'
3
+ export { ContentClient } from './clients/ContentClient'
4
+
5
+ // Interfaces
6
+ export type { SanityConfig } from './interfaces/SanityConfig'
7
+ export type { SanityQuery } from './interfaces/SanityQuery'
8
+ export type { SanityResponse } from './interfaces/SanityResponse'
9
+ export type { SanityError } from './interfaces/SanityError'
10
+ export type { QueryExecutor } from './interfaces/QueryExecutor'
11
+ export type { ConfigProvider } from './interfaces/ConfigProvider'
12
+ export type { FetchByIdOptions } from './interfaces/FetchByIdOptions'
13
+
14
+ // Providers
15
+ export { DefaultConfigProvider } from './providers/DefaultConfigProvider'
16
+
17
+ // Executors
18
+ export { FetchQueryExecutor } from './executors/FetchQueryExecutor'
19
+
@@ -0,0 +1,6 @@
1
+ import { SanityConfig } from './SanityConfig'
2
+
3
+ export interface ConfigProvider {
4
+ getConfig(): SanityConfig
5
+ }
6
+
@@ -0,0 +1,7 @@
1
+ export interface FetchByIdOptions {
2
+ type: string
3
+ id: number | string
4
+ fields?: string[]
5
+ includeChildren?: boolean
6
+ }
7
+
@@ -0,0 +1,8 @@
1
+ import { SanityQuery } from './SanityQuery'
2
+ import { SanityResponse } from './SanityResponse'
3
+ import { SanityConfig } from './SanityConfig'
4
+
5
+ export interface QueryExecutor {
6
+ execute<T>(query: SanityQuery, config: SanityConfig): Promise<SanityResponse<T>>
7
+ }
8
+
@@ -0,0 +1,10 @@
1
+ export interface SanityConfig {
2
+ projectId: string
3
+ dataset: string
4
+ version: string
5
+ token: string
6
+ perspective?: string
7
+ useCachedAPI?: boolean
8
+ debug?: boolean
9
+ }
10
+
@@ -0,0 +1,7 @@
1
+ export interface SanityError {
2
+ message: string
3
+ query?: string
4
+ params?: Record<string, any>
5
+ originalError?: any
6
+ }
7
+
@@ -0,0 +1,5 @@
1
+ export interface SanityQuery {
2
+ query: string
3
+ params?: Record<string, any>
4
+ }
5
+
@@ -0,0 +1,6 @@
1
+ export interface SanityResponse<T = any> {
2
+ result: T
3
+ ms: number
4
+ query: string
5
+ }
6
+
@@ -0,0 +1,38 @@
1
+ import { ConfigProvider } from '../interfaces/ConfigProvider'
2
+ import { SanityConfig } from '../interfaces/SanityConfig'
3
+ import { globalConfig } from '../../../services/config.js'
4
+
5
+ export class DefaultConfigProvider implements ConfigProvider {
6
+ getConfig(): SanityConfig {
7
+ if (!globalConfig.sanityConfig) {
8
+ throw new Error('Sanity configuration is not available in globalConfig')
9
+ }
10
+
11
+ const config = globalConfig.sanityConfig
12
+
13
+ // Validate required fields
14
+ if (!config.token) {
15
+ throw new Error('Sanity token is missing in configuration')
16
+ }
17
+ if (!config.projectId) {
18
+ throw new Error('Sanity projectId is missing in configuration')
19
+ }
20
+ if (!config.dataset) {
21
+ throw new Error('Sanity dataset is missing in configuration')
22
+ }
23
+ if (!config.version) {
24
+ throw new Error('Sanity version is missing in configuration')
25
+ }
26
+
27
+ return {
28
+ projectId: config.projectId,
29
+ dataset: config.dataset,
30
+ version: config.version,
31
+ token: config.token,
32
+ perspective: (config as any).perspective ?? 'published',
33
+ useCachedAPI: (config as any).useCachedAPI ?? false,
34
+ debug: (config as any).debug ?? false,
35
+ }
36
+ }
37
+ }
38
+
@@ -0,0 +1,142 @@
1
+ export interface Decoratable {
2
+ children?: Decoratable[]
3
+ [key: string]: unknown
4
+ }
5
+
6
+ export type DecorateFn<T, V> = (item: T) => V
7
+
8
+ export type DecorateFnAsync<T, V> = (item: T) => Promise<V>
9
+
10
+ export interface FieldDecorator<T extends Decoratable, K extends string = string, V = unknown> {
11
+ field: K
12
+ compute: DecorateFn<T, V>
13
+ recurse?: boolean
14
+ }
15
+
16
+ export interface FieldDecoratorAsync<
17
+ T extends Decoratable,
18
+ K extends string = string,
19
+ V = unknown,
20
+ > {
21
+ field: K
22
+ compute: DecorateFnAsync<T, V>
23
+ recurse?: boolean
24
+ }
25
+
26
+ type Decorated<T, K extends string, V> = T & { [P in K]: V }
27
+
28
+ const MAX_CHILD_DEPTH = 3
29
+
30
+ export function decorate<T extends Decoratable, K extends string, V>(
31
+ items: T[],
32
+ field: K,
33
+ compute: DecorateFn<T, V>
34
+ ): Decorated<T, K, V>[]
35
+ export function decorate<T extends Decoratable, K extends string, V>(
36
+ items: T,
37
+ field: K,
38
+ compute: DecorateFn<T, V>
39
+ ): Decorated<T, K, V>
40
+ export function decorate<T extends Decoratable, K extends string, V>(
41
+ items: T | T[],
42
+ field: K,
43
+ compute: DecorateFn<T, V>
44
+ ): Decorated<T, K, V> | Decorated<T, K, V>[] {
45
+ const list = Array.isArray(items) ? items : [items]
46
+ for (const item of list) visit(item, [{ field, compute }], 0)
47
+ return items as Decorated<T, K, V> | Decorated<T, K, V>[]
48
+ }
49
+
50
+ export function decorateAll<T extends Decoratable>(
51
+ items: T[],
52
+ decorators: ReadonlyArray<FieldDecorator<T>>
53
+ ): T[]
54
+ export function decorateAll<T extends Decoratable>(
55
+ items: T,
56
+ decorators: ReadonlyArray<FieldDecorator<T>>
57
+ ): T
58
+ export function decorateAll<T extends Decoratable>(
59
+ items: T | T[],
60
+ decorators: ReadonlyArray<FieldDecorator<T>>
61
+ ): T | T[] {
62
+ const list = Array.isArray(items) ? items : [items]
63
+ for (const item of list) visit(item, decorators, 0)
64
+ return items
65
+ }
66
+
67
+ function visit<T extends Decoratable>(
68
+ item: T,
69
+ decorators: ReadonlyArray<FieldDecorator<T, string, unknown>>,
70
+ depth: number
71
+ ): void {
72
+ if (!item) return
73
+ const target = item as Record<string, unknown>
74
+ for (const { field, compute } of decorators) {
75
+ target[field] = compute(item)
76
+ }
77
+ if (depth >= MAX_CHILD_DEPTH - 1) return
78
+ const children = item.children
79
+ if (!Array.isArray(children)) return
80
+ const recursing = decorators.filter((d) => d.recurse !== false)
81
+ if (recursing.length === 0) return
82
+ for (const child of children) {
83
+ visit(child as T, recursing, depth + 1)
84
+ }
85
+ }
86
+
87
+ export function decorateAsync<T extends Decoratable, K extends string, V>(
88
+ items: T[],
89
+ field: K,
90
+ compute: DecorateFnAsync<T, V>
91
+ ): Promise<Decorated<T, K, V>[]>
92
+ export function decorateAsync<T extends Decoratable, K extends string, V>(
93
+ items: T,
94
+ field: K,
95
+ compute: DecorateFnAsync<T, V>
96
+ ): Promise<Decorated<T, K, V>>
97
+ export function decorateAsync<T extends Decoratable, K extends string, V>(
98
+ items: T | T[],
99
+ field: K,
100
+ compute: DecorateFnAsync<T, V>
101
+ ): Promise<Decorated<T, K, V> | Decorated<T, K, V>[]> {
102
+ return decorateAllAsync(items as T[], [{ field, compute }]) as Promise<
103
+ Decorated<T, K, V> | Decorated<T, K, V>[]
104
+ >
105
+ }
106
+
107
+ export function decorateAllAsync<T extends Decoratable>(
108
+ items: T[],
109
+ decorators: ReadonlyArray<FieldDecoratorAsync<T>>
110
+ ): Promise<T[]>
111
+ export function decorateAllAsync<T extends Decoratable>(
112
+ items: T,
113
+ decorators: ReadonlyArray<FieldDecoratorAsync<T>>
114
+ ): Promise<T>
115
+ export async function decorateAllAsync<T extends Decoratable>(
116
+ items: T | T[],
117
+ decorators: ReadonlyArray<FieldDecoratorAsync<T>>
118
+ ): Promise<T | T[]> {
119
+ const list = Array.isArray(items) ? items : [items]
120
+ await Promise.all(list.map((item) => visitAsync(item, decorators, 0)))
121
+ return items
122
+ }
123
+
124
+ async function visitAsync<T extends Decoratable>(
125
+ item: T,
126
+ decorators: ReadonlyArray<FieldDecoratorAsync<T, string, unknown>>,
127
+ depth: number
128
+ ): Promise<void> {
129
+ if (!item) return
130
+ const target = item as Record<string, unknown>
131
+ await Promise.all(
132
+ decorators.map(async ({ field, compute }) => {
133
+ target[field] = await compute(item)
134
+ })
135
+ )
136
+ if (depth >= MAX_CHILD_DEPTH - 1) return
137
+ const children = item.children
138
+ if (!Array.isArray(children)) return
139
+ const recursing = decorators.filter((d) => d.recurse !== false)
140
+ if (recursing.length === 0) return
141
+ await Promise.all(children.map((child) => visitAsync(child as T, recursing, depth + 1)))
142
+ }