JustSearch.Typesense
0.6.1
dotnet add package JustSearch.Typesense --version 0.6.1
NuGet\Install-Package JustSearch.Typesense -Version 0.6.1
<PackageReference Include="JustSearch.Typesense" Version="0.6.1" />
<PackageVersion Include="JustSearch.Typesense" Version="0.6.1" />
<PackageReference Include="JustSearch.Typesense" />
paket add JustSearch.Typesense --version 0.6.1
#r "nuget: JustSearch.Typesense, 0.6.1"
#:package JustSearch.Typesense@0.6.1
#addin nuget:?package=JustSearch.Typesense&version=0.6.1
#tool nuget:?package=JustSearch.Typesense&version=0.6.1
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 appendsfield: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:descbefore 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 throwNotSupportedException. Scalar numeric, boolean, and string ranking fields are supported. A descendingInt32/Floatmetric is also used asdefault_sorting_fieldwhen available; ascending and other field types usesort_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
customRankingafter the default relevance criteria. Scalar numeric and boolean ranking fields are supported; strings throwNotSupportedException. 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.ThenByis not supported and throws. Default queries use Search rather than Browse so ranking is honored. Ranked or explicitly sorted queries withTakeabove 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:
Localeis currently not sent to the engine because the .NET SDK does not exposelocalizedAttributes. 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
indexLanguagesandqueryLanguagesfrom 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, includingpt-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 throwNotSupportedException; 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
estimatedTotalHitsusinglimit = 0. - Typesense: returns
foundusingper_page = 0. - Algolia: returns
nbHitsfrom the primary index usinghitsPerPage = 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 | Versions 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. |
-
net10.0
- JustSearch.Abstractions (>= 0.6.1)
- Typesense (>= 9.0.0)
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 |