     1	/**
     2	 * Provides utilities for loading related resources according to JSON:API specification
     3	 *
     4	 * @description
     5	 * This module implements the JSON:API include functionality, enabling compound documents
     6	 * that contain both primary data and related resources. It handles all relationship types
     7	 * and supports arbitrary nesting depths while preventing N+1 query problems.
     8	 *
     9	 * @example
    10	 * // Input: Simple include request
    11	 * // GET /articles?include=author
    12	 * // Records before:
    13	 * [
    14	 *   { id: 1, title: 'Article 1', author_id: 10 },
    15	 *   { id: 2, title: 'Article 2', author_id: 11 }
    16	 * ]
    17	 *
    18	 * // Result: 2 queries total (articles + authors)
    19	 * // Records after (with RELATIONSHIPS_KEY):
    20	 * [
    21	 *   {
    22	 *     id: 1,
    23	 *     title: 'Article 1',
    24	 *     author_id: 10,
    25	 *     __$jsonrestapi_rel$__: {
    26	 *       author: { data: { type: 'users', id: '10' } }
    27	 *     }
    28	 *   }
    29	 * ]
    30	 * // Included resources:
    31	 * [
    32	 *   { type: 'users', id: '10', attributes: { name: 'John' } },
    33	 *   { type: 'users', id: '11', attributes: { name: 'Jane' } }
    34	 * ]
    35	 *
    36	 * @example
    37	 * // Input: Nested includes
    38	 * // GET /articles?include=comments.author
    39	 * // Articles → Comments → Authors (3 queries total)
    40	 *
    41	 * // Result: Comments loaded with their authors
    42	 * // Included array contains both comments AND their authors
    43	 * // No duplicate authors even if multiple comments by same person
    44	 *
    45	 * @example
    46	 * // Input: Polymorphic includes
    47	 * // GET /activities?include=trackable
    48	 * // Where trackable can be 'posts', 'photos', or 'videos'
    49	 *
    50	 * // Groups by type and loads each type separately:
    51	 * // Query 1: SELECT * FROM posts WHERE id IN (1, 3, 5)
    52	 * // Query 2: SELECT * FROM photos WHERE id IN (2, 4)
    53	 * // Query 3: SELECT * FROM videos WHERE id IN (6)
    54	 *
    55	 * Used by:
    56	 * - rest-api-knex-plugin calls buildIncludedResources in dataGet and dataQuery
    57	 * - Called after primary records are fetched but before response assembly
    58	 * - Results go into the 'included' section of JSON:API response
    59	 *
    60	 * Purpose:
    61	 * - Implements JSON:API compound documents with primary data and included resources
    62	 * - Prevents N+1 queries through batch loading (1 query per resource type)
    63	 * - Supports deep nesting like article.author.company.country.continent
    64	 * - Deduplicates resources automatically (each resource appears once)
    65	 * - Handles circular references through processedPaths tracking
    66	 * - Applies sparse fieldsets to reduce payload size
    67	 *
    68	 * Data flow:
    69	 * 1. Parse include parameter into tree structure (author,comments.author)
    70	 * 2. For each relationship, determine type (belongsTo, hasMany, polymorphic)
    71	 * 3. Batch load all related records of same type in single query
    72	 * 4. Recursively process nested includes
    73	 * 5. Store all included resources in deduplication Map
    74	 * 6. Return array of unique included resources
    75	 */
    76	
    77	import { buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
    78	import { getForeignKeyFields } from '../querying-writing/field-utils.js'
    79	import { toJsonApiRecord } from './knex-json-api-transformers-querying.js'
    80	import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClause } from './knex-window-queries.js'
    81	import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
    82	import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
    83	
    84	const translateSelectFieldsForAdapter = (fields, adapter) => {
    85	  if (!adapter || !fields) return fields
    86	
    87	  const translateField = (field) => {
    88	    if (typeof field !== 'string') return field
    89	
    90	    if (field === '*') return '*'
    91	
    92	    const aliasMatch = field.match(/\s+as\s+/i)
    93	    if (aliasMatch) {
    94	      const [source, alias] = field.split(/\s+as\s+/i)
    95	      const translatedSource = adapter.translateColumn(source.trim()) || source.trim()
    96	      return `${translatedSource} as ${alias.trim()}`
    97	    }
    98	
    99	    const translated = adapter.translateColumn(field)
   100	    return translated || field
   101	  }
   102	
   103	  return fields.map(translateField)
   104	}
   105	
   106	/**
   107	 * Groups records by their polymorphic type for efficient batch loading
   108	 *
   109	 * @param {Array<Object>} records - Records containing polymorphic fields
   110	 * @param {string} typeField - Name of the type field (e.g., 'commentable_type')
   111	 * @param {string} idField - Name of the ID field (e.g., 'commentable_id')
   112	 * @returns {Object<string, Array<number|string>>} Map of type to array of unique IDs
   113	 *
   114	 * @example
   115	 * // Input: Comments with polymorphic commentable relationship
   116	 * const records = [
   117	 *   { id: 1, commentable_type: 'articles', commentable_id: 10 },
   118	 *   { id: 2, commentable_type: 'videos', commentable_id: 20 },
   119	 *   { id: 3, commentable_type: 'articles', commentable_id: 11 },
   120	 *   { id: 4, commentable_type: 'articles', commentable_id: 10 }  // Duplicate
   121	 * ];
   122	 *
   123	 * const grouped = groupByPolymorphicType(records, 'commentable_type', 'commentable_id');
   124	 *
   125	 * // Output: IDs grouped by type, duplicates removed
   126	 * // {
   127	 * //   articles: [10, 11],  // Only unique IDs
   128	 * //   videos: [20]
   129	 * // }
   130	 *
   131	 * @example
   132	 * // Input: Some records have null relationships
   133	 * const records = [
   134	 *   { id: 1, item_type: 'products', item_id: 1 },
   135	 *   { id: 2, item_type: null, item_id: null },      // Ignored
   136	 *   { id: 3, item_type: 'products', item_id: null }, // Ignored
   137	 *   { id: 4, item_type: 'categories', item_id: 5 }
   138	 * ];
   139	 *
   140	 * const grouped = groupByPolymorphicType(records, 'item_type', 'item_id');
   141	 *
   142	 * // Output: Only records with both type AND id
   143	 * // {
   144	 * //   products: [1],
   145	 * //   categories: [5]
   146	 * // }
   147	 *
   148	 * @description
   149	 * Used by:
   150	 * - loadPolymorphicBelongsTo uses this to group records before batch loading
   151	 * - Enables efficient loading with one query per type instead of per record
   152	 *
   153	 * Purpose:
   154	 * - Minimizes database queries when loading polymorphic relationships
   155	 * - Instead of N queries (one per record), makes T queries (one per type)
   156	 * - Automatically deduplicates IDs within each type
   157	 * - Safely handles null/undefined values by filtering them out
   158	 *
   159	 * Data flow:
   160	 * 1. Receives array of records with polymorphic fields
   161	 * 2. Groups records by their type field value
   162	 * 3. Collects unique IDs for each type
   163	 * 4. Returns map used for batch WHERE IN queries
   164	 */
   165	export const groupByPolymorphicType = (records, typeField, idField) => {
   166	  const grouped = {}
   167	
   168	  records.forEach(record => {
   169	    const type = record[typeField]
   170	    const id = record[idField]
   171	
   172	    // Skip if either type or id is missing
   173	    if (!type || !id) return
   174	
   175	    if (!grouped[type]) {
   176	      grouped[type] = []
   177	    }
   178	
   179	    // Only add unique IDs
   180	    if (!grouped[type].includes(id)) {
   181	      grouped[type].push(id)
   182	    }
   183	  })
   184	
   185	  return grouped
   186	}
   187	
   188	/**
   189	 * Parses the include parameter string into a tree structure
   190	 *
   191	 * @param {string|Array<string>} includeParam - The include parameter value
   192	 * @returns {Object} Nested object representing the include tree
   193	 *
   194	 * @example
   195	 * // Input: Simple comma-separated includes
   196	 * const tree = parseIncludeTree('author,comments');
   197	 *
   198	 * // Output: Flat structure
   199	 * // {
   200	 * //   author: {},
   201	 * //   comments: {}
   202	 * // }
   203	 *
   204	 * @example
   205	 * // Input: Nested includes with dot notation
   206	 * const tree = parseIncludeTree('comments.author,comments.replies');
   207	 *
   208	 * // Output: Nested structure
   209	 * // {
   210	 * //   comments: {
   211	 * //     author: {},
   212	 * //     replies: {}
   213	 * //   }
   214	 * // }
   215	 *
   216	 * @example
   217	 * // Input: Complex mixed-depth includes
   218	 * const tree = parseIncludeTree('author,comments.author,comments.replies.author,tags');
   219	 *
   220	 * // Output: Multi-level nesting
   221	 * // {
   222	 * //   author: {},
   223	 * //   comments: {
   224	 * //     author: {},
   225	 * //     replies: {
   226	 * //       author: {}
   227	 * //     }
   228	 * //   },
   229	 * //   tags: {}
   230	 * // }
   231	 *
   232	 * @description
   233	 * Used by:
   234	 * - buildIncludedResources parses the include parameter before processing
   235	 * - processIncludes traverses this tree structure recursively
   236	 *
   237	 * Purpose:
   238	 * - Converts flat string format into hierarchical structure for processing
   239	 * - Enables recursive traversal of relationship includes
   240	 * - Handles both string and pre-split array inputs
   241	 * - Empty values are filtered out automatically
   242	 *
   243	 * Data flow:
   244	 * 1. Split comma-separated string into individual paths
   245	 * 2. For each path, split by dots to get nesting levels
   246	 * 3. Build nested object structure following the path
   247	 * 4. Return tree for recursive processing
   248	 */
   249	export const parseIncludeTree = (includeParam) => {
   250	  if (!includeParam) return {}
   251	
   252	  // Handle array input (already split)
   253	  const includes = Array.isArray(includeParam)
   254	    ? includeParam
   255	    : includeParam.split(',').map(s => s.trim()).filter(Boolean)
   256	
   257	  const tree = {}
   258	
   259	  includes.forEach(include => {
   260	    const parts = include.split('.')
   261	    let current = tree
   262	
   263	    parts.forEach(part => {
   264	      if (!current[part]) {
   265	        current[part] = {}
   266	      }
   267	      current = current[part]
   268	    })
   269	  })
   270	
   271	  return tree
   272	}
   273	
   274	/**
   275	 * Loads relationship metadata for included resources
   276	 * This ensures included resources have complete JSON:API representation
   277	 *
   278	 * @param {Object} scopes - The hooked-api scopes object
   279	 * @param {Array<Object>} records - Records to add relationships to
   280	 * @param {string} scopeName - The scope/resource type name
   281	 */
   282	const loadRelationshipMetadata = async (scopes, records, scopeName) => {
   283	  try {
   284	    // Get schema information
   285	    const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
   286	    if (!schemaInfo) return
   287	
   288	    const schema = schemaInfo.schemaInstance.structure || schemaInfo.schemaInstance
   289	    const relationships = schemaInfo.schemaRelationships || {}
   290	
   291	    // Process each record
   292	    records.forEach(record => {
   293	      record[RELATIONSHIP_METADATA_KEY] = {}
   294	
   295	      // Process belongsTo relationships from schema
   296	      for (const [fieldName, fieldDef] of Object.entries(schema)) {
   297	        if (fieldDef.belongsTo && fieldDef.as) {
   298	          const foreignKeyValue = record[fieldName]
   299	          if (foreignKeyValue != null) {
   300	            record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = {
   301	              data: {
   302	                type: fieldDef.belongsTo,
   303	                id: String(foreignKeyValue)
   304	              }
   305	            }
   306	          } else {
   307	            record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = { data: null }
   308	          }
   309	        }
   310	      }
   311	
   312	      // Process hasMany, hasOne and manyToMany relationships
   313	      for (const [relName, relDef] of Object.entries(relationships)) {
   314	        if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
   315	          // Add empty relationship data - will be populated if explicitly included
   316	          record[RELATIONSHIP_METADATA_KEY][relName] = { data: [] }
   317	        } else if (relDef.type === 'hasOne') {
   318	          // hasOne expects a single object or null
   319	          record[RELATIONSHIP_METADATA_KEY][relName] = { data: null }
   320	        } else if (relDef.belongsToPolymorphic) {
   321	          // Process polymorphic relationships defined in relationships section
   322	          const { typeField, idField } = relDef.belongsToPolymorphic
   323	          const type = record[typeField]
   324	          const id = record[idField]
   325	
   326	          if (type && id) {
   327	            record[RELATIONSHIP_METADATA_KEY][relName] = {
   328	              data: { type, id: String(id) }
   329	            }
   330	          } else {
   331	            record[RELATIONSHIP_METADATA_KEY][relName] = { data: null }
   332	          }
   333	        }
   334	      }
   335	    })
   336	  } catch (error) {
   337	    // Log error with context and re-throw
   338	    const errorContext = {
   339	      scopeName,
   340	      recordCount: records?.length || 0,
   341	      error: error.message
   342	    }
   343	    console.error('[loadRelationshipMetadata] Error loading relationship metadata:', errorContext)
   344	    throw new Error(`Failed to load relationship metadata for scope '${scopeName}': ${error.message}`)
   345	  }
   346	}
   347	
   348	/**
   349	 * Loads belongsTo relationships (many-to-one)
   350	 *
   351	 * This function loads the "parent" side of a relationship. For example, if comments
   352	 * belong to articles, this loads the articles for a set of comments.
   353	 *
   354	 * @param {Object} scope - The hooked-api scope object containing:
   355	 *   - records: Array<Object> - Records to load relationships for
   356	 *   - fieldName: string - The foreign key field name (e.g., 'author_id')
   357	 *   - fieldDef: Object - The field definition from the schema
   358	 *   - includeName: string - The relationship name (e.g., 'author')
   359	 *   - subIncludes: Object - Nested includes to process recursively
   360	 *   - included: Map - Map of already included resources
   361	 *   - processedPaths: Set - Set of already processed paths
   362	 *   - currentPath: string - Current include path for tracking
   363	 *   - fields: Object - Sparse fieldsets configuration
   364	 *   - idProperty: string - The ID property name
   365	 * @param {Object} deps - Dependencies object containing:
   366	 *   - context.scopes: Object - The hooked-api scopes object
   367	 *   - context.log: Object - Logger instance
   368	 *   - context.knex: Object - Knex instance
   369	 * @returns {Promise<void>}
   370	 */
   371	export const loadBelongsTo = async (scope, deps) => {
   372	  const { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty } = scope
   373	  const { scopes, log, knex, capabilities } = deps.context
   374	  try {
   375	    log.trace('[INCLUDE] Loading belongsTo:', {
   376	      fieldName,
   377	      includeName,
   378	      recordCount: records.length
   379	    })
   380	
   381	    // Get the target scope name
   382	    const targetScope = fieldDef.belongsTo
   383	    if (!scopes[targetScope]) {
   384	      log.warn('[INCLUDE] Target scope not found:', targetScope)
   385	      return
   386	    }
   387	
   388	    // Collect all foreign key values
   389	    const foreignKeyValues = records
   390	      .map(r => r[fieldName])
   391	      .filter(val => val != null) // Filter out null/undefined
   392	
   393	    const uniqueIds = [...new Set(foreignKeyValues)]
   394	
   395	    log.debug('[INCLUDE] Loading belongsTo records:', {
   396	      targetScope,
   397	      uniqueIds
   398	    })
   399	
   400	    if (uniqueIds.length === 0) {
   401	      // No relationships to load, set all to null
   402	      records.forEach(record => {
   403	        if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
   404	        const relationshipObject = { data: null }
   405	
   406	        // Add links if urlPrefix is configured
   407	        const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
   408	        if (scopeName) {
   409	          relationshipObject.links = {
   410	            self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
   411	            related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
   412	          }
   413	        }
   414	
   415	        record[RELATIONSHIPS_KEY][includeName] = relationshipObject
   416	      })
   417	      return
   418	    }
   419	
   420	    // Get target table info
   421	    const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
   422	    const targetIdProperty = scopes[targetScope].vars.schemaInfo.idProperty
   423	    const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
   424	
   425	    // Build field selection for sparse fieldsets
   426	    const targetScopeObject = scopes[targetScope]
   427	    const storageAdapter = targetScopeObject.vars.storageAdapter
   428	    const fieldSelectionInfo = fields?.[targetScope]
   429	      ? await buildFieldSelection(targetScopeObject, {
   430	        context: {
   431	          scopeName: targetScope,
   432	          queryParams: { fields: { [targetScope]: fields[targetScope] } },
   433	          schemaInfo: targetScopeObject.vars.schemaInfo,
   434	          storageAdapter,
   435	        }
   436	      })
   437	      : null
   438	
   439	    // Load the target records
   440	    let query = knex(targetTableName).whereIn(targetIdProperty, uniqueIds)
   441	    if (fieldSelectionInfo) {
   442	      const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
   443	      query = query.select(selectFields)
   444	    } else if (targetIdProperty !== 'id') {
   445	      // If no field selection but custom idProperty, we need to alias it
   446	      query = query.select('*', `${targetIdProperty} as id`)
   447	    }
   448	    const targetRecords = await query
   449	
   450	    // Load relationship metadata for all target records
   451	    await loadRelationshipMetadata(scopes, targetRecords, targetScope)
   452	
   453	    // Create lookup map
   454	    const targetById = {}
   455	    targetRecords.forEach(record => {
   456	      targetById[record.id || record[targetIdProperty]] = record
   457	    })
   458	
   459	    // Set relationships on original records
   460	    const targetRecordsToProcess = []
   461	
   462	    records.forEach(record => {
   463	      if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
   464	
   465	      const targetId = record[fieldName]
   466	      const targetRecord = targetById[targetId]
   467	
   468	      if (targetRecord) {
   469	        // Convert to JSON:API format
   470	        const jsonApiRecord = toJsonApiRecord(
   471	          scopes[targetScope],
   472	          targetRecord,
   473	          targetScope
   474	        )
   475	
   476	        // Add relationships from metadata
   477	        if (targetRecord[RELATIONSHIP_METADATA_KEY]) {
   478	          jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY]
   479	          // Clean up the temporary property
   480	          delete targetRecord[RELATIONSHIP_METADATA_KEY]
   481	        }
   482	
   483	        // Attach computed dependencies info if sparse fieldsets were used
   484	        if (fieldSelectionInfo?.computedDependencies) {
   485	          jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
   486	        }
   487	
   488	        const relationshipObject = { data: { type: targetScope, id: String(targetId) } }
   489	
   490	        // Add links if urlPrefix is configured
   491	        const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
   492	        if (scopeName) {
   493	          relationshipObject.links = {
   494	            self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
   495	            related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
   496	          }
   497	        }
   498	
   499	        record[RELATIONSHIPS_KEY][includeName] = relationshipObject
   500	
   501	        // Add to included if not already there
   502	        const resourceKey = `${targetScope}:${targetId}`
   503	        if (!included.has(resourceKey)) {
   504	          included.set(resourceKey, jsonApiRecord) // Now includes relationships!
   505	
   506	          // Collect for nested processing
   507	          if (Object.keys(subIncludes).length > 0) {
   508	            targetRecordsToProcess.push(targetRecord)
   509	          }
   510	        }
   511	      } else {
   512	        const relationshipObject = { data: null }
   513	
   514	        // Add links if urlPrefix is configured
   515	        const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
   516	        if (scopeName) {
   517	          relationshipObject.links = {
   518	            self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
   519	            related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
   520	          }
   521	        }
   522	
   523	        record[RELATIONSHIPS_KEY][includeName] = relationshipObject
   524	      }
   525	    })
   526	
   527	    // Process nested includes if any
   528	    if (targetRecordsToProcess.length > 0) {
   529	      const nextPath = `${currentPath}.${includeName}`
   530	      if (!processedPaths.has(nextPath)) {
   531	        await processIncludes(
   532	          { records: targetRecordsToProcess, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
   533	          { context: { scopes, log, knex, capabilities } }
   534	        )
   535	      }
   536	    }
   537	  } catch (error) {
   538	    // Log error with detailed context
   539	    log.error('[INCLUDE] Error loading belongsTo relationship:', {
   540	      fieldName,
   541	      includeName,
   542	      targetScope: fieldDef.belongsTo,
   543	      recordCount: records?.length || 0,
   544	      error: error.message,
   545	      stack: error.stack
   546	    })
   547	
   548	    // Re-throw with enhanced error message
   549	    const enhancedError = new Error(
   550	      `Failed to load belongsTo relationship '${includeName}' for field '${fieldName}': ${error.message}`
   551	    )
   552	    enhancedError.originalError = error
   553	    enhancedError.context = { fieldName, includeName, targetScope: fieldDef.belongsTo }
   554	    throw enhancedError
   555	  }
   556	}
   557	
   558	/**
   559	 * Loads hasMany relationships (one-to-many or many-to-many)
   560	 *
   561	 * This function loads the "child" side of a relationship. For example, if articles
   562	 * have many comments, this loads all comments for a set of articles. It also handles
   563	 * many-to-many relationships through a pivot table.
   564	 *
   565	 * @param {Object} scope - The hooked-api scope object containing:
   566	 *   - records: Array<Object> - Parent records to load relationships for
   567	 *   - scopeName: string - The parent scope name
   568	 *   - includeName: string - The relationship name to include
   569	 *   - relDef: Object - The relationship definition
   570	 *   - subIncludes: Object - Nested includes to process recursively
   571	 *   - included: Map - Map of already included resources
   572	 *   - processedPaths: Set - Set of already processed paths
   573	 *   - currentPath: string - Current include path for tracking
   574	 *   - fields: Object - Sparse fieldsets configuration
   575	 * @param {Object} deps - Dependencies object containing:
   576	 *   - context.scopes: Object - The hooked-api scopes object
   577	 *   - context.log: Object - Logger instance
   578	 *   - context.knex: Object - Knex instance
   579	 * @returns {Promise<void>}
   580	 */
   581	export const loadHasMany = async (scope, deps) => {
   582	  const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
   583	  const { scopes, log, knex, capabilities } = deps.context
   584	  try {
   585	    log.trace('[INCLUDE] Loading hasMany/manyToMany relationship:', {
   586	      scopeName,
   587	      includeName,
   588	      recordCount: records.length,
   589	      relDef,
   590	      isManyToMany: relDef.type === 'manyToMany',
   591	      hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
   592	    })
   593	
   594	    // Collect all parent IDs
   595	    const mainIds = records.map(r => r.id).filter(Boolean)
   596	
   597	    if (mainIds.length === 0) {
   598	      log.trace('[INCLUDE] No parent IDs found, skipping hasMany load')
   599	      return
   600	    }
   601	
   602	    // Check if this is a many-to-many relationship
   603	    if (relDef.type === 'manyToMany') {
   604	    // Handle many-to-many relationship
   605	      const pivotTable = scopes[relDef.through].vars.schemaInfo.tableName
   606	      const foreignKey = relDef.foreignKey
   607	      const otherKey = relDef.otherKey
   608	
   609	      if (!foreignKey || !otherKey) {
   610	        throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${relName}' for scope '${scopeName}'`)
   611	      }
   612	
   613	      // For manyToMany, the target scope is the relationship name itself
   614	      const targetScope = includeName
   615	      const targetTable = scopes[targetScope].vars.schemaInfo.tableName
   616	
   617	      log.debug(`[INCLUDE] Loading pivot records from ${pivotTable}:`, {
   618	        foreignKey,
   619	        whereIn: mainIds
   620	      })
   621	
   622	      // Step 1: Query the pivot table
   623	      const pivotRecords = await knex(pivotTable)
   624	        .whereIn(foreignKey, mainIds)
   625	        .orderBy(otherKey)
   626	
   627	      if (pivotRecords.length === 0) {
   628	      // No relationships found, set empty arrays for all records
   629	        records.forEach(record => {
   630	          if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
   631	          const relationshipObject = { data: [] }
   632	
   633	          // Add links if urlPrefix is configured
   634	          const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
   635	          if (scopeName) {
   636	            relationshipObject.links = {
   637	              self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
   638	              related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
   639	            }
   640	          }
   641	
   642	          record[RELATIONSHIPS_KEY][includeName] = relationshipObject
   643	        })
   644	        return
   645	      }
   646	
   647	      // Step 2: Extract target IDs from pivot records
   648	      const targetIds = [...new Set(pivotRecords.map(p => p[otherKey]).filter(Boolean))]
   649	
   650	      log.debug(`[INCLUDE] Loading ${targetScope} records:`, {
   651	        whereIn: targetIds,
   652	        includeConfig: relDef.include
   653	      })
   654	
   655	      // Step 3: Build field selection for sparse fieldsets
   656	      const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
   657	      const targetScopeObject = scopes[targetScope]
   658	      const storageAdapter = targetScopeObject.vars.storageAdapter
   659	      const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
   660	      const fieldSelectionInfo = fields?.[targetScope]
   661	        ? await buildFieldSelection(targetScopeObject, {
   662	          context: {
   663	            scopeName: targetScope,
   664	            queryParams: { fields: { [targetScope]: fields[targetScope] } },
   665	            schemaInfo: targetScopeObject.vars.schemaInfo,
   666	            storageAdapter,
   667	          }
   668	        })
   669	        : null
   670	
   671	      // Step 4: For many-to-many with limits, we need a different approach
   672	      let targetRecords
   673	
   674	      if (relDef.include?.strategy === 'window') {
   675	      // For many-to-many with window functions, we need to limit per parent
   676	      // This requires joining back through the pivot table
   677	
   678	        // Get target scope vars for defaults
   679	        const targetVars = scopes[targetScope].vars || {}
   680	
   681	        // Calculate effective limit with defaults
   682	        const effectiveLimit = relDef.include?.limit ?? targetVars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT
   683	
   684	        // Validate against max
   685	        if (targetVars.queryMaxLimit && effectiveLimit > targetVars.queryMaxLimit) {
   686	          throw new RestApiResourceError({
   687	            title: 'Include Limit Exceeds Maximum',
   688	            detail: `Requested include limit (${effectiveLimit}) exceeds queryMaxLimit (${targetVars.queryMaxLimit})`,
   689	            status: 400
   690	          })
   691	        }
   692	
   693	        const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
   694	        const windowQuery = knex
   695	          .select(`${targetTable}.*`)
   696	
   697	        // Add id alias if needed
   698	        if (targetIdProperty !== 'id') {
   699	          windowQuery.select(`${targetTable}.${targetIdProperty} as id`)
   700	        }
   701	
   702	        windowQuery.select(
   703	          knex.raw(
   704	            'ROW_NUMBER() OVER (PARTITION BY pivot.?? ORDER BY ' +
   705	            buildOrderByClause(relDef.include?.orderBy || [targetIdProperty], targetTable) +
   706	            ') as ' + ROW_NUMBER_KEY,
   707	            [foreignKey]
   708	          )
   709	        )
   710	          .from(targetTable)
   711	          .join(`${pivotTable} as pivot`, `${targetTable}.${targetIdProperty}`, 'pivot.' + otherKey)
   712	          .whereIn(`pivot.${foreignKey}`, mainIds)
   713	
   714	        // Wrap to filter by row number
   715	        const limitedQuery = knex
   716	          .select('*')
   717	          .from(windowQuery.as('_windowed'))
   718	          .where(ROW_NUMBER_KEY, '<=', effectiveLimit)
   719	
   720	        targetRecords = await limitedQuery
   721	
   722	        // Remove row number column and restore proper pivot grouping
   723	        targetRecords.forEach(record => delete record[ROW_NUMBER_KEY])
   724	      } else {
   725	      // Standard query without per-parent limits
   726	        const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
   727	        let query = knex(targetTable).whereIn(targetIdProperty, targetIds)
   728	
   729	        if (fieldSelectionInfo) {
   730	          const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
   731	          query = query.select(selectFields)
   732	        } else if (targetIdProperty !== 'id') {
   733	        // If no field selection but custom idProperty, we need to alias it
   734	          query = query.select('*', `${targetIdProperty} as id`)
   735	        }
   736	
   737	        // Apply standard include config (global limits)
   738	        if (relDef.include) {
   739	          const targetVars = scopes[targetScope].vars
   740	          query = applyStandardIncludeConfig(query, relDef.include, targetVars, log)
   741	        }
   742	
   743	        targetRecords = await query
   744	      }
   745	
   746	      log.trace('[INCLUDE] Loaded target records:', { count: targetRecords.length })
   747	
   748	      // Step 5: Load relationship metadata for all target records
   749	      await loadRelationshipMetadata(scopes, targetRecords, targetScope)
   750	
   751	      // Step 6: Create lookup map for target records
   752	      const targetById = {}
   753	      targetRecords.forEach(record => {
   754	        targetById[record.id] = record
   755	      })
   756	
   757	      // Step 7: Group pivot records by parent ID
   758	      const pivotsByParent = {}
   759	      pivotRecords.forEach(pivot => {
   760	        const parentId = pivot[foreignKey]
   761	        if (!pivotsByParent[parentId]) {
   762	          pivotsByParent[parentId] = []
   763	        }
   764	        pivotsByParent[parentId].push(pivot[otherKey])
   765	      })
   766	
   767	      // Step 7: Set relationships on parent records
   768	
   769	      records.forEach(record => {
   770	        if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
   771	
   772	        const childIds = pivotsByParent[record.id] || []
   773	
   774	        const relData = childIds
   775	          .map(childId => targetById[childId])
   776	          .filter(Boolean)
   777	          .map(childRecord => {
   778	          // Add to included
   779	            const resourceKey = `${targetScope}:${childRecord.id}`
   780	            if (!included.has(resourceKey)) {
   781	              const jsonApiRecord = toJsonApiRecord(
   782	                scopes[targetScope],
   783	                childRecord,
   784	                targetScope
   785	              )
   786	
   787	              // Add relationships from metadata
   788	              if (childRecord[RELATIONSHIP_METADATA_KEY]) {
   789	                jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
   790	                // Clean up the temporary property
   791	                delete childRecord[RELATIONSHIP_METADATA_KEY]
   792	              }
   793	
   794	              // Attach computed dependencies info if sparse fieldsets were used
   795	              if (fieldSelectionInfo?.computedDependencies) {
   796	                jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
   797	              }
   798	
   799	              included.set(resourceKey, jsonApiRecord)
   800	            }
   801	            return { type: targetScope, id: String(childRecord.id) }
   802	          })
   803	
   804	        const relationshipObject = { data: relData }
   805	
   806	        // Add links if urlPrefix is configured
   807	        const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
   808	        if (scopeName) {
   809	          relationshipObject.links = {
   810	            self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
   811	            related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
   812	          }
   813	        }
   814	
   815	        record[RELATIONSHIPS_KEY][includeName] = relationshipObject
   816	
   817	        // Update the record in the included Map if it exists
   818	        const recordKey = `${scopeName}:${record.id}`
   819	        if (included.has(recordKey)) {
   820	          const existingRecord = included.get(recordKey)
   821	          if (!existingRecord.relationships) {
   822	            existingRecord.relationships = {}
   823	          }
   824	          existingRecord.relationships[includeName] = relationshipObject
   825	        }
   826	      })
   827	
   828	      // Step 8: Process nested includes if any
   829	      if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
   830	        const nextPath = `${currentPath}.${includeName}`
   831	        if (!processedPaths.has(nextPath)) {
   832	          await processIncludes(
   833	            { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
   834	            { context: { scopes, log, knex, capabilities } }
   835	          )
   836	        }
   837	      }
   838	    } else if (relDef.type === 'hasMany') {
   839	    // Handle one-to-many relationship
   840	      const targetScope = relDef.target
   841	      const targetTable = scopes[targetScope].vars.schemaInfo.tableName
   842	      const foreignKey = relDef.foreignKey
   843	
   844	      if (!foreignKey) {
   845	        throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
   846	      }
   847	
   848	      log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
   849	        whereIn: mainIds,
   850	        includeConfig: relDef.include
   851	      })
   852	
   853	      // Build field selection for sparse fieldsets
   854	      const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
   855	      const targetScopeObject = scopes[targetScope]
   856	      const storageAdapter = targetScopeObject.vars.storageAdapter
   857	      const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
   858	      const fieldSelectionInfo = fields?.[targetScope]
   859	        ? await buildFieldSelection(targetScopeObject, {
   860	          context: {
   861	            scopeName: targetScope,
   862	            queryParams: { fields: { [targetScope]: fields[targetScope] } },
   863	            schemaInfo: targetScopeObject.vars.schemaInfo,
   864	            storageAdapter,
   865	          }
   866	        })
   867	        : null
   868	
   869	      let query
   870	      let usingWindowFunction = false
   871	
   872	      // Check if we should use window functions
   873	      if (relDef.include?.strategy === 'window') {
   874	        try {
   875	        // Try to build window function query
   876	          const selectFields = fieldSelectionInfo
   877	            ? translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
   878	            : null
   879	
   880	          query = buildWindowedIncludeQuery(
   881	            knex,
   882	            targetTable,
   883	            foreignKey,
   884	            mainIds,
   885	            selectFields,
   886	            relDef.include || {},
   887	            capabilities,
   888	            targetScopeObject.vars  // Pass target scope vars
   889	          )
   890	          usingWindowFunction = true
   891	          log.debug('[INCLUDE] Using window function strategy with limits')
   892	        } catch (error) {
   893	        // If window functions not supported, this will throw a clear error
   894	          if (error.details?.requiredFeature === 'window_functions') {
   895	            throw error // Re-throw the descriptive error
   896	          }
   897	          // For other errors, fall back to standard query
   898	          log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
   899	            error: error.message,
   900	            stack: error.stack
   901	          })
   902	          usingWindowFunction = false
   903	        }
   904	      }
   905	
   906	      // Build standard query if not using window functions
   907	      if (!usingWindowFunction) {
   908	        query = knex(targetTable).whereIn(foreignKey, mainIds)
   909	
   910	        if (fieldSelectionInfo) {
   911	          const selectFieldsStd = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
   912	          query = query.select(selectFieldsStd)
   913	        }
   914	
   915	        // Apply standard config with defaults
   916	        const targetVars = scopes[targetScope].vars
   917	        query = applyStandardIncludeConfig(
   918	          query,
   919	          relDef.include || {},
   920	          targetVars,
   921	          log
   922	        )
   923	      }
   924	
   925	      // Execute query
   926	      const targetRecords = await query
   927	
   928	      // If using window functions, remove the row number column
   929	      if (usingWindowFunction) {
   930	        targetRecords.forEach(record => delete record[ROW_NUMBER_KEY])
   931	      }
   932	
   933	      log.trace('[INCLUDE] Loaded hasMany records:', {
   934	        count: targetRecords.length,
   935	        usingWindowFunction
   936	      })
   937	
   938	      // Load relationship metadata for all target records
   939	      await loadRelationshipMetadata(scopes, targetRecords, targetScope)
   940	
   941	      // Group by parent ID
   942	      const childrenByParent = {}
   943	      targetRecords.forEach(record => {
   944	        const parentId = record[foreignKey]
   945	        if (!childrenByParent[parentId]) {
   946	          childrenByParent[parentId] = []
   947	        }
   948	        childrenByParent[parentId].push(record)
   949	      })
   950	
   951	      // Set relationships on parent records
   952	      records.forEach(record => {
   953	        if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
   954	
   955	        const children = childrenByParent[record.id] || []
   956	        const relData = children.map(childRecord => {
   957	        // Add to included
   958	          const resourceKey = `${targetScope}:${childRecord.id}`
   959	          if (!included.has(resourceKey)) {
   960	            const jsonApiRecord = toJsonApiRecord(
   961	              scopes[targetScope],
   962	              childRecord,
   963	              targetScope
   964	            )
   965	
   966	            // Add relationships from metadata
   967	            if (childRecord[RELATIONSHIP_METADATA_KEY]) {
   968	              jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
   969	              // Clean up the temporary property
   970	              delete childRecord[RELATIONSHIP_METADATA_KEY]
   971	            }
   972	
   973	            // Attach computed dependencies info if sparse fieldsets were used
   974	            if (fieldSelectionInfo?.computedDependencies) {
   975	              jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
   976	            }
   977	
   978	            included.set(resourceKey, jsonApiRecord)
   979	          }
   980	          return { type: targetScope, id: String(childRecord.id) }
   981	        })
   982	
   983	        const relationshipObject = { data: relData }
   984	
   985	        // Add links if urlPrefix is configured
   986	        const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
   987	        if (scopeName) {
   988	          relationshipObject.links = {
   989	            self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
   990	            related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
   991	          }
   992	        }
   993	
   994	        record[RELATIONSHIPS_KEY][includeName] = relationshipObject
   995	
   996	        // Update the record in the included Map if it exists
   997	        const recordKey = `${scopeName}:${record.id}`
   998	        if (included.has(recordKey)) {
   999	          const existingRecord = included.get(recordKey)
  1000	          if (!existingRecord.relationships) {
  1001	            existingRecord.relationships = {}
  1002	          }
  1003	          existingRecord.relationships[includeName] = relationshipObject
  1004	        }
  1005	      })
  1006	
  1007	      // Process nested includes
  1008	      if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
  1009	        const nextPath = `${currentPath}.${includeName}`
  1010	        if (!processedPaths.has(nextPath)) {
  1011	          await processIncludes(
  1012	            { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
  1013	            { context: { scopes, log, knex, capabilities } }
  1014	          )
  1015	        }
  1016	      }
  1017	    }
  1018	  } catch (error) {
  1019	    // Log error with detailed context
  1020	    log.error('[INCLUDE] Error loading hasMany relationship:', {
  1021	      scopeName,
  1022	      includeName,
  1023	      hasThrough: !!relDef.through,
  1024	      recordCount: records?.length || 0,
  1025	      error: error.message,
  1026	      stack: error.stack
  1027	    })
  1028	
  1029	    // Re-throw with enhanced error message
  1030	    const enhancedError = new Error(
  1031	      `Failed to load hasMany relationship '${includeName}' for scope '${scopeName}': ${error.message}`
  1032	    )
  1033	    enhancedError.originalError = error
  1034	    enhancedError.context = { scopeName, includeName, hasThrough: !!relDef.through }
  1035	    throw enhancedError
  1036	  }
  1037	}
  1038	
  1039	/**
  1040	 * Loads hasOne relationships (one-to-one)
  1041	 *
  1042	 * Similar to hasMany but expects a single related record.
  1043	 * For example, a user that has one profile.
  1044	 *
  1045	 * @param {Object} scope - The hooked-api scope object
  1046	 * @param {Object} deps - Dependencies object
  1047	 */
  1048	export const loadHasOne = async (scope, deps) => {
  1049	  const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
  1050	  const { context: { scopes, log, knex, capabilities } } = deps
  1051	
  1052	  log = log?.child ? log.child({ method: 'loadHasOne' }) : console
  1053	
  1054	  if (!records || records.length === 0) {
  1055	    log.trace('[INCLUDE] No records to load hasOne for')
  1056	    return
  1057	  }
  1058	
  1059	  // Get parent IDs
  1060	  const idProperty = scopes[scopeName]?.vars?.schemaInfo?.idProperty || 'id'
  1061	  const mainIds = [...new Set(records.map(r => r[idProperty]).filter(Boolean))]
  1062	
  1063	  if (mainIds.length === 0) {
  1064	    log.trace('[INCLUDE] No parent IDs found, skipping hasOne load')
  1065	    return
  1066	  }
  1067	
  1068	  const targetScope = relDef.target
  1069	  const targetTable = scopes[targetScope].vars.schemaInfo.tableName
  1070	  const foreignKey = relDef.foreignKey
  1071	
  1072	  if (!foreignKey) {
  1073	    throw new Error(`Missing foreignKey in hasOne relationship '${includeName}' for scope '${scopeName}'`)
  1074	  }
  1075	
  1076	  log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
  1077	    whereIn: mainIds
  1078	  })
  1079	
  1080	  // Build query for hasOne - expects single result per parent
  1081	  const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
  1082	  const targetScopeObject = scopes[targetScope]
  1083	  const storageAdapter = targetScopeObject.vars.storageAdapter
  1084	  const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
  1085	
  1086	  // Build field selection for sparse fieldsets
  1087	  const fieldSelectionInfo = fields?.[targetScope]
  1088	    ? await buildFieldSelection(targetScopeObject, {
  1089	      context: {
  1090	        scopeName: targetScope,
  1091	        queryParams: { fields: { [targetScope]: fields[targetScope] } },
  1092	        schemaInfo: targetScopeObject.vars.schemaInfo,
  1093	        storageAdapter,
  1094	      }
  1095	    })
  1096	    : null
  1097	
  1098	  // Query related records
  1099	  let query = knex(targetTable).whereIn(foreignKey, mainIds)
  1100	
  1101	  if (fieldSelectionInfo?.fieldsToSelect) {
  1102	    const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
  1103	    query = query.select(selectFields)
  1104	  }
  1105	
  1106	  const relatedRecords = await query
  1107	
  1108	  // Create a map of parent ID to related record (should be one-to-one)
  1109	  const relatedByParentId = {}
  1110	  for (const related of relatedRecords) {
  1111	    const parentId = related[foreignKey]
  1112	    if (relatedByParentId[parentId]) {
  1113	      log.warn(`[INCLUDE] Multiple records found for hasOne relationship '${includeName}' with ${foreignKey}=${parentId}`)
  1114	    }
  1115	    relatedByParentId[parentId] = related
  1116	  }
  1117	
  1118	  // Process each parent record
  1119	  for (const record of records) {
  1120	    const recordId = record[idProperty]
  1121	    const relatedRecord = relatedByParentId[recordId]
  1122	
  1123	    if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
  1124	
  1125	    if (relatedRecord) {
  1126	      // Transform to JSON:API format
  1127	      const transformed = await toJsonApiRecord(
  1128	        targetScopeObject,
  1129	        {
  1130	          context: {
  1131	            record: relatedRecord,
  1132	            scopeName: targetScope,
  1133	            schemaInfo: targetScopeObject.vars.schemaInfo
  1134	          }
  1135	        }
  1136	      )
  1137	
  1138	      // Set single object (not array) for hasOne
  1139	      record[RELATIONSHIPS_KEY][includeName] = {
  1140	        data: { type: targetScope, id: String(relatedRecord[targetIdProperty]) }
  1141	      }
  1142	
  1143	      // Add to included if not already there
  1144	      const key = `${targetScope}:${relatedRecord[targetIdProperty]}`
  1145	      if (!included.has(key)) {
  1146	        included.set(key, transformed)
  1147	      }
  1148	
  1149	      // Process nested includes if any
  1150	      if (subIncludes && Object.keys(subIncludes).length > 0) {
  1151	        const newPath = `${currentPath}.${includeName}`
  1152	        if (!processedPaths.has(newPath)) {
  1153	          processedPaths.add(newPath)
  1154	          await processIncludes(
  1155	            { records: [relatedRecord], scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: newPath, fields },
  1156	            { context: { scopes, log, knex, capabilities } }
  1157	          )
  1158	        }
  1159	      }
  1160	    } else {
  1161	      // No related record found
  1162	      record[RELATIONSHIPS_KEY][includeName] = { data: null }
  1163	    }
  1164	  }
  1165	}
  1166	
  1167	/**
  1168	 * Loads polymorphic belongsTo relationships
  1169	 *
  1170	 * Handles relationships where a record can belong to different types of parent records.
  1171	 * For example, comments that can belong to either articles or videos.
  1172	 *
  1173	 * @param {Object} scope - The hooked-api scope object containing:
  1174	 *   - records: Array<Object> - Records with polymorphic relationships
  1175	 *   - relName: string - The relationship name
  1176	 *   - relDef: Object - The relationship definition with belongsToPolymorphic
  1177	 *   - subIncludes: Object - Nested includes to process recursively
  1178	 *   - included: Map - Map of already included resources
  1179	 *   - processedPaths: Set - Set of already processed paths
  1180	 *   - currentPath: string - Current include path for tracking
  1181	 *   - fields: Object - Sparse fieldsets configuration
  1182	 * @param {Object} deps - Dependencies object containing:
  1183	 *   - context.scopes: Object - The hooked-api scopes object
  1184	 *   - context.log: Object - Logger instance
  1185	 *   - context.knex: Object - Knex instance
  1186	 * @returns {Promise<void>}
  1187	 */
  1188	export const loadPolymorphicBelongsTo = async (scope, deps) => {
  1189	  const { records, scopeName, relName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
  1190	  const { scopes, log, knex, capabilities } = deps.context
  1191	  try {
  1192	    log.trace('[INCLUDE] Loading polymorphic belongsTo:', {
  1193	      relName,
  1194	      recordCount: records.length
  1195	    })
  1196	
  1197	    const { typeField, idField, types } = relDef.belongsToPolymorphic
  1198	
  1199	    // Group records by their target type using helper
  1200	    const grouped = groupByPolymorphicType(
  1201	      records,
  1202	      typeField,
  1203	      idField
  1204	    )
  1205	
  1206	    log.trace('[INCLUDE] Grouped by type:', {
  1207	      types: Object.keys(grouped),
  1208	      counts: Object.entries(grouped).map(([t, ids]) => `${t}: ${ids.length}`)
  1209	    })
  1210	
  1211	    // Load each type separately
  1212	    for (const [targetType, targetIds] of Object.entries(grouped)) {
  1213	    // Skip if type not allowed (shouldn't happen with validation, but be safe)
  1214	      if (!types.includes(targetType)) {
  1215	        log.warn('[INCLUDE] Skipping non-allowed type:', targetType)
  1216	        continue
  1217	      }
  1218	
  1219	      if (targetIds.length === 0) continue
  1220	
  1221	      // Get target table information
  1222	      const targetSchemaInfo = scopes[targetType].vars.schemaInfo
  1223	      const targetTable = targetSchemaInfo?.tableName || targetType
  1224	
  1225	      // Build field selection for sparse fieldsets
  1226	      const targetScopeObject = scopes[targetType]
  1227	      // const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
  1228	      const storageAdapter = targetScopeObject.vars.storageAdapter
  1229	      const fieldSelectionInfo = fields?.[targetType]
  1230	        ? await buildFieldSelection(targetScopeObject, {
  1231	          context: {
  1232	            scopeName: targetType,
  1233	            queryParams: { fields: { [targetType]: fields[targetType] } },
  1234	            schemaInfo: targetScopeObject.vars.schemaInfo,
  1235	            storageAdapter,
  1236	          }
  1237	        })
  1238	        : null
  1239	
  1240	      log.debug(`[INCLUDE] Loading ${targetType} records:`, {
  1241	        ids: targetIds,
  1242	        fields: fieldSelectionInfo ? fieldSelectionInfo.fieldsToSelect : '*'
  1243	      })
  1244	
  1245	      // Query for this type
  1246	      const targetIdProperty = scopes[targetType]?.vars?.schemaInfo?.idProperty || 'id'
  1247	      let query = knex(targetTable).whereIn(targetIdProperty, targetIds)
  1248	      if (fieldSelectionInfo) {
  1249	        const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
  1250	        query = query.select(selectFields)
  1251	      } else if (targetIdProperty !== 'id') {
  1252	      // If no field selection but custom idProperty, we need to alias it
  1253	        query = query.select('*', `${targetIdProperty} as id`)
  1254	      }
  1255	      const targetRecords = await query
  1256	
  1257	      // Load relationship metadata for all target records
  1258	      await loadRelationshipMetadata(scopes, targetRecords, targetType)
  1259	
  1260	      // Create lookup map
  1261	      const targetById = {}
  1262	      targetRecords.forEach(record => {
  1263	        targetById[record.id] = record
  1264	      })
  1265	
  1266	      // Add to included and set relationships
  1267	      records.forEach(record => {
  1268	        if (record[typeField] === targetType) {
  1269	          const targetId = record[idField]
  1270	          const targetRecord = targetById[targetId]
  1271	
  1272	          if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
  1273	
  1274	          if (targetRecord) {
  1275	          // Add to included
  1276	            const resourceKey = `${targetType}:${targetId}`
  1277	            if (!included.has(resourceKey)) {
  1278	              const jsonApiRecord = toJsonApiRecord(
  1279	                scopes[targetType],
  1280	                targetRecord,
  1281	                targetType
  1282	              )
  1283	
  1284	              // Add relationships from metadata
  1285	              if (targetRecord[RELATIONSHIP_METADATA_KEY]) {
  1286	                jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY]
  1287	                // Clean up the temporary property
  1288	                delete targetRecord[RELATIONSHIP_METADATA_KEY]
  1289	              }
  1290	
  1291	              // Attach computed dependencies info if sparse fieldsets were used
  1292	              if (fieldSelectionInfo?.computedDependencies) {
  1293	                jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
  1294	              }
  1295	
  1296	              included.set(resourceKey, jsonApiRecord)
  1297	            }
  1298	
  1299	            const relationshipObject = {
  1300	              data: { type: targetType, id: String(targetId) }
  1301	            }
  1302	
  1303	            // Add links if urlPrefix is configured
  1304	            const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
  1305	            if (scopeName) {
  1306	              relationshipObject.links = {
  1307	                self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
  1308	                related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
  1309	              }
  1310	            }
  1311	
  1312	            record[RELATIONSHIPS_KEY][relName] = relationshipObject
  1313	          } else {
  1314	            const relationshipObject = { data: null }
  1315	
  1316	            // Add links if urlPrefix is configured
  1317	            const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
  1318	            if (scopeName) {
  1319	              relationshipObject.links = {
  1320	                self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
  1321	                related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
  1322	              }
  1323	            }
  1324	
  1325	            record[RELATIONSHIPS_KEY][relName] = relationshipObject
  1326	          }
  1327	        }
  1328	      })
  1329	
  1330	      // Process nested includes for this type
  1331	      if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
  1332	        const nextPath = `${currentPath}.${relName}`
  1333	        if (!processedPaths.has(nextPath)) {
  1334	          await processIncludes(
  1335	            { records: targetRecords, scopeName: targetType, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
  1336	            { context: { scopes, log, knex, capabilities } }
  1337	          )
  1338	        }
  1339	      }
  1340	    }
  1341	
  1342	    // Set null for records without relationships
  1343	    records.forEach(record => {
  1344	      if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
  1345	      if (!record[RELATIONSHIPS_KEY][relName]) {
  1346	        const relationshipObject = { data: null }
  1347	
  1348	        // Add links if urlPrefix is configured
  1349	        const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
  1350	        if (scopeName) {
  1351	          relationshipObject.links = {
  1352	            self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
  1353	            related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
  1354	          }
  1355	        }
  1356	
  1357	        record[RELATIONSHIPS_KEY][relName] = relationshipObject
  1358	      }
  1359	    })
  1360	  } catch (error) {
  1361	    // Log error with detailed context
  1362	    log.error('[INCLUDE] Error loading polymorphic belongsTo relationship:', {
  1363	      relName,
  1364	      recordCount: records?.length || 0,
  1365	      types: relDef?.belongsToPolymorphic?.types,
  1366	      error: error.message,
  1367	      stack: error.stack
  1368	    })
  1369	
  1370	    // Re-throw with enhanced error message
  1371	    const enhancedError = new Error(
  1372	      `Failed to load polymorphic belongsTo relationship '${relName}': ${error.message}`
  1373	    )
  1374	    enhancedError.originalError = error
  1375	    enhancedError.context = { relName, types: relDef?.belongsToPolymorphic?.types }
  1376	    throw enhancedError
  1377	  }
  1378	}
  1379	
  1380	/**
  1381	 * Loads reverse polymorphic relationships (via)
  1382	 *
  1383	 * Handles loading "child" records that have a polymorphic relationship back to the parent.
  1384	 * For example, loading all comments (which can belong to articles or videos) for a specific article.
  1385	 *
  1386	 * @param {Object} scope - The hooked-api scope object containing:
  1387	 *   - records: Array<Object> - Parent records
  1388	 *   - scopeName: string - The parent scope name
  1389	 *   - includeName: string - The relationship name
  1390	 *   - relDef: Object - The relationship definition with 'via' property
  1391	 *   - subIncludes: Object - Nested includes to process recursively
  1392	 *   - included: Map - Map of already included resources
  1393	 *   - processedPaths: Set - Set of already processed paths
  1394	 *   - currentPath: string - Current include path for tracking
  1395	 *   - fields: Object - Sparse fieldsets configuration
  1396	 * @param {Object} deps - Dependencies object containing:
  1397	 *   - context.scopes: Object - The hooked-api scopes object
  1398	 *   - context.log: Object - Logger instance
  1399	 *   - context.knex: Object - Knex instance
  1400	 * @returns {Promise<void>}
  1401	 */
  1402	export const loadReversePolymorphic = async (scope, deps) => {
  1403	  const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
  1404	  const { scopes, log, knex, capabilities } = deps.context
  1405	  try {
  1406	    log.trace('[INCLUDE] Loading reverse polymorphic (via):', {
  1407	      scopeName,
  1408	      includeName,
  1409	      via: relDef.via,
  1410	      recordCount: records.length
  1411	    })
  1412	
  1413	    const targetScope = relDef.target
  1414	    const viaRelName = relDef.via
  1415	
  1416	    // Get the polymorphic field info from target scope
  1417	    const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships
  1418	    const viaRel = targetRelationships?.[viaRelName]
  1419	
  1420	    if (!viaRel?.belongsToPolymorphic) {
  1421	      log.warn('[INCLUDE] Via relationship not found or not polymorphic:', {
  1422	        targetScope,
  1423	        viaRelName
  1424	      })
  1425	      return
  1426	    }
  1427	
  1428	    const { typeField, idField } = viaRel.belongsToPolymorphic
  1429	    const targetTable = scopes[targetScope].vars.schemaInfo.tableName
  1430	
  1431	    // Collect parent IDs
  1432	    const parentIds = records.map(r => r.id).filter(Boolean)
  1433	    if (parentIds.length === 0) return
  1434	
  1435	    log.debug(`[INCLUDE] Loading ${targetScope} records via ${viaRelName}:`, {
  1436	      typeField,
  1437	      idField,
  1438	      scopeName,
  1439	      parentIds
  1440	    })
  1441	
  1442	    // Build field selection for sparse fieldsets
  1443	    const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
  1444	    const targetScopeObject = scopes[targetScope]
  1445	    const storageAdapter = targetScopeObject.vars.storageAdapter
  1446	    const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
  1447	    const fieldSelectionInfo = fields?.[targetScope]
  1448	      ? await buildFieldSelection(targetScopeObject, {
  1449	        context: {
  1450	          scopeName: targetScope,
  1451	          queryParams: { fields: { [targetScope]: fields[targetScope] } },
  1452	          schemaInfo: targetScopeObject.vars.schemaInfo,
  1453	          storageAdapter,
  1454	        }
  1455	      })
  1456	      : null
  1457	
  1458	    // Query for records pointing back to our scope
  1459	    let query = knex(targetTable)
  1460	      .where(typeField, scopeName)
  1461	      .whereIn(idField, parentIds)
  1462	
  1463	    if (fieldSelectionInfo) {
  1464	      const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
  1465	      query = query.select(selectFields)
  1466	    } else if (targetIdProperty !== 'id') {
  1467	    // If no field selection but custom idProperty, we need to alias it
  1468	      query = query.select('*', `${targetIdProperty} as id`)
  1469	    }
  1470	
  1471	    // Apply include configuration (limits, ordering, etc.)
  1472	    const targetVars = scopes[targetScope].vars
  1473	    query = applyStandardIncludeConfig(
  1474	      query,
  1475	      relDef.include || {},
  1476	      targetVars,
  1477	      log
  1478	    )
  1479	
  1480	    const targetRecords = await query
  1481	
  1482	    log.trace('[INCLUDE] Loaded reverse polymorphic records:', {
  1483	      count: targetRecords.length
  1484	    })
  1485	
  1486	    // Load relationship metadata for all target records
  1487	    await loadRelationshipMetadata(scopes, targetRecords, targetScope)
  1488	
  1489	    // Group by parent ID
  1490	    const childrenByParent = {}
  1491	    targetRecords.forEach(record => {
  1492	      const parentId = record[idField]
  1493	      if (!childrenByParent[parentId]) {
  1494	        childrenByParent[parentId] = []
  1495	      }
  1496	      childrenByParent[parentId].push(record)
  1497	    })
  1498	
  1499	    // Set relationships on parent records
  1500	    records.forEach(record => {
  1501	      if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
  1502	
  1503	      const children = childrenByParent[record.id] || []
  1504	      const relData = children.map(childRecord => {
  1505	      // Add to included
  1506	        const resourceKey = `${targetScope}:${childRecord.id}`
  1507	        if (!included.has(resourceKey)) {
  1508	          const jsonApiRecord = toJsonApiRecord(
  1509	            scopes[targetScope],
  1510	            childRecord,
  1511	            targetScope
  1512	          )
  1513	
  1514	          // Add relationships from metadata
  1515	          if (childRecord[RELATIONSHIP_METADATA_KEY]) {
  1516	            jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
  1517	            // Clean up the temporary property
  1518	            delete childRecord[RELATIONSHIP_METADATA_KEY]
  1519	          }
  1520	
  1521	          // Attach computed dependencies info if sparse fieldsets were used
  1522	          if (fieldSelectionInfo?.computedDependencies) {
  1523	            jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
  1524	          }
  1525	
  1526	          included.set(resourceKey, jsonApiRecord)
  1527	        }
  1528	        return { type: targetScope, id: String(childRecord.id) }
  1529	      })
  1530	
  1531	      const relationshipObject = { data: relData }
  1532	
  1533	      // Add links if urlPrefix is configured
  1534	      const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
  1535	      if (scopeName) {
  1536	        relationshipObject.links = {
  1537	          self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
  1538	          related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
  1539	        }
  1540	      }
  1541	
  1542	      record[RELATIONSHIPS_KEY][includeName] = relationshipObject
  1543	    })
  1544	
  1545	    // Process nested includes
  1546	    if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
  1547	      const nextPath = `${currentPath}.${includeName}`
  1548	      if (!processedPaths.has(nextPath)) {
  1549	        await processIncludes(
  1550	          { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
  1551	          { context: { scopes, log, knex } }
  1552	        )
  1553	      }
  1554	    }
  1555	  } catch (error) {
  1556	    // Log error with detailed context
  1557	    log.error('[INCLUDE] Error loading reverse polymorphic relationship:', {
  1558	      scopeName,
  1559	      includeName,
  1560	      via: relDef?.via,
  1561	      targetScope: relDef?.target,
  1562	      recordCount: records?.length || 0,
  1563	      error: error.message,
  1564	      stack: error.stack
  1565	    })
  1566	
  1567	    // Re-throw with enhanced error message
  1568	    const enhancedError = new Error(
  1569	      `Failed to load reverse polymorphic relationship '${includeName}' via '${relDef?.via}' for scope '${scopeName}': ${error.message}`
  1570	    )
  1571	    enhancedError.originalError = error
  1572	    enhancedError.context = { scopeName, includeName, via: relDef?.via, targetScope: relDef?.target }
  1573	    throw enhancedError
  1574	  }
  1575	}
  1576	
  1577	/**
  1578	 * Processes includes for a set of records
  1579	 *
  1580	 * This is the main recursive function that processes the include tree. It examines
  1581	 * the schema to determine relationship types and calls the appropriate loader function.
  1582	 *
  1583	 * @param {Object} scope - The hooked-api scope object containing:
  1584	 *   - records: Array<Object> - Records to process includes for
  1585	 *   - scopeName: string - The scope name of the records
  1586	 *   - includeTree: Object - Parsed include tree from parseIncludeTree
  1587	 *   - included: Map - Map storing all included resources
  1588	 *   - processedPaths: Set - Set tracking processed paths to prevent cycles
  1589	 *   - currentPath: string - Current path in the include tree (default '')
  1590	 *   - fields: Object - Sparse fieldsets configuration (default {})
  1591	 *   - idProperty: string - The ID property name
  1592	 * @param {Object} deps - Dependencies object containing:
  1593	 *   - context.scopes: Object - The hooked-api scopes object
  1594	 *   - context.log: Object - Logger instance
  1595	 *   - context.knex: Object - Knex instance
  1596	 * @returns {Promise<void>}
  1597	 */
  1598	export const processIncludes = async (scope, deps) => {
  1599	  const { records, scopeName, includeTree, included, processedPaths, currentPath = '', fields = {}, idProperty } = scope
  1600	  const { scopes, log, knex, capabilities } = deps.context
  1601	  try {
  1602	    log.trace('[INCLUDE] Processing includes:', {
  1603	      scopeName,
  1604	      includes: Object.keys(includeTree),
  1605	      recordCount: records.length,
  1606	      currentPath
  1607	    })
  1608	
  1609	    if (records.length === 0) return
  1610	
  1611	    // Get schema info for this scope
  1612	    const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
  1613	    if (!schemaInfo) {
  1614	      log.warn('[INCLUDE] No schema info for scope:', scopeName)
  1615	      return
  1616	    }
  1617	
  1618	    const { schemaInstance, schemaRelationships } = schemaInfo
  1619	
  1620	    // Process each include
  1621	    for (const [includeName, subIncludes] of Object.entries(includeTree)) {
  1622	      const fullPath = currentPath ? `${currentPath}.${includeName}` : includeName
  1623	
  1624	      // Skip if already processed (prevents infinite loops)
  1625	      if (processedPaths.has(fullPath)) {
  1626	        log.trace('[INCLUDE] Skipping already processed path:', fullPath)
  1627	        continue
  1628	      }
  1629	      processedPaths.add(fullPath)
  1630	
  1631	      // Check if it's a schema field (belongsTo)
  1632	      let handled = false
  1633	
  1634	      try {
  1635	        // Look for belongsTo relationships in schema fields
  1636	        for (const [fieldName, fieldDef] of Object.entries(schemaInstance.structure || {})) {
  1637	          if (fieldDef.as === includeName && fieldDef.belongsTo) {
  1638	            await loadBelongsTo(
  1639	              { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty },
  1640	              { context: { scopes, log, knex, capabilities } }
  1641	            )
  1642	            handled = true
  1643	            break
  1644	          }
  1645	        }
  1646	
  1647	        // Check relationships
  1648	        if (!handled && schemaRelationships) {
  1649	          const relDef = schemaRelationships[includeName]
  1650	
  1651	          if (relDef) {
  1652	            if (relDef.type === 'hasOne') {
  1653	              // Handle hasOne relationship
  1654	              log.debug('[INCLUDE] About to call loadHasOne:', {
  1655	                scopeName,
  1656	                includeName,
  1657	                target: relDef.target
  1658	              })
  1659	              await loadHasOne(
  1660	                { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
  1661	                { context: { scopes, log, knex, capabilities } }
  1662	              )
  1663	              handled = true
  1664	            } else if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
  1665	              // Check if it's a reverse polymorphic (via)
  1666	              if (relDef.via) {
  1667	                await loadReversePolymorphic(
  1668	                  { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
  1669	                  { context: { scopes, log, knex, capabilities } }
  1670	                )
  1671	              } else {
  1672	                log.debug('[INCLUDE] About to call loadHasMany:', {
  1673	                  scopeName,
  1674	                  includeName,
  1675	                  relDefKeys: Object.keys(relDef || {}),
  1676	                  hasManyToMany: relDef.type === 'manyToMany',
  1677	                  hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
  1678	                })
  1679	                await loadHasMany(
  1680	                  { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
  1681	                  { context: { scopes, log, knex, capabilities } }
  1682	                )
  1683	              }
  1684	              handled = true
  1685	            } else if (relDef.belongsToPolymorphic) {
  1686	              await loadPolymorphicBelongsTo(
  1687	                { records, scopeName, relName: includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
  1688	                { context: { scopes, log, knex, capabilities } }
  1689	              )
  1690	              handled = true
  1691	            }
  1692	          }
  1693	        }
  1694	      } catch (includeError) {
  1695	        // Log specific include error and continue with other includes
  1696	        log.error('[INCLUDE] Error processing include:', {
  1697	          scopeName,
  1698	          includeName,
  1699	          fullPath,
  1700	          error: includeError.message
  1701	        })
  1702	        // Re-throw to maintain existing behavior
  1703	        throw includeError
  1704	      }
  1705	
  1706	      if (!handled) {
  1707	        log.warn('[INCLUDE] Unknown relationship:', {
  1708	          scopeName,
  1709	          includeName,
  1710	          availableFields: Object.keys(schemaInstance.structure || {}).filter(k => schemaInstance.structure[k].as),
  1711	          availableRelationships: Object.keys(schemaRelationships || {})
  1712	        })
  1713	      }
  1714	    }
  1715	  } catch (error) {
  1716	    // Log error with full context
  1717	    log.error('[INCLUDE] Error in processIncludes:', {
  1718	      scopeName,
  1719	      currentPath,
  1720	      includeTree: Object.keys(includeTree || {}),
  1721	      recordCount: records?.length || 0,
  1722	      error: error.message,
  1723	      stack: error.stack
  1724	    })
  1725	    throw error // Re-throw to maintain existing error propagation
  1726	  }
  1727	}
  1728	
  1729	/**
  1730	 * Main entry point for building included resources
  1731	 *
  1732	 * Takes a set of records and an include parameter, loads all requested relationships,
  1733	 * and returns both the included resources and the records with relationship data attached.
  1734	 *
  1735	 * @param {Object} scope - The hooked-api scope object containing:
  1736	 *   - records: Array<Object> - The main records to process
  1737	 *   - scopeName: string - The scope name of the main resources
  1738	 *   - includeParam: string - The include parameter value (e.g., "author,comments.author")
  1739	 *   - fields: Object - Sparse fieldsets configuration
  1740	 *   - idProperty: string - The ID property name
  1741	 * @param {Object} deps - Dependencies object containing:
  1742	 *   - context.scopes: Object - The hooked-api scopes object
  1743	 *   - context.log: Object - Logger instance
  1744	 *   - context.knex: Object - Knex instance
  1745	 * @returns {Promise<Object>} Object with included array and records with relationships
  1746	 *
  1747	 * @example
  1748	 * const result = await buildIncludedResources(
  1749	 *   {
  1750	 *     records: articleRecords,
  1751	 *     scopeName: 'articles',
  1752	 *     includeParam: 'author,comments.author',
  1753	 *     fields: { articles: 'title,body', people: 'name' },
  1754	 *     idProperty: 'id'
  1755	 *   },
  1756	 *   { context: { scopes, log, knex } }
  1757	 * );
  1758	 *
  1759	 * // result.included = [
  1760	 * //   { type: 'people', id: '1', attributes: {...} },
  1761	 * //   { type: 'comments', id: '1', attributes: {...} },
  1762	 * //   ...
  1763	 * // ]
  1764	 *
  1765	 * // result.recordsWithRelationships = original records with _relationships added
  1766	 */
  1767	export const buildIncludedResources = async (scope, deps) => {
  1768	  const { records, scopeName, includeParam, fields, idProperty } = scope
  1769	  const { scopes, log, knex, capabilities } = deps.context
  1770	  try {
  1771	    log.trace('[INCLUDE] Building included resources:', { scopeName, includeParam, recordCount: records.length })
  1772	
  1773	    // Check if includes are empty or records are empty
  1774	    if (!includeParam || records.length === 0) {
  1775	      log.trace('[INCLUDE] No includes requested or no records')
  1776	      return {
  1777	        included: [],
  1778	        recordsWithRelationships: records
  1779	      }
  1780	    }
  1781	
  1782	    // Handle both string and array formats
  1783	    if (Array.isArray(includeParam) && includeParam.length === 0) {
  1784	      log.trace('[INCLUDE] Empty include array, no relationships to load')
  1785	      return {
  1786	        included: [],
  1787	        recordsWithRelationships: records
  1788	      }
  1789	    }
  1790	
  1791	    // Parse the include parameter
  1792	    const includeTree = parseIncludeTree(includeParam)
  1793	
  1794	    log.debug('[INCLUDE] Parsed include tree:', includeTree)
  1795	
  1796	    // Use a Map to track included resources by type:id
  1797	    const included = new Map()
  1798	    const processedPaths = new Set() // Prevent infinite loops
  1799	
  1800	    // Process all includes
  1801	    await processIncludes(
  1802	      { records, scopeName, includeTree, included, processedPaths, currentPath: '', fields, idProperty },
  1803	      { context: { scopes, log, knex, capabilities } }
  1804	    )
  1805	
  1806	    // Convert Map to array for JSON:API format
  1807	    const includedArray = Array.from(included.values())
  1808	
  1809	    log.debug('[INCLUDE] Completed building includes:', {
  1810	      includedCount: includedArray.length,
  1811	      uniqueTypes: [...new Set(includedArray.map(r => r.type))]
  1812	    })
  1813	
  1814	    return {
  1815	      included: includedArray,
  1816	      recordsWithRelationships: records
  1817	    }
  1818	  } catch (error) {
  1819	    // Log comprehensive error information
  1820	    log.error('[INCLUDE] Failed to build included resources:', {
  1821	      scopeName,
  1822	      includeParam,
  1823	      recordCount: records?.length || 0,
  1824	      error: error.message,
  1825	      stack: error.stack
  1826	    })
  1827	
  1828	    // Re-throw with additional context
  1829	    const enhancedError = new Error(`Failed to build included resources for scope '${scopeName}': ${error.message}`)
  1830	    enhancedError.originalError = error
  1831	    enhancedError.context = {
  1832	      scopeName,
  1833	      includeParam,
  1834	      recordCount: records?.length || 0
  1835	    }
  1836	    throw enhancedError
  1837	  }
  1838	}
  1839	
  1840	/**
  1841	 * Loads relationship identifiers for all hasMany relationships without fetching full related records.
  1842	 *
  1843	 * ## Purpose
  1844	 * This function ensures that all hasMany relationships (one-to-many, many-to-many, and polymorphic)
  1845	 * always return resource identifiers in the JSON:API response, even when the related resources
  1846	 * are not included via the ?include parameter.
  1847	 *
  1848	 * ## Why this is needed
  1849	 * 1. **JSON:API Consistency**: The JSON:API spec allows servers to include relationship identifiers
  1850	 *    without including the full related resources. This provides a consistent API surface where
  1851	 *    clients always know what relationships exist and their IDs.
  1852	 *
  1853	 * 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into
  1854	 *    minimal objects (e.g., `reviews: [{id: '1'}, {id: '2'}, {id: '3'}]`). Without this function,
  1855	 *    these objects only appear when using ?include, creating an inconsistent API where fields appear/disappear.
  1856	 *
  1857	 * 3. **Performance Balance**: Loading just IDs is much cheaper than loading full records. This gives
  1858	 *    clients the ability to know what relationships exist without the cost of fetching all data.
  1859	 *
  1860	 * ## What it does
  1861	 * - Runs ONE query per relationship type (not per record) to fetch all related IDs
  1862	 * - Populates the relationship data with resource identifiers: `{ type: 'resource', id: '123' }`
  1863	 * - Handles all relationship types: one-to-many, many-to-many, and polymorphic
  1864	 * - Works for both JSON:API and simplified modes (transformation happens elsewhere)
  1865	 *
  1866	 * ## When it runs
  1867	 * This runs after the main records are fetched but before includes are processed.
  1868	 * If includes ARE specified, they will overwrite these IDs with full data.
  1869	 *
  1870	 * @param {Array<Object>} records - The parent records to load relationships for
  1871	 * @param {string} scopeName - The parent scope name (e.g., 'authors')
  1872	 * @param {Object} scopes - All available scopes with their schemas
  1873	 * @param {Object} knex - Knex instance for database queries
  1874	 * @returns {Promise<void>} Modifies records in place by adding relationship data
  1875	 */
  1876	export const loadRelationshipIdentifiers = async (records, scopeName, scopes, knex) => {
  1877	  if (!records.length) return
  1878	
  1879	  const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
  1880	  if (!schemaInfo) return
  1881	
  1882	  const relationships = schemaInfo.schemaRelationships || {}
  1883	  const recordIds = records.map(r => r.id)
  1884	
  1885	  // Process each hasMany relationship
  1886	  for (const [relName, relDef] of Object.entries(relationships)) {
  1887	    const idsMap = {}
  1888	
  1889	    if (relDef.type === 'hasMany' && !relDef.via) {
  1890	      // Regular one-to-many
  1891	      // Example: publisher hasMany authors
  1892	      const foreignKey = relDef.foreignKey
  1893	
  1894	      if (!foreignKey) {
  1895	        throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
  1896	      }
  1897	      const targetScope = scopes[relDef.target]
  1898	      const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.target
  1899	      const targetIdProperty = targetScope?.vars?.schemaInfo?.idProperty || 'id'
  1900	
  1901	      const results = await knex(targetTable)
  1902	        .whereIn(foreignKey, recordIds)
  1903	        .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', foreignKey)
  1904	
  1905	      results.forEach(row => {
  1906	        const parentId = String(row[foreignKey])
  1907	        if (!idsMap[parentId]) idsMap[parentId] = []
  1908	        idsMap[parentId].push(String(row.id))
  1909	      })
  1910	    } else if (relDef.type === 'manyToMany') {
  1911	      // Many-to-many relationship
  1912	      // Example: { type: 'manyToMany', through: 'article_tags', foreignKey: 'article_id', otherKey: 'tag_id' }
  1913	      const { through, foreignKey, otherKey } = relDef
  1914	      const fk = foreignKey
  1915	      const ok = otherKey
  1916	
  1917	      if (!fk || !ok) {
  1918	        throw new Error(`Missing foreignKey or otherKey in manyToMany relationship '${relName}' for scope '${scopeName}'`)
  1919	      }
  1920	
  1921	      // Get the actual table name from the pivot scope
  1922	      const pivotScope = scopes[through]
  1923	      const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || through
  1924	
  1925	      const results = await knex(pivotTable)
  1926	        .whereIn(fk, recordIds)
  1927	        .select(fk, ok)
  1928	
  1929	      results.forEach(row => {
  1930	        const parentId = String(row[fk])
  1931	        const childId = String(row[ok])
  1932	        if (!idsMap[parentId]) idsMap[parentId] = []
  1933	        idsMap[parentId].push(childId)
  1934	      })
  1935	    } else if (relDef.type === 'hasMany' && relDef.via) {
  1936	      // Polymorphic reverse (via)
  1937	      // Example: publishers hasMany reviews via reviewable (where reviews.reviewable_type = 'publishers')
  1938	      const targetScope = relDef.target
  1939	      const targetRelationships = scopes[targetScope]?.vars?.schemaInfo?.schemaRelationships
  1940	      const viaRel = targetRelationships?.[relDef.via]
  1941	
  1942	      if (viaRel?.belongsToPolymorphic) {
  1943	        const { typeField, idField } = viaRel.belongsToPolymorphic
  1944	
  1945	        // Get the actual table name
  1946	        const targetTable = scopes[targetScope]?.vars?.schemaInfo?.tableName || targetScope
  1947	        const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
  1948	
  1949	        const results = await knex(targetTable)
  1950	          .where(typeField, scopeName)
  1951	          .whereIn(idField, recordIds)
  1952	          .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', idField)
  1953	
  1954	        results.forEach(row => {
  1955	          const parentId = String(row[idField])
  1956	          if (!idsMap[parentId]) idsMap[parentId] = []
  1957	          idsMap[parentId].push(String(row.id))
  1958	        })
  1959	      }
  1960	    }
  1961	
  1962	    // Apply the collected IDs to all records
  1963	    if (Object.keys(idsMap).length > 0 || relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
  1964	      records.forEach(record => {
  1965	        if (!record[RELATIONSHIPS_KEY]) {
  1966	          record[RELATIONSHIPS_KEY] = {}
  1967	        }
  1968	
  1969	        const ids = idsMap[String(record.id)] || []
  1970	        // For manyToMany, the target type is the relationship name itself
  1971	        // For hasMany/hasOne, the target type is specified in the target property
  1972	        const targetType = (relDef.type === 'manyToMany') ? relName : relDef.target
  1973	
  1974	        record[RELATIONSHIPS_KEY][relName] = {
  1975	          data: ids.map(id => ({
  1976	            type: targetType,
  1977	            id
  1978	          }))
  1979	        }
  1980	      })
  1981	    }
  1982	  }
  1983	}
