     1	import { createSchema } from 'json-rest-schema'
     2	import { ensureSearchFieldsAreIndexed, generateSearchSchemaFromSchema, sortFieldsByDependencies } from './schema-helpers.js'
     3	
     4	/**
     5	 * Compiles and enriches schemas for a resource scope
     6	 *
     7	 * @param {Object} scope - Scope containing raw schema options
     8	 * @param {Object} deps - Dependencies with context and hooks
     9	 * @returns {Promise<void>} Resolves when compilation complete
    10	 *
    11	 * @example
    12	 * // Input: Raw schema with computed field
    13	 * const rawFields = {
    14	 *   title: { type: 'string', required: true },
    15	 *   content: { type: 'string' },
    16	 *   author_id: { belongsTo: 'users', as: 'author' },  // Missing type
    17	 *   word_count: {
    18	 *     type: 'number',
    19	 *     computed: true,
    20	 *     compute: (record) => record.content.split(' ').length
    21	 *   }
    22	 * };
    23	 *
    24	 * await compileSchemas(scope, deps);
    25	 *
    26	 * // Output in scope.vars.schemaInfo:
    27	 * // {
    28	 * //   schema: Schema {},           // json-rest-schema instance
    29	 * //   schemaStructure: {
    30	 * //     title: { type: 'string', required: true },
    31	 * //     content: { type: 'string' },
    32	 * //     author_id: { type: 'id', belongsTo: 'users', as: 'author' }  // Type added!
    33	 * //     // Note: word_count removed (it's computed)
    34	 * //   },
    35	 * //   computed: {
    36	 * //     word_count: { type: 'number', computed: true, compute: [Function] }
    37	 * //   }
    38	 * // }
    39	 *
    40	 * @example
    41	 * // Input: Schema with search fields
    42	 * const rawFields = {
    43	 *   title: { type: 'string', search: true },    // Searchable
    44	 *   status: { type: 'string' },                 // Not searchable
    45	 *   author_id: { belongsTo: 'users', as: 'author' }
    46	 * };
    47	 *
    48	 * // Output: Auto-generated searchSchema
    49	 * // searchSchemaObject contains:
    50	 * // {
    51	 * //   title: {
    52	 * //     type: 'string',
    53	 * //     indexed: true,        // Added for DB optimization
    54	 * //     filterOperator: '='   // Default operator
    55	 * //   }
    56	 * // }
    57	 *
    58	 * @example
    59	 * // Input: Pivot table auto-detection
    60	 * const pivotSchema = {
    61	 *   article_id: { belongsTo: 'articles', as: 'article' },
    62	 *   tag_id: { belongsTo: 'tags', as: 'tag' },
    63	 *   display_order: { type: 'number' }
    64	 * };
    65	 *
    66	 * // Output: Both foreign keys become searchable
    67	 * // schemaStructure will have:
    68	 * // {
    69	 * //   article_id: { type: 'id', belongsTo: 'articles', as: 'article', search: true },
    70	 * //   tag_id: { type: 'id', belongsTo: 'tags', as: 'tag', search: true },
    71	 * //   display_order: { type: 'number' }
    72	 * // }
    73	 * // This enables efficient many-to-many filtering!
    74	 *
    75	 * @example
    76	 * // Input: Schema with dependent getters
    77	 * const schema = {
    78	 *   first_name: { type: 'string' },
    79	 *   last_name: { type: 'string' },
    80	 *   full_name: {
    81	 *     type: 'string',
    82	 *     getter: (record) => `${record.first_name} ${record.last_name}`,
    83	 *     runGetterAfter: ['first_name', 'last_name']  // Dependencies
    84	 *   },
    85	 *   email: {
    86	 *     type: 'string',
    87	 *     setter: (value) => value.toLowerCase().trim()
    88	 *   }
    89	 * };
    90	 *
    91	 * // Output includes dependency-sorted fields:
    92	 * // {
    93	 * //   fieldGetters: { full_name: { getter: [Function], runGetterAfter: [...] } },
    94	 * //   sortedGetterFields: ['full_name'],    // Topologically sorted
    95	 * //   fieldSetters: { email: { setter: [Function], runSetterAfter: [] } },
    96	 * //   sortedSetterFields: ['email']
    97	 * // }
    98	 *
    99	 * @description
   100	 * Used by:
   101	 * - Every REST method calls this before any operations
   102	 * - Compilation happens once per scope (cached)
   103	 *
   104	 * Purpose:
   105	 * - Separates computed fields from database fields
   106	 * - Adds missing types to belongsTo relationships
   107	 * - Auto-detects pivot tables for many-to-many
   108	 * - Generates search schemas from field markers
   109	 * - Validates getter/setter dependencies
   110	 * - Provides hooks for schema enrichment
   111	 * - Caches everything for performance
   112	 *
   113	 * Data flow:
   114	 * 1. Extracts computed fields to separate object
   115	 * 2. Adds type:'id' to belongsTo fields
   116	 * 3. Detects pivot tables (2+ foreign keys, 40%+ of fields)
   117	 * 4. Runs schema:enrich hook for plugins
   118	 * 5. Creates json-rest-schema validation instance
   119	 * 6. Generates searchSchema from search:true fields
   120	 * 7. Runs searchSchema:enrich hook
   121	 * 8. Extracts and topologically sorts getters/setters
   122	 * 9. Caches all results in scope.vars.schemaInfo
   123	 */
   124	export async function compileSchemas (scope, deps) {
   125	  // Extract scopeName from context
   126	  const { context, runHooks } = deps
   127	  const scopeName = context.scopeName
   128	
   129	  // Get raw schema
   130	  const rawFields = scope.scopeOptions?.schema || {}
   131	
   132	  // Extract computed fields from schema and build enriched schema
   133	  const computedFields = {}
   134	  const enrichedFields = {}
   135	
   136	  for (const [fieldName, fieldDef] of Object.entries(rawFields)) {
   137	    if (fieldDef.computed === true) {
   138	      // Extract computed field - copy entire definition
   139	      computedFields[fieldName] = { ...fieldDef }
   140	
   141	      // Validate computed field
   142	      if (!fieldDef.type) {
   143	        throw new Error(`Computed field '${fieldName}' in scope '${scopeName}' must have a type`)
   144	      }
   145	
   146	      if (fieldDef.compute && typeof fieldDef.compute !== 'function') {
   147	        throw new Error(`Computed field '${fieldName}' in scope '${scopeName}' has invalid compute function`)
   148	      }
   149	
   150	      // Don't include computed fields in the validation schema
   151	    } else {
   152	      enrichedFields[fieldName] = { ...fieldDef }
   153	    }
   154	  }
   155	
   156	  // Default enrichment: Add type to belongsTo fields
   157	  for (const [fieldName, fieldDef] of Object.entries(enrichedFields)) {
   158	    if (fieldDef.belongsTo && !fieldDef.type) {
   159	      fieldDef.type = 'id'
   160	    }
   161	  }
   162	
   163	  // ADD LOGIC TO MAKE FIELDS SEARCHABLE HERE
   164	  // Auto-detect pivot tables and make their foreign key fields searchable
   165	  // This is critical for many-to-many relationship operations which need to filter pivot records
   166	  const fields = Object.entries(enrichedFields)
   167	  const belongsToFields = fields.filter(([_, def]) => def.belongsTo)
   168	
   169	  // If 2+ belongsTo fields and they make up 40%+ of non-id fields, likely a pivot table
   170	  const nonIdFields = fields.filter(([name, _]) => name !== 'id')
   171	  const isProbablyPivot = belongsToFields.length >= 2 &&
   172	    (nonIdFields.length === 0 || belongsToFields.length / nonIdFields.length >= 0.4)
   173	
   174	  if (isProbablyPivot) {
   175	    // Make all belongsTo fields searchable for pivot operations
   176	    for (const [fieldName, fieldDef] of belongsToFields) {
   177	      if (!fieldDef.search) {
   178	        fieldDef.search = true
   179	      }
   180	    }
   181	  }
   182	
   183	  // Hook: schema:enrich
   184	  const schemaContext = {
   185	    fields: enrichedFields,    // Mutable
   186	    originalFields: rawFields,  // Read-only
   187	    scopeName,
   188	    scopeOptions: scope.scopeOptions || {}
   189	  }
   190	  await runHooks('schema:enrich', schemaContext)
   191	
   192	  // Create schema object
   193	  const schemaObject = createSchema(schemaContext.fields)
   194	
   195	  // Generate searchSchema by merging explicit searchSchema with fields marked search:true.
   196	  // This allows two ways to define searchable fields: either mark fields with search:true
   197	  // in the main schema, or provide an explicit searchSchema with more control over filtering.
   198	  // The explicit searchSchema takes precedence when there are conflicts - it can override
   199	  // fields marked with search:true. Fields with search:true that are NOT in the explicit
   200	  // searchSchema will be added automatically.
   201	  // Example: title: {search: true} auto-generates a searchable field with sensible defaults,
   202	  // while searchSchema can specify filterOperator: 'contains' or complex join configurations.
   203	  const rawSearchFields = generateSearchSchemaFromSchema(
   204	    schemaContext.fields,
   205	    scope.scopeOptions.searchSchema
   206	  )
   207	
   208	  if (rawSearchFields) {
   209	    // Mark all search fields as indexed for database optimization.
   210	    // This ensures that any field used for filtering will have a database index created,
   211	    // dramatically improving query performance. Without indexes, filtering large tables
   212	    // would require full table scans. The storage plugin uses these hints to create indexes.
   213	    // Example: A status field marked for search gets indexed: true, enabling efficient
   214	    // WHERE status = 'published' queries that can use index lookups instead of scanning all rows.
   215	    ensureSearchFieldsAreIndexed(rawSearchFields)
   216	
   217	    // Hook: searchSchema:enrich
   218	    const searchSchemaContext = {
   219	      fields: schemaContext.fields,       // Mutable
   220	      originalFields: rawSearchFields,    // Read-only enriched schema
   221	      scopeName
   222	    }
   223	    await runHooks('searchSchema:enrich', searchSchemaContext)
   224	
   225	    // Create searchSchema object
   226	    var searchSchemaObject = createSchema(rawSearchFields)
   227	  } else {
   228	    var searchSchemaObject = createSchema({})
   229	  }
   230	
   231	  // Build schemaRelationships including polymorphic fields from schema
   232	  const schemaRelationships = { ...(scope.scopeOptions.relationships || {}) }
   233	
   234	  // Validate belongsTo fields
   235	  for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
   236	    // Validate that belongsTo fields have 'as' property
   237	    if (fieldDef.belongsTo && !fieldDef.as) {
   238	      throw new Error(
   239	        `Field '${fieldName}' in resource '${scopeName}' has belongsTo: '${fieldDef.belongsTo}' but is missing the required 'as' property. ` +
   240	        'The \'as\' property defines the relationship name used in JSON:API payloads. ' +
   241	        `Example: ${fieldName}: { type: 'number', belongsTo: '${fieldDef.belongsTo}', as: '${fieldName.replace(/_id$/, '')}' }`
   242	      )
   243	    }
   244	  }
   245	
   246	  // Extract and validate getter definitions
   247	  const fieldGetters = {}
   248	  const getterFields = []
   249	
   250	  for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
   251	    if (fieldDef.getter && typeof fieldDef.getter === 'function') {
   252	      fieldGetters[fieldName] = {
   253	        getter: fieldDef.getter,
   254	        runGetterAfter: fieldDef.runGetterAfter || [],
   255	        fieldDef
   256	      }
   257	      getterFields.push(fieldName)
   258	
   259	      // Validate dependencies exist
   260	      if (fieldDef.runGetterAfter && Array.isArray(fieldDef.runGetterAfter)) {
   261	        for (const dep of fieldDef.runGetterAfter) {
   262	          if (!schemaContext.fields[dep]) {
   263	            throw new Error(
   264	              `Field '${fieldName}' in resource '${scopeName}' has getter dependency '${dep}' that does not exist in schema`
   265	            )
   266	          }
   267	        }
   268	      }
   269	    }
   270	  }
   271	
   272	  // Sort getters by dependencies
   273	  let sortedGetterFields = []
   274	  if (Object.keys(fieldGetters).length > 0) {
   275	    try {
   276	      sortedGetterFields = sortFieldsByDependencies(fieldGetters, 'runGetterAfter')
   277	    } catch (error) {
   278	      throw new Error(`Invalid getter dependencies in ${scopeName}: ${error.message}`)
   279	    }
   280	  }
   281	
   282	  // Extract and validate setter definitions
   283	  const fieldSetters = {}
   284	  const setterFields = []
   285	
   286	  for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
   287	    if (fieldDef.setter && typeof fieldDef.setter === 'function') {
   288	      fieldSetters[fieldName] = {
   289	        setter: fieldDef.setter,
   290	        runSetterAfter: fieldDef.runSetterAfter || [],
   291	        fieldDef
   292	      }
   293	      setterFields.push(fieldName)
   294	
   295	      // Validate dependencies exist
   296	      if (fieldDef.runSetterAfter && Array.isArray(fieldDef.runSetterAfter)) {
   297	        for (const dep of fieldDef.runSetterAfter) {
   298	          if (!schemaContext.fields[dep]) {
   299	            throw new Error(
   300	              `Field '${fieldName}' in resource '${scopeName}' has setter dependency '${dep}' that does not exist in schema`
   301	            )
   302	          }
   303	        }
   304	      }
   305	    }
   306	  }
   307	
   308	  // Sort setters by dependencies
   309	  let sortedSetterFields = []
   310	  if (Object.keys(fieldSetters).length > 0) {
   311	    try {
   312	      sortedSetterFields = sortFieldsByDependencies(fieldSetters, 'runSetterAfter')
   313	    } catch (error) {
   314	      throw new Error(`Invalid setter dependencies in ${scopeName}: ${error.message}`)
   315	    }
   316	  }
   317	
   318	  // Cache everything
   319	  scope.vars.schemaInfo = {
   320	
   321	    schemaInstance: schemaObject,
   322	    schemaStructure: schemaObject.structure,
   323	
   324	    searchSchemaInstance: searchSchemaObject,
   325	    searchSchemaStructure: searchSchemaObject.structure,
   326	
   327	    computed: computedFields,
   328	    schemaRelationships,
   329	    tableName: scope.scopeOptions.tableName || scopeName,
   330	    idProperty: scope.scopeOptions.idProperty || scope.vars.idProperty,
   331	    fieldGetters,
   332	    sortedGetterFields,
   333	    fieldSetters,
   334	    sortedSetterFields
   335	  }
   336	}
