JustSearch.Typesense 0.6.1

dotnet add package JustSearch.Typesense --version 0.6.1
                    
NuGet\Install-Package JustSearch.Typesense -Version 0.6.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="JustSearch.Typesense" Version="0.6.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="JustSearch.Typesense" Version="0.6.1" />
                    
Directory.Packages.props
<PackageReference Include="JustSearch.Typesense" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add JustSearch.Typesense --version 0.6.1
                    
#r "nuget: JustSearch.Typesense, 0.6.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package JustSearch.Typesense@0.6.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=JustSearch.Typesense&version=0.6.1
                    
Install as a Cake Addin
#tool nuget:?package=JustSearch.Typesense&version=0.6.1
                    
Install as a Cake Tool

JustSearch

This library provides an easy integration to Algolia, TypeSense and MeiliSearch engines. Easy to switch between them or combine them for redundancy.

Usage

Add JustSearch and your data sources in your startup file.

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddJustSearch(new JustSearchOptions()
    {
        // Run indexing on app startup (optional)
        SyncOnStartup = true,
        
        // Prefix indexes/documents with environment name (optional)
        Prefix = env switch
        {
            not null when env.IsDevelopment() => "Dev_",
            not null when env.IsStaging() => "Test_",
            not null when env.IsProduction()  => "",
            _ => "Unk_"
        }
    })
    
    // Add your data source
    .AddSearchIndexDataProvider<ProductIndexDataProvider>()
    // You can add multiple source
    .AddSearchIndexDataProvider<CategoryIndexDataProvider>()
    
    // Add a provider
    .AddAlgoliaProvider("appId", "writeKey")
    
    // Or add another provider
    // (you can have multiple providers at the same time)
    .AddTypeSenseProvider(config =>
    {
        config.ApiKey = "API_KEY";
        config.Nodes = new List<Node>
        {
            new Node("localhost", "8108", "http")
        };
    })
    
    // Or MeiliSearch
    .AddMeiliSearchProvider("http://localhost:7700", "masterKey");

Configuring Data Source


// define your model
public record SearchableBrand : Searchable
{
    public string Url { get; init; }
    
    public string Name { get; init; }
    
    public string Logo { get; init; }
}

// implement ISearchIndexDataProvider
public class BrandIndexDataProvider : ISearchIndexDataProvider
{
    // This example uses EntityFrameworkCore, but you can use any data source
    private readonly AppDbContext db;
    
    public BrandIndexDataProvider(AppDbContext db)
    {
        this.db = db;
    }

    // Define your index name
    public string Name => "Brands";

    // Define your fields
    public async IAsyncEnumerable<ISearchField> GetFields()
    {
        yield return new SearchField("url", Type: SearchFieldType.String);
        yield return new SearchField("name", Type: SearchFieldType.String, IsSearchable: true);
        yield return new SearchField("logo", Type: SearchFieldType.String);
    }

    // You can optionally define synonyms, or return empty list
    public IAsyncEnumerable<ISynonym> GetSynonyms()
    {
        return AsyncEnumerable.Empty<ISynonym>();
    }
    
    // Retrive your data.
    // updatedSince is optional, but highly recommended for performance reasons.
    // if updatedSince is not null, only return items that have been updated since the given date.
    public IAsyncEnumerable<ISearchable> Get(DateTimeOffset? updatedSince = null)
    {
        return db.brands
            .OrderBy(a => a.id)
            .Where(a => a.enabled)
            .Where(a => updatedSince == null || a.updated_at > updatedSince || a.created_at > updatedSince)
            .Select(a => new SearchableBrand()
            {
                Id = a.id.ToString(), // Searchable comes with a required string Id field
                Url = a.url,
                Name = a.name,
                Logo = a.logo_url
            })
            .AsAsyncEnumerable();
    }
    
    // Retrive the id of the deleted items since the given date.
    // (you can leave it empty, if you manually delete items from trigger.)
    public IAsyncEnumerable<string> GetDeleted(DateTimeOffset since)
    {
        return db.brands
            
            // Gets disabled items since the given date
            .Where(a => !a.enabled)
            .Where(a => a.updated_at > since || a.created_at > since)
            
            .Select(a => a.id.ToString())
            .AsAsyncEnumerable()
            .Concat(
                // Gets deleted items since given date
                db.deleted_items
                    .Where(a => a.entity == "brands")
                    .Where(a => a.deleted_at > since)
                    .Select(a => a.entity_id.ToString())
                    .AsAsyncEnumerable()
            );
    }
}

The GetDeleted method might be confusing. Here is a sample implementation of deleted_items entity in EntityFrameworkCore with a library called Laraue.EfCoreTriggers.

public class brand
{
    [Key]
    public int id { get; set; }
    
    // ...
    
    public DateTimeOffset created_at { get; set; }
    
    // useful for data source
    public DateTimeOffset? updated_at { get; set; }
    
    // useful for soft deletes (optional if you use deleted_items table)
    // public DateTimeOffset? deleted_at { get; set; }
}

public class deleted_item
{
    public string entity { get; set; }
    
    public int entity_id { get; set; }
    
    public DateTimeOffset deleted_at { get; set; }
}

public class AppDbContext : DbContext
{
    public DbSet<brand> brands { get; set; }
    
    public DbSet<deleted_item> deleted_items { get; set; }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // ...
        
        modelBuilder.Entity<brand>(brand =>
        {
            // ...
            
            // This will create trigger for the DELETE operation in the database itself.
            brand.AfterDelete(op => op
                .Action(a => a
                    .Insert(e => new deleted_item
                    {
                        entity = "brands",
                        entity_id = e.Old.id
                    })));
        });
        
        modelBuilder.Entity<deleted_item>(deleted_item =>
        {
            deleted_item
                .HasKey(a => new {a.entity, a.entity_id});

            deleted_item
                .Property(a => a.deleted_at)
                .HasDefaultValueSql("now()");

            deleted_item
                .HasIndex(a => a.deleted_at)
                .IsDescending();
        });
    }
}

Triggers

Use triggers to keep your search indexes up-to-date.

public class BrandController : ControllerBase
{
    // inject the trigger on your controller or service
    private readonly ISearchIndexTrigger _searchIndexTrigger;
    
    // when an action is taken on brand, trigger the index update
    [HttpPost]
    [HttpPatch("{id}")]
    [HttpDelete("{id}")]
    public void DoStuffWithBrand(int id) {
        // ... (do database operation here)
        
        // Or update a specific index
        _searchIndexTrigger.Sync<BrandIndexDataProvider>();
        
        // Update all of the indexes.
        // Useful if you have multiple data sources that depend on each other.
        // (such as some products needs to be deleted from the index when its brand is deactivated)
        _searchIndexTrigger.SyncAll();
        
        // If you have implemented SearchIndexDataProvider correctly,
        // then all you need is the first two (SyncAll and Sync<T>)
        
        // If not, you can be more specific like:
        
        // Upsert specific item to the index
        _searchIndexTrigger.Upsert<BrandIndexDataProvider>(new SearchableBrand() {
            Id = id.ToString(),
            Name = "New Name",
            Url = "new-url",
            Logo = "new-logo"
        });
        
        // Upsert multiple items to the index
        _searchIndexTrigger.Upsert<BrandIndexDataProvider>(new List<SearchableBrand>() {
            new SearchableBrand() {
                Id = id.ToString(),
                Name = "New Name",
                Url = "new-url",
                Logo = "new-logo"
            },
            new SearchableBrand() {
                Id = (id + 1).ToString(),
                Name = "New Name 2",
                Url = "new-url-2",
                Logo = "new-logo-2"
            }
        });
        
        // Delete specific item from the index
        _searchIndexTrigger.Delete<BrandIndexDataProvider>(id.ToString());
        
        // Or delete multiple items from the index
        _searchIndexTrigger.Delete<BrandIndexDataProvider>(new List<string>() {
            id.ToString(),
            (id + 1).ToString()
        });
    }
}

The operations are queued in-memory and executed in the background service serially. If you need to ensure their success, you can use the WaitAll method returned from trigger actions.

    public void DoStuffWithBrand(int id) {
        // ...
        
        // Update all of the indexes and wait the operation to complete.
        await _searchIndexTrigger.SyncAll()
            .WaitAsync(CancellationToken.None);
        
        // This will work for rest of the operations as well.
        await _searchIndexTrigger.Delete<BrandIndexDataProvider>(id.ToString())
            .WaitAsync(CancellationToken.None);
    }

Lookups by ID (0.5.0)

Every provider supports ID equality and membership filters without declaring an id field. MeiliSearch configures id as filterable, Typesense supports its reserved id field natively, and Algolia stores both id and the native objectID, with id configured for filtering. Run a sync to apply updated index settings.

Use FindAsync on the injected ISearchQuery<T> to retrieve documents through the engine's document API:

var hotel = await hotels.FindAsync("123", cancellationToken); // null when missing
await foreach (var found in hotels.FindAsync(ids, cancellationToken))
{
    // Missing IDs are omitted. Result order is unspecified.
}

Lookups accept cancellation and propagate engine errors. They operate on the index itself: calling FindAsync on a composed query (search, filters, sorting, or pagination) throws NotSupportedException. Use LINQ filters to combine IDs with other conditions. The document API may return more fields than the search API, according to engine permissions and document retrieval rules.

Multi-ID lookups enumerate the input in bounded batches. MeiliSearch uses document fetch requests filtered by ID in batches of 500 (compatible with the current SDK). Algolia uses its native multi-object endpoint in batches of 500. Typesense has no bulk document GET endpoint, so it runs at most 16 individual requests at a time. Empty ID inputs return no documents. Duplicate IDs may produce duplicate results.

This release adds required FindAsync members to ISearchQuery<T> and ISearchQueryProvider; custom implementations must implement both overloads. Algolia now uses the configured index name consistently, without the previously hardcoded De suffix. Existing suffixed indexes are left in place; sync to populate the correctly named index before switching consumers, and retire old indexes when no longer needed.

Deletions in data streams

ISearchIndexDataProvider.Get() can yield SearchableDeletion alongside documents:

yield return new Hotel { Id = "123", Name = "Hotel" };
yield return new SearchableDeletion("123"); // removes it
yield return new Hotel { Id = "123", Name = "Reopened hotel" }; // restores it

All three providers submit every operation in stream order, including repeated IDs. Tombstones become document deletions and are never indexed as documents. MeiliSearch and Typesense split batches into consecutive runs of upserts or deletions; Algolia supports mixed operations within a batch. Data providers may deduplicate their own streams when appropriate.

Tombstones work in both initial and incremental syncs. On incremental syncs, the existing GetDeleted(since) pass runs afterward, so its deletions take precedence. The affected count includes every submitted upsert and deletion, including repeated IDs and deletions of missing documents.

Sync failure handling

Sync failures are logged per engine/data-provider pair. Remaining pairs are still attempted, and startup sync failures do not stop the background service. Subsequent trigger jobs continue to run independently. Syncs are not automatically retried.

A triggered job faults after all its pairs have been attempted. WaitAsync throws an AggregateException containing a SearchIndexSyncException for each failed pair. Each entry exposes EngineName, DataProviderName, and the original error as InnerException:

try
{
    await trigger.SyncAll().WaitAsync(cancellationToken);
}
catch (AggregateException errors)
{
    foreach (var failure in errors.InnerExceptions.OfType<SearchIndexSyncException>())
    {
        logger.LogError(failure.InnerException, "Sync failed: {Engine}/{Index}",
            failure.EngineName, failure.DataProviderName);
    }
}

Host shutdown cancels active and queued jobs. Canceling only a caller's WaitAsync wait leaves the queued job running. An engine or data source throwing an OperationCanceledException while the host is still running is reported as a sync failure, so other indexes can proceed.

Custom ranking

Declare popularity or another ranking metric on a field:

new SearchField("hotelCount", SearchFieldType.Int32, CustomRanking: Ranking.Desc)

CustomRanking is nullable and accepts Ranking.Asc or Ranking.Desc. It can also be set with an object initializer. Ranking fields are automatically treated as sortable. Multiple ranking fields break ties in declaration order, after text relevance. Explicit OrderBy/OrderByDescending takes precedence over custom ranking, which remains a final tiebreaker for otherwise equal results. Sorting a ranked field in the opposite direction uses the explicit direction.

Run a sync after changing ranking declarations. Removing a declaration clears the managed custom ranking on the next sync. Existing SearchField constructors and deconstruction remain available; existing ISearchField implementations default to no custom ranking.

Engine behavior and limits:

  • MeiliSearch: retains the six default ranking rules, including sort, and appends field:asc/field:desc. Its normal text relevance rules retain their position before explicit sorting. Scalar numeric and string ranking fields are supported.
  • Typesense: sends _text_match:desc before custom ranking fields. Explicit sorts appear before _text_match. The engine permits three sort criteria in total, so at most two custom ranking fields are supported, and combinations with explicit sorts that exceed the limit throw NotSupportedException. Scalar numeric, boolean, and string ranking fields are supported. A descending Int32/Float metric is also used as default_sorting_field when available; ascending and other field types use sort_by. Changes to the collection's sortable fields or default sorting field recreate its schema and require the data provider to supply a full dump.
  • Algolia: configures customRanking after the default relevance criteria. Scalar numeric and boolean ranking fields are supported; strings throw NotSupportedException. Sortable fields, including custom ranking fields, create ascending and descending standard replicas. Explicit queries select the corresponding replica, with explicit sorting first and custom ranking last. Replica settings are refreshed on sync. ThenBy is not supported and throws. Default queries use Search rather than Browse so ranking is honored. Ranked or explicitly sorted queries with Take above 1000 throw because Browse does not preserve ranking. Replica creation is subject to the Algolia account's limits.

Arrays, object fields, unknown field types, and invalid ranking directions fail explicitly during index setup. No provider silently drops an unsupported ranking.

When using ISearchQuery<T>, field metadata is supplied automatically from the registered ISearchIndexDataProvider<T>. Direct users of ISearchQueryProvider should supply SearchQueryContext.CustomRankings and SearchableFields themselves; Typesense needs these for its query-time ranking and text fields.

See the engine references for MeiliSearch ranking rules, Typesense sorting, and Algolia custom ranking.

Localized fields

Declare each text field's language with Locale:

new SearchField("nameEn", SearchFieldType.String, Locale: "en", IsSearchable: true)
new SearchField("nameNl", SearchFieldType.String, Locale: "nl", IsSearchable: true)
new SearchField("nameFr", SearchFieldType.String, Locale: "fr", IsSearchable: true)
new SearchField("nameAr", SearchFieldType.String, Locale: "ar", IsSearchable: true)

Typesense and Algolia trim and lowercase codes. Null, empty, or whitespace values use the engine's default behavior. Regional tags are not silently reduced to a different language: use a code that the selected engine supports. These providers reject locales on numeric, boolean, object, or unknown field types; string arrays are supported.

  • MeiliSearch: Locale is currently not sent to the engine because the .NET SDK does not expose localizedAttributes. The provider neither sets nor clears this setting. Automatic language detection, or language settings configured directly on the engine, remain in effect. Locale declarations do not require extra client registration and do not prevent synchronization.
  • Typesense: writes the locale into each field's schema and validates ISO 639-1 language codes. Its default locale is English. A changed locale, or removing a locale that differs from that default, recreates the collection schema and requests a full dump via Get(null). Equivalent casing/whitespace does not recreate the collection. Data providers must be able to supply a full dump.
  • Algolia: derives both indexLanguages and queryLanguages from the distinct declared locales, replacing the old hardcoded German/Turkish settings. This is applied on every sync, including sorting replicas; removing all locales clears both lists. Supported codes come from the Algolia SDK, including pt-br. These settings are index-wide: Algolia cannot select tokenization per field. A declared Chinese, Japanese, or Korean language is placed first for query segmentation. Multiple CJK languages in one index throw NotSupportedException; use separate indexes when each needs its own primary CJK tokenizer.

See MeiliSearch language settings, Typesense field locales, and Algolia index languages.

Full rebuilds

Use a rebuild when the current dump is authoritative and documents missing from it must disappear:

await trigger.Rebuild<HotelIndexDataProvider>().WaitAsync(cancellationToken);

To rebuild on startup, set both SyncOnStartup = true and RebuildOnStartup = true. RebuildOnStartup defaults to false. Normal Sync operations retain their incremental behavior, including when no checkpoint exists.

A rebuild reads Get(null) into a unique <publicName>_pending_<guid> index, including inline tombstones, and waits for indexing to succeed before publication. It does not call GetDeleted. The configured public name and prefix stay the same. An empty dump publishes an empty index. Successful rebuilds replace the checkpoint with the rebuild's start time, so subsequent incremental syncs can pick up changes made while the dump was being processed. MeiliSearch stores this under the public name in MetaCollections; Typesense and Algolia use their existing metadata mechanisms.

  • MeiliSearch: atomically swaps the completed index with the public index, then deletes the old index. A first rebuild creates an empty public placeholder before the swap because both names must exist.
  • Typesense: switches the public collection alias to the completed collection, then deletes the previous physical collection. For an existing physical collection with the public name, the initial alias operation fails with a name conflict. Only this specific error permits a one-time migration: delete the old collection and immediately create the alias, with a brief interruption between those operations. If alias creation then fails, the error identifies the complete replacement and the alias needed to recover. Other alias errors never trigger deletion of the live collection. Later rebuilds switch the alias directly. Schema changes on an alias-backed index also use a rebuild.
  • Algolia: moves the complete temporary index over the public index. Temporary indexes have no sorting replicas because Algolia cannot move an index with replicas. After publication, the provider refreshes the stable sorting replica settings without reading the dump again. This refresh can fail after the primary has already switched; the trigger reports that failure.

Failures before publication leave the live index intact and attempt temporary-index cleanup. If publication may have started, the provider keeps any remaining temporary index and logs its name, since a lost response can leave the outcome uncertain. Cleanup or checkpoint failures after publication fault the job even though the new index may already be live. Rebuilds need capacity for both old and new indexes. The background worker serializes local jobs; applications sharing indexes across multiple hosts must coordinate writers during rebuilds.

See Typesense alias-based reindexing, MeiliSearch index swaps, and Algolia's replacement implementation.

Large imports

Configure indexing batch sizes and asynchronous task windows through the shared options:

builder.Services.AddJustSearch(new JustSearchOptions
{
    BatchSize = 500,        // Default; increase for larger indexing payloads.
    MaxPendingTasks = 32   // Default; wait when this many engine tasks are queued.
});

Both values must be positive; zero and negative values throw ArgumentOutOfRangeException. They apply to initial syncs, incremental syncs, inline tombstones, the GetDeleted pass, and rebuilds. They do not change query or ID-lookup batching. BatchSize counts stream operations rather than bytes, so choose a size that fits the engine's payload limits and document sizes.

MeiliSearch sends NDJSON upserts and document-deletion batches. Algolia sends mixed operation batches. Each tracks at most MaxPendingTasks queued task IDs, waits for the window before submitting another batch, and waits for any final partial window before writing a checkpoint or publishing a rebuilt index. This bounds task tracking independently of the total dump size. Settings tasks also use bounded windows; single setup and publication tasks are awaited directly.

Typesense import/delete responses complete the operation synchronously, so it has no asynchronous engine task queue to bound. Imports use BatchSize; deletion filters use at most min(BatchSize, 24) IDs to keep request URLs short. Separate upsert and delete endpoints preserve stream order and can produce smaller batches when operations alternate.

Document batch submission or task-completion failures stop that index's sync and leave its checkpoint unchanged. Errors identify the engine, index, operation, and batch number, and include the task ID when available. The underlying error is retained as InnerException; the background service logs and wraps the failure as described above. Operations already accepted by an engine can still complete after a failure.

Direct users of provider constructors can pass a SearchIndexingOptions from JustSearch.Abstractions as the final constructor argument. Existing constructors continue to use the defaults. AddJustSearch registers its options for all three providers automatically.

Retrievable fields

MeiliSearch uses displayedAttributes = ["*"] when all declared fields have IsRetrievable = true (the default), including when no fields are declared. Search results then include stored properties that were not declared in GetFields(). Declaring fields remains necessary for search, filtering, sorting, and other field settings.

If any field is explicitly marked IsRetrievable = false, MeiliSearch uses an allowlist of id plus declared retrievable fields. Undeclared properties are also omitted in this case. Changing declarations takes effect on the next sync or rebuild; removing the last exclusion restores "*".

This controls search results rather than LINQ projections. Select projections are not currently supported, and direct FindAsync document lookups follow the engine's document API retrieval rules. Algolia continues to configure explicit unretrievableAttributes; Typesense currently does not apply IsRetrievable.

Total hit counts

ISearchQuery<T> exposes both async enumeration and CountAsync directly:

var page = hotels.Query("Paris")
    .Where(hotel => hotel.Country == "FR")
    .OrderBy(hotel => hotel.Name)
    .Skip(10)
    .Take(10);

long total = await page.CountAsync(cancellationToken);
await foreach (var hotel in page.WithCancellation(cancellationToken))
{
    // Process the requested page.
}

CountAsync returns the engine-reported total for the search and filters before Skip and Take, including when Take(0) is used. Sorting and custom ranking are omitted from the count request. This is a search total, rather than LINQ's count of a paginated sequence. The original query remains unchanged for streaming. Where, OrderBy, OrderByDescending, ThenBy, ThenByDescending, Skip(int), and Take(int) preserve ISearchQuery<T>, so its count and lookup methods remain accessible after composition. Lookup restrictions on composed queries still apply.

Each count call sends a separate search request with zero requested documents; it never enumerates the result stream or downloads matching documents to count them. Counting and enumerating the same query makes two requests, which can see different index states if documents change between them. Empty ID membership filters return zero without contacting the engine. Cancellation and engine errors propagate to the caller.

  • MeiliSearch: returns estimatedTotalHits using limit = 0.
  • Typesense: returns found using per_page = 0.
  • Algolia: returns nbHits from the primary index using hitsPerPage = 0; no sorting replica or Browse request is needed.

Totals follow the engine's matching behavior, settings, and count limits. They may be estimates; Task<long> does not expose an accuracy flag or guarantee an exhaustive count. See MeiliSearch search totals, Typesense search, and Algolia search pagination.

Custom ISearchQueryProvider implementations must implement the new CountAsync(string indexName, SearchQueryContext context, CancellationToken) member. Custom ISearchQuery<T> implementations must implement CountAsync and async enumeration; the interface now declares IAsyncEnumerable<T> explicitly.

Geo queries with Microsoft.Spatial

Geo queries use Microsoft.Spatial's existing LINQ expressions. JustSearch translates them into engine requests; it does not execute spatial calculations locally or register global SpatialOperations. The Abstractions package references Microsoft.Spatial 9.0.0 for net10.0 and 8.4.4 for earlier targets. Version 8.4.4 requires .NET 8 or later; the repository currently targets .NET 10.

Declare one GeographyPoint? property and one corresponding Geo field:

using Microsoft.Spatial;

public record Hotel(string Id, string Name, GeographyPoint? Location) : ISearchable;

// In GetFields():
yield return new SearchField("name", SearchFieldType.String, IsSearchable: true);
yield return new SearchField("location", SearchFieldType.Geo);

For an existing index, rebuild from a full dump to backfill native geo coordinates; an incremental sync only converts documents that the data source yields.

Geo fields automatically support filtering and distance sorting. Arrays, text search, facets, locales, and custom ranking on a geo field are rejected. An index and its document model support one geo point. Coordinates must be nonempty WGS84 (EPSG:4326), with finite latitude [-90,90] and longitude [-180,180]. Altitude and measure values are not used. JustSearch validates these restrictions even though GeographyPoint.Create permits some out-of-range values.

var center = GeographyPoint.Create(41.0369, 28.9850); // latitude, longitude
var nearby = hotels.Query("Hilton")
    .Where(h => h.Location != null && h.Location.Distance(center) <= 2_000)
    .OrderBy(h => h.Location.Distance(center))
    .Take(20);

long total = await nearby.CountAsync(cancellationToken);
await foreach (var hotel in nearby.WithCancellation(cancellationToken))
{
    // Location is restored as a GeographyPoint.
}

Distances and radii in translated queries are in metres. Radius predicates support Distance(center) <= radius, or the equivalent reversed comparison radius >= Distance(center). The constant point can also be the first operand. Strict comparisons, outer-radius comparisons, distances between two document properties, and other spatial operations such as Length throw NotSupportedException. Radii must be finite and nonnegative. Sorting recognizes Distance in OrderBy, OrderByDescending, ThenBy, and ThenByDescending, subject to the engine restrictions below. Radius and polygon predicates exclude documents without coordinates; redundant Location != null guards are removed from their AND groups. CountAsync applies geo filters and ignores pagination and sorting.

Use a closed polygon for map bounds or polygon filtering. Intersects returns bool?, so compare it to true:

var bounds = GeographyFactory.Polygon()
    .Ring(48.86, 2.34)
    .LineTo(48.86, 2.36)
    .LineTo(48.85, 2.36)
    .LineTo(48.85, 2.34)
    .LineTo(48.86, 2.34)
    .Build();

var pins = hotels.Where(h => h.Location.Intersects(bounds) == true);

Polygons require one closed exterior ring, at least three distinct vertices, and no holes. Polygons crossing the antimeridian are rejected explicitly in this implementation; query separate polygons on each side. Boolean AND/OR groups retain their structure, so grouped geo conditions are not silently widened.

Engine Filtering and ordering
MeiliSearch Maps the point to _geo, makes it filterable and sortable, and uses _geoRadius / _geoPoint. Rectangular polygons map to _geoBoundingBox; other polygons are rejected. AND, OR, and negated geo groups are supported.
Typesense Declares an optional, indexed, sortable geopoint and stores [latitude, longitude]. Radii are converted to kilometres for the native API. Polygons are normalized to counter-clockwise order. Distance sorts can combine with ordinary sorts within the existing three-sort limit. Negated groups and missing-geo filters are rejected by this provider.
Algolia Maps the point to _geoloc. Geo constraints use search parameters, so only one radius or polygon per query is supported, combined with ordinary filters using AND. Geo predicates inside OR/NOT, missing-geo filters, and fractional or greater-than-Int32 radii are rejected. Only a single ascending distance order is supported, with no other explicit sort clauses. Radius filtering and distance ordering must share their center. Polygon/box plus distance ordering is rejected because Algolia ignores aroundLatLng in that combination. Geo queries cannot use Browse (Take > 1000).

Algolia distance ordering without a radius explicitly uses aroundRadius = "all" and metre-level ranking precision. This considers located records; use Location != null when the count should also exclude unlocated records. Native ranking applies with text queries: MeiliSearch's default relevance rules precede its sort rule, whereas Algolia uses its configured geo ranking criterion. Distance ranking is therefore not identical across engines. Algolia radius filtering also activates its native geo ranking even without explicit ordering.

A null location omits the native coordinate field. Upserts, rebuilds, ordinary search hits, single-ID lookups, and bulk-ID lookups use the same coordinate mapping. Retrievable-field settings still apply: an excluded geo field is not restored in search results. The native _geo/_geoloc names are reserved when a Geo field is declared. Direct ISearchQueryProvider callers must set SearchQueryContext.GeoField to the declared field name for geo queries; the registered ISearchQuery<T> supplies it automatically.

These expressions require a JustSearch query provider. Moving them into in-memory LINQ invokes Microsoft.Spatial's methods, which require a separately registered operations implementation. This change provides filtering, ordering, and model round-tripping. Callers can calculate display distances from the returned coordinates.

See MeiliSearch geo search, Typesense geo search, and Algolia polygon constraints.

Development and package compatibility

All five NuGet packages include this README, XML documentation for IntelliSense, and portable symbols with Codeberg Source Link. Public and protected API members require XML documentation; missing or malformed comments fail library builds. Internal implementation members and test assemblies do not require annotations.

The projects target .NET 10. Test projects use xunit.v3 4.x with its built-in Microsoft.Testing.Platform v2 runner. global.json selects that runner for dotnet test; no separate VSTest adapter is required.

dotnet build JustSearch.slnx
dotnet test --project tests/JustSearch.Tests
# Requires a local MeiliSearch instance (test master key: masterKey).
JUSTSEARCH_TEST_MEILISEARCH_URL=http://localhost:7700 dotnet test --project providers/meilisearch/JustSearch.MeiliSearch.Tests
# Runs the Typesense HTTP/mock tests without a live server.
dotnet test --project providers/typesense/JustSearch.Typesense.Tests --filter-not-class '*TypeSenseProviderTests'

The Typesense 9.x client uses the server v30+ synonym sets API. Synonym synchronization replaces the complete <physical-collection>_synonyms_index set, reusing Typesense's automatic migration name, and attaches it to the collection while preserving other attached sets. That managed set belongs to JustSearch; keep shared synonyms in separately named sets. Rebuild candidates have their own sets, and deleting an old collection also cleans up its managed set. An older server fails explicitly when synonym synchronization is required. API keys need synonym_sets:* permissions and permission to update the collection; the old synonyms:* permissions do not cover these endpoints. See the Typesense migration documentation.

MeiliSearch 0.20.0 still does not expose localized attributes in its settings model, so the existing locale limitation remains.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.6.1 33 10/30/2026
0.6.0 42 10/30/2026
0.5.0 42 10/30/2026
0.4.1 161 7/10/2026
0.4.0 343 11/11/2025
0.3.2 291 11/10/2025
0.3.0 226 11/18/2024
0.2.1 259 3/26/2024
0.2.0 218 3/25/2024
0.1.9 238 3/24/2024
0.1.8 230 3/23/2024
0.1.7 220 3/23/2024
0.1.6 224 3/23/2024
0.1.5 235 3/22/2024
0.1.4 228 3/22/2024
0.1.3 231 3/22/2024
0.1.2 233 3/22/2024
0.1.1 221 3/22/2024
0.1.0 234 3/22/2024