Skip to content

The search box knows all the secrets -- try it!

Fisher is part of the Critter Stack ecosystem.

JasperFx Logo JasperFx provides formal support for Fisher and other Critter Stack libraries. Please check our Support Plans for more details.

Migration Guide ​

Fisher's API mirrors Marten's and Polecat's, so most code ports by changing a registration line and a connection string. This page is about the parts that do not.

Registration ​

cs
// Marten
builder.Services.AddMarten(opts => opts.Connection("Host=…"));

// Polecat
builder.Services.AddPolecat(opts => opts.Connection("Server=…"));

// Fisher
builder.Services.AddFisher(opts => opts.Connection("Data Source=app.db"));

Name changes ​

Marten / PolecatFisher
AddMarten / AddPolecatAddFisher
IConfigureMarten / IConfigurePolecatIConfigureFisher
ConfigureMarten / ConfigurePolecatConfigureFisher
AddMartenStore<T> / AddPolecatStore<T>AddFisherStore<T>
DocumentMetadata (the read result)StoredDocumentMetadata
IChangeSet.Deleted is IEnumerable<IDeletion>IEnumerable<IDocumentDeletion>

TIP

The last two are collision avoidance rather than preference. Fisher already has a DocumentMetadata one namespace away doing the opposite job, and Weasel.Storage.IDeletion is already in scope as the storage operation that deletes. In both cases the members are unchanged, so a body ports; only a declaration naming the type has to be edited.

Schemas become prefixes ​

cs
opts.DatabaseSchemaName = "reporting";   // reporting_fi_doc_order, not reporting.fi_doc_order

SQLite has no schemas. Nothing renders as qualified SQL, and the prefix is what isolates two logical stores in one file.

Marten features Fisher does not have ​

These are absent rather than different, so a ported file naming one will not compile. They are listed here because Fisher's parity work was measured against Polecat, not Marten — a feature Marten has and Polecat does not was invisible to that tracking, which is exactly the set below and exactly the set a migrating Marten user hits first.

MartenStatus on FisherInstead
Compiled queries — ICompiledQuery<T>, ICompiledListQuery<T>, ICompiledQuery<TDoc,TOut>Absent, and decided — see fisher#195 for the measurement.Nothing. Building the SQL is 4–11% of an ordinary Fisher query and 22.5% of the cheapest one it can run; the work is real but the absolute saving is ~2 µs. A filter-shape query plan cache would collect nearly all of it with no public API, which is the move that comes first.
Child-collection LINQPartly landed (fisher#166).See the operators page — the shipped and missing halves are below.

Child collections: what ships and what does not ​

Querying into a document's own collection members works over correlated json_each sub-queries:

  • Contains(value) over scalar element collections — strings, numbers, Guids, enums, bools.
  • Any(), and Any(c => …) with a predicate over a complex element's own members.
  • All(c => …), vacuously true over an absent, empty or JSON-null collection.
  • Count() compared to a value in either operand order, the .Count property, an array's .Length, and Count(c => …).
  • Nesting — x.Stops.Any(s => s.Cargo.Contains("fuel")) — one alias deeper per level.

Still missing against Marten, and refused by name rather than answered wrongly:

  • Dictionary members. IDictionary<,> and IReadOnlyDictionary<,> are excluded outright, so querying a dictionary member is not expressible at all.
  • SelectMany over a child collection, and therefore anything that flattens elements into the result — including ordering or projecting by a child element's member.
  • Select projecting a child collection.
  • A predicate inside Any / All / Count follows SQL null semantics, not C#'s: a predicate that evaluates to NULL is not satisfied.

TIP

The one to check in ported code is the last bullet, because it compiles either way. The rest are BadLinqExpressionException at the call, naming the operator — Fisher's LINQ surface refuses rather than falling back to client-side evaluation, which is the invariant, not the size of the surface.

A note on string ordering ​

Marten's string-named ordering — OrderBy(string property, StringComparer) — exists only on its batched queryable, not on IQueryable, so it is a narrower difference than it looks. Fisher's batched query takes a lambda instead (Query<T>(session => session.Query<T>().OrderBy(x => x.Name))), which expresses the same thing with the member checked at compile time.

What is not here, and will not be ​

Unlike the list above, these are settled decisions rather than unbuilt features.

Why
A message busThe side-effect seam exists; delivery is a bus integration's job here as on both siblings.
Table partitioningSQLite has no partition functions or schemes. So PartitionOn, MultiTenantedWithPartitioning, SoftDeletedWithPartitioning* and DoNotPartition have nothing to mean.
Row-level securityUseRowLevelSecurity / DisableRowLevelSecurity are PostgreSQL policies. SQLite has no such concept; isolate with database-per-tenant, which is a file per tenant.
GIN indexes over the JSON bodyGinIndexJsonData and its member form are PostgreSQL's. SQLite indexes an expression instead, which is cheaper and needs no column.
UniqueIndexType / TenancyScope / IsConcurrent / index sort order and casingEvery one of them describes a computed column and a PostgreSQL index. A Fisher index is an expression index and a duplicated field is a VIRTUAL generated column that cannot drift, so there is nothing for Computed vs DuplicatedField to choose between, no direction worth naming, and no casing to apply — SQLite's default collation is case-sensitive and the string operators are ordinal to match.
PropertySearching, DdlTemplate, StructuralTyped, per-type DatabaseSchemaNameNot SQLite concepts. DatabaseSchemaName is store-wide here and folds into the table prefix.
UseIdentityKeyA database-assigned identity would need the write path to read the id back rather than assign it client-side. IdStrategy is the seam for a custom strategy; a database-assigned one is a different write path.
DaemonMode.HotColdLeader election across nodes means several processes sharing one file.
Newtonsoft.JsonSystem.Text.Json only.
CreatedSince / CreatedBeforeThere is no created_at column unless you enable one; answering from last_modified would be a different question.
A binary event serializerThe seam exists; choosing an encoding is your decision.

Behaviour that differs ​

These are the ones that compile and then behave differently, so they are worth checking in ported code.

The exclusive append methods fail rather than wait ​

DANGER

AppendExclusive, FetchForExclusiveWriting and WriteExclusivelyToAggregate are the optimistic methods on Fisher. A competing session gets EventStreamUnexpectedMaxEventIdException instead of waiting its turn.

The safety property is unchanged — the version guard still runs inside the write transaction. Code that relied on waiting needs a retry.

See Appending Events.

Include() is an extension method, not a builder ​

Fisher includes related documents, and covers the same three plan kinds Marten does — a callback or IList, a dictionary keyed by the related identity, and a dictionary of lists grouped by a mapping member — in both join directions, with an optional filter on each. What differs is the call shape, so a ported line needs editing even though the feature is there:

cs
// Marten
query.Include<Boat>(boats).On(x => x.BoatId);
query.Include<Catch>(x => x.Id, catches).On(x => x.Id, c => c.AnglerId);

// Fisher
query.Include(x => x.BoatId, boats);
query.Include(x => x.Id, (Catch c) => c.AnglerId, catches);

There is no IMartenQueryable-equivalent interface to hang members off, so there is no fluent .On(…) builder; the id source, the optional id mapping and the destination are all arguments of one extension method. The filter is a trailing optional argument rather than a separate overload.

Two behavioural differences to plan for. Fisher resolves an include with a second statement rather than Marten's temp-table join, because an embedded store has no round trip to amortise — so the reads are not atomic with each other unless you wrap them in a transaction. And an Include combined with a Select, a GroupBy, a join, or a terminal that returns no documents is refused by name rather than silently leaving the destination empty.

Full-text search is FTS5, and relevance is OrderByRelevance() ​

Fisher has full-text search over SQLite's FTS5, with all six of Marten's operators — Search, PlainTextSearch, PhraseSearch, WebStyleSearch, PrefixSearch and NgramSearch — plus [FullTextIndex] and Schema.For<T>().FullTextIndex(...). Four things differ from Marten, and all but the third are what a ported line needs edited for:

  • No regConfig argument. Marten's overloads take a PostgreSQL text-search configuration name; FTS5 has no equivalent, and its nearest relative — the tokenizer — is fixed on the index rather than chosen per query. So the second argument is gone and FullTextIndex(FullTextTokenizer.Porter, …) is where the choice is made.
  • One index per document type. Marten permits several and carries AmbiguousFullTextIndexException for a search that cannot tell which one it meant; here a search operator names no index, so with one there is nothing to disambiguate. A second declaration is refused — put every searchable member in the one.
  • NgramSearch needs a Trigram index and the word operators refuse one. Marten reaches ngram search through a separate index type and has the same requirement; what is different is that Fisher refuses the mismatch by name in both directions rather than returning nothing.
  • Relevance is OrderByRelevance(), not OrderByTextRank(term, function). FTS5's bm25() reads its match from the query's own MATCH, so Fisher's ordering repeats neither the term nor the search function. Column weights are a query argument (OrderByRelevance(10.0, 1.0), in index declaration order) rather than Marten's WeightedFullTextIndex at declaration time, and OrderByNgramRank has no counterpart because bm25() ranks a Trigram index like any other. Snippet() and Highlight(column) project FTS5's snippet() and highlight() inside a Select. See Full-Text Search.

The index itself is an external-content FTS5 table kept in step by database triggers, so it survives writes that never went through Fisher — and it is created and populated by the ordinary schema migration, including on a store that already holds documents.

Vector search shares the contracts, not the index ​

Fisher has vector search, vector projections and hybrid search on Marten.PgVector's API shape, and the types a ported line names — IEmbeddingProvider, DistanceFunction, VectorMatch<T> — are the store-neutral ones in JasperFx.Events.Vectors that all three stores share. What a port has to edit:

  • No UsePgVector(), and no HNSW index. Marten.PgVector's opts.VectorIndex<T>(x => x.Embedding, dimensions, distance, m, efConstruction) declares an HNSW index that schema migrations track, and one index serves one metric. Fisher deliberately has none: Schema.For<T>().VectorIndex(x => x.Embedding, dimensions) or [VectorIndex(dimensions)] declares the member, and the search is brute force — every row's distance computed in full — so m and efConstruction have nowhere to go, and one declaration serves whichever metric a call names. See why there is no side table.
  • The embedding provider is already shared, from Marten 9.36. Marten.PgVector's projection and search run on JasperFx.Events.Vectors.IEmbeddingProvider, which returns ReadOnlyMemory<float>[], so a provider written against it ports unchanged. Older Marten.PgVector had its own IEmbeddingProvider returning Pgvector.Vector[]; it survives only as an [Obsolete] compatibility type, and a provider still implementing it needs its return type changed, because Fisher takes only the shared contract.
  • VectorProjection<TDoc, TId> writes a document, not a table. The document implements IVectorized<TId> and declares its own vector index, and it is searched with VectorSearchAsync like any other — there is no VectorProjectionSearchAsync. TId is any identity Fisher stores, where Marten's is Guid only.
  • A delete always takes an id selector. Marten.PgVector's Delete<TEvent>() falls back to the stream id when no selector is given; map.Delete<TEvent>(e => …) here has no such overload, so a line relying on that default gains e => e.StreamId.
  • Selectors take the event envelope. Marten.PgVector's Map and Delete selectors receive the event's data (e => e.Text); Fisher's receive IEvent<TEvent> (e => e.Data.Text), so the stream id and metadata are in reach without a second overload.
  • Register it Async. Nothing refuses Inline, but an inline projection would hold SQLite's one write lock across the model call. The embedding commits in the daemon's batch transaction, not on a connection of its own.

Hybrid search needs both a full-text and a vector index on the type, and refuses a type with only one rather than degrading to the leg it has.

String searching is ordinal and case-sensitive ​

Fisher uses instr/substr rather than LIKE, because SQLite's LIKE is case-insensitive for ASCII while = is case-sensitive — a LIKE-based Contains would contradict == in the same Where clause. See Searching on String Fields.

Timestamp equality is normalised ​

A document's DateTimeOffset member is compared through SQLite's date parser, folding the offset into UTC at millisecond precision. Two spellings of one instant compare equal — which costs sub-millisecond discrimination on ==, as it does on the siblings.

Numeric revisions follow Marten, not Polecat ​

WARNING

The guard requires the supplied revision to be strictly greater than the stored one. Polecat diverged to an equality rule; ported Polecat code that re-stores an instance carrying its current revision gets a ConcurrencyException here.

See Optimistic Concurrency.

Batched queries are not a performance feature ​

They exist for API parity. SQLite is embedded, so there are no round trips to collapse. The ordering property still holds. See Batched Queries.

QueryForNonStaleData waits for the whole store ​

Where Polecat waits for the projections feeding the queried type. Stricter, not weaker.

An event-raising projection actually raises events ​

WARNING

Polecat no-ops the three members involved, so an event-raising projection there drops its events with no signal. Fisher appends them, inside the batch's transaction, with the optimistic guard.

Inner-side join predicates are applied ​

WARNING

Polecat silently drops the inner query's own predicates, so GroupJoin(session.Query<Catch>().Where(...)) there returns rows the caller excluded. Fisher applies them — which means a ported query may return fewer rows here, correctly.

Things that are cheaper here ​

Worth revisiting when you port, because the workaround you carried may no longer be needed:

  • Duplicated fields are generated columns — no backfill when added to a populated table, and a patch has nothing to refresh.
  • Indexes are expression indexes — no computed column, no JSON_VALUE index.
  • Patching needs no server-side function installed.
  • JSON reads are byte-exact and save the whole round trip rather than a fraction of it.
  • Joins are plain SQL and there is no round trip they are competing against.
  • Database-per-tenant is a file per tenant, and it buys concurrency as well as isolation.

Things to plan for ​

  • One writer per file. If several processes or many concurrent writers need the same data, either split across files or use Marten or Polecat.
  • Transaction participants and QueueSqlCommand stop being conveniences and become the way you write your own tables alongside Fisher's without contending with yourself.
  • WAL must stay on if you run the async daemon.
  • Guid casing matters in any SQL you write by hand.

Testing against Fisher, deploying elsewhere ​

An integration suite with no server at all is an appealing idea, and it is a narrow one. Read the scope before adopting it.

DANGER

Fisher is not a test double for Marten or Polecat. The section above is a list of behaviours that compile and then differ — which is the exact failure mode of a stand-in: the suite compiles, goes green, and production behaves another way.

Several of those divergences fall squarely in the territory integration tests exist to cover. Concurrency under contention differs (the exclusive methods fail where the siblings wait), the numeric-revision guard differs from Polecat's, QueryForNonStaleData is stricter here — so a real staleness race in the deployed store can stay hidden — and both ordinal string comparison and inner-side join predicates change which rows come back.

What a suite on Fisher can honestly cover for an application deployed elsewhere: wiring, registration, projection shape, and that your handlers and endpoints hold together. What it cannot cover: concurrency, ordering, collation, and staleness semantics. Those have to be tested against the store you deploy on, and the hard part is that knowing in advance which of your tests are sensitive to them is not obvious.

Fisher's own positioning is SQLite in production — edge, embedded, desktop, single-node. That is where it is a first-class answer rather than a compromise.

See Integration Testing.

Released under the MIT License.