Put OneIndex attributes on an EF Core entity, add UseOneIndex(...) to the context options and UseOneIndexOutbox() to the model. Every SaveChanges then logs the change in the same transaction, the index is updated from the database, and db.Set.Search(text) returns tracked entities with a Where that runs partly in the index and partly in the database.
dotnet new console -o EfSearch && cd EfSearch dotnet add package RecursiveAI.OneIndex.EntityFrameworkCore --version 0.4.0-preview.2 dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.14
Put this in Program.cs and run dotnet run:
using Microsoft.EntityFrameworkCore; using OneIndex; using OneIndex.EntityFrameworkCore; var options = new DbContextOptionsBuilder<AppDb>() .UseSqlite("Data Source=app.db") .UseOneIndex("search-data", o => o.Index<Ticket>()) .Options; using var db = new AppDb(options); db.Database.EnsureCreated(); // creates Tickets and the outbox tables db.Tickets.AddRange( new Ticket { Id = "t1", Status = "open", CustomerId = 7, Title = "Login page times out" }, new Ticket { Id = "t2", Status = "open", CustomerId = 7, Title = "Password reset email never arrives" }, new Ticket { Id = "t3", Status = "closed", CustomerId = 9, Title = "Login works after reset" }); await db.SaveChangesAndIndexAsync(); // rows + outbox in one transaction, then index // Title and Status are filtered in the index; CustomerId only in the database. var found = await db.Tickets.Search("login") .Where(t => t.Status == "open" && t.CustomerId == 7) .Take(10).ToListAsync(); foreach (var t in found) Console.WriteLine($"{t.Id} {t.Title}"); var t2 = await db.Tickets.FindAsync("t2"); t2!.Title = "Login blocked: password reset email never arrives"; await db.SaveChangesAndIndexAsync(); // the change reaches the index too Console.WriteLine(); foreach (var t in await db.Tickets.Search("login").Take(10).ToListAsync()) Console.WriteLine($"{t.Id} {t.Title}"); public class Ticket // an EF entity and a search document at once { [SearchId] public string Id { get; set; } = ""; [FullText] public string Title { get; set; } = ""; [Keyword, Facet] public string Status { get; set; } = ""; public int CustomerId { get; set; } // not indexed: the database filters on it } public sealed class AppDb(DbContextOptions<AppDb> options) : DbContext(options) { public DbSet<Ticket> Tickets => Set<Ticket>(); protected override void OnModelCreating(ModelBuilder b) => b.UseOneIndexOutbox(); }
t1 Login page times out t1 Login page times out t3 Login works after reset t2 Login blocked: password reset email never arrives
- The index is not an EF provider and not a second copy of your data: the database stays the source of truth. The outbox row says “this entity changed”; the applier reads the entity’s current row and upserts or deletes it in the index, so applying a row twice gives the same index.
SaveChangesAndIndexAsync()saves, then catches the index up, so the next search sees the change. A plainSaveChangeswrites the outbox row too; in a web app runOneIndexRuntime.For(db).RunAsync(token)from a hosted service to apply it, and a search may lag the database by about 250 ms unless it asks for.AfterPendingChanges().Whereis split on&&: each part OneIndex can translate runs in the index, every other part runs in the database over the candidates the index returns. The page is filled unless fewer entities match. The entities returned are the database’s current rows.- The outbox tables are part of your model, so
dotnet ef migrations addcreates them like any other table.
Limits
- A preview (0.4.0-preview.2): APIs and file formats may still change between previews.
- A configuration that can never apply now fails loudly. From the 0.4.0-preview.2 changelog: “A configuration that can never apply (no
ContextFactoryand no public constructor taking the context’sDbContextOptions) now throwsInvalidOperationExceptionfromSaveChanges(withApplyInline) orRunAsync, before anything is written, instead of failing silently.” - Bulk writes are not indexed.
ExecuteUpdate,ExecuteDeleteand raw SQL bypassSaveChanges, so they log nothing. Repair withOneIndexRuntime.ReindexAsync(query)(re-reads those rows) orReindexAsync<T>()(rebuilds the whole index, deletions included). - SQL Server and MySQL have not been run. The project’s kill tests covered SQLite and PostgreSQL 16; in its own words, “SQL Server and MySQL are not tested.”
- A transaction that stays open longer than
LateRowAge(default 30 seconds) or pastLateRowWindow(10,000 later log positions) and then commits reaches the index only with that entity’s next change orReindexAsync. SetLateRowAgeabove your longest transaction. - One process per index directory: two app instances on one database each need their own index directory.
- Run it once as written: a second run adds the same keys again and EF Core refuses them. Delete
app.dbandsearch-datato start over.