Compatibility
What the artifacts promise
- Binary compatibility within a major version is checked by MiMa on every
build;
versionScheme := "semver-spec"describes what the artifacts promise. - New methods added to
EffectandRsBridgecarry default implementations, so implementing either typeclass yourself keeps working across minor releases. - Deprecations get at least one minor release before removal.
- Every deliberate break inside a major version is listed here and carries a matching MiMa exclusion in
build.sbt, so nothing is waived silently. A major release —3.0.0,4.0.0,5.0.0— has no such exceptions: its migration guide below is what the major version covers.
Upgrading from 5.1.x to 6.0.0
Only the organization changes. The code is 5.1.1's, the package is still mongo4s, and MiMa checks every module of
6.0.0 against org.mongo4s 5.0.0 and 5.1.1 and finds nothing.
// before
"org.mongo4s" %% "mongo4s-cats" % "5.1.1"
// after
"now.sendline" %% "mongo4s-cats" % "6.0.0"A build tool treats a new organization as a new library, so it will not evict one in favour of the other. If anything
in your graph still brings org.mongo4s 5.x — an internal library not yet moved — both jars land on the classpath
with the same classes, and which one loads depends on classpath order. Move that library too, or exclude the old
organization where it comes in:
libraryDependencies += ("com.example" %% "internal-commons" % "1.2.3").excludeAll(ExclusionRule("org.mongo4s"))org.mongo4s 6.0.0 exists as a relocation: each module is a pom whose <relocation> names now.sendline, with no
jar of its own. Maven, and sbt through Coursier, resolve it to the now.sendline module of the same name and version,
so a build — or Scala Steward — that only bumps the version lands on the new organization. Its description and its
relocation message both read "Moved to now.sendline, see https://oss.sendline.now/mongo4s/": Maven prints the message
when it follows the relocation, and Central and mvnrepository show the description. sbt follows it silently. Nothing
is published under org.mongo4s after it.
Upgrading from 5.1.0 to 5.1.1
Binary compatible: MiMa checks 5.1.1 against 5.0.0 and 5.1.0 and finds nothing. Nothing to change in calling
code. On kyo, a finalizer given to guaranteeCase or bracketCase sees an interrupted body as ExitCase.Canceled,
where 5.1.0 handed it ExitCase.Errored(KyoException("fiber abandoned")); code that matched that error to spot an
interrupt matches Canceled instead.
The MongoDB driver moves from 5.13.0 to 5.14.0, for a fix to large replies read in many small pieces, which hung
on 5.13.0. A build that pins org.mongodb jars of its own should pin 5.14.0 or later, or keep the hang.
Upgrading from 5.0.x to 5.1.0
Binary compatible: MiMa checks 5.1.0 against 5.0.0 and finds nothing, and no exclusion was needed — a library
compiled against 5.0 links against 5.1 unchanged. New members of Repository, MongoCollection and
MongoDatabase carry bodies, so an implementation of your own keeps compiling; the ones it cannot implement for you
refuse by name until you override them. What a recompile or a run can show:
field.set(None)renders$unsetrather than$set: null, andsetOnInsert(None)adds nothing. A query matching{field: null}still finds the unset field; one asking$type: "null"no longer does.setNullwrites thenullwhere it is wanted.- A filter that matches nothing renders
{_id: {$in: []}}rather than{$nor: [{}]}—in(Nil),containsAll(Nil),Filter.none, an emptyKeyRef.inFilter,Filter.Or(Nil)and!Filter.all. A test that compared the rendered document needs the new one. MongoErrorfiles more failures where operations teams look for them: a clienttimeoutMSthat ran out isExecutionTimeout, notUnavailable; refused credentialsUnauthorized; a stepped-down primary, a recovering node, a cleared pool and a closed serverUnavailable. A match that relied on the old case moves.- Decoding failures carry their path. A
WireCodecfailure, and aBsonDecodercollection's, is wrapped inBsonError.Nestedsegments naming the field, position or key; a match on a bareTypeMismatchfinds it inside. Filterhas a new case,TextSearch, which an exhaustive match overFilterhas to handle.Accumulator.sumandavgover a field compile only on a number or anOptionof one. Code compiled against5.0keeps linking; code that summed a non-numeric field — which the server answers with0— no longer compiles.wheretakes only an array filter identifier, and refusesField.self, which used to render$[].- Refusals that were thrown where an operation was built are raised in
F— an aggregation ordistinctwhose filter does not render, contradictory create options, a page of no entities, a counter step below one, and the driver's argument checks on the index, drop and listing operations. Atryaround building the call no longer sees them;attempton the effect does. - On
zio, a defect stays a defect.attemptandhandleErrorWithrecover failures only, and a throw insidemap,flatMaporsuspendis a failure rather than a defect. - The fake answers more like the server, which a test pinned to its old answers notices: array paths and
numeric segments, operators through arrays,
$allon a scalar, a regular expression in$in, UTF-8 string order, updates through array positions and the server's codes 28 and 14, a negative$position,$pullby a document, number widths in the array operators,distinctand$group, projections through arrays, an_idstored first,modifiedCountof an identical replacement, every bulk write error as aBulkWriteFailed, andexplainandwatchrefused in the effect or the stream.
Upgrading from 5.0.0 to 5.0.1
Binary compatible: MiMa checks 5.0.1 against 5.0.0 and finds nothing. Nothing to change in calling code. A module
compiled by Scala 3.10 with -Werror that failed on an ArrowAssoc deprecation inside a derived WireCodec or a
PrimaryKey.compound compiles once it is on 5.0.1.
Migrating from 4.x to 5.0.0
5.0.0 keeps Scala 3.9 LTS. It is a major release because several methods gained parameters and several AST cases
changed shape, which is a binary break even where the source still compiles; each is a section below. The ones
ordinary calling code is likely to meet are bulkWrite's ordered flag becoming BulkWriteOptions, $addFields
taking computed fields, and containsAll(Nil) now matching nothing. Anything compiled against 4.x — a library that
depends on mongo4s — has to be recompiled.
bulkWrite takes BulkWriteOptions
bulkWrite(commands, ordered = false) is bulkWrite(commands, BulkWriteOptions.unordered), on MongoCollection and
on Repository. bulkWrite(commands) is unchanged and still ordered. BulkWriteOptions also carries comment,
bypassDocumentValidation and let.
Inserts take options
insertOne(document, options = InsertOneOptions.default) and insertMany(documents, options = InsertManyOptions.default), on the collection and on the repository. Calls without options compile unchanged; the
binary signature is new. InsertManyOptions.unordered is the new way to insert past a duplicate.
$addFields takes computed fields
Stage.addFields("name" -> bsonValue) is Stage.addFields("name" := expression). The typed form is usually the
better rewrite — "total" := priceField.expr * qtyField.expr — and Expr.raw(bsonValue) carries a value exactly as
before. A Field works as the target too, spelled through the collection's naming: totalField := ....
AST cases changed shape
A match over one of these, or a direct construction of one, needs the new shape; the factories are unchanged unless noted:
Stage.Group(by, accumulators)—byis aGroupKey(All,Path,Evaluated,Compound) instead of anOption[FieldPath].Stage.AddFields(fields)holdsComputedvalues;Stage.Redact(expression)anExpr;Stage.UnwindgainedincludeArrayIndexandStage.UnionWithapipeline.Filter.Expr(expression)holds anExpr[T, Boolean].Filter.expr(bsonDocument)still works, and wraps the document inExpr.raw.Filter.Type(path, bsonTypes)holds a list, so it can match several types.Update.CurrentDate(path, asTimestamp)says whether it writes a date or a timestamp.Projection.Includegained the computed fields it carries.WriteCommand.DeleteOne(filter, options)andDeleteMany(filter, options)carryDeleteOptions, defaulting to none, soWriteCommand.DeleteOne(filter)still compiles; a pattern on the old arity does not.Accumulator.ExpressionandStagehave new cases —Evaluated,ReplaceWith,Search,SearchMeta,VectorSearch— which an exhaustive match has to handle.
Traits grew
FindQuery has let, allowDiskUse, cursorType, maxAwaitTime and noCursorTimeout; AggregateQuery has let,
maxAwaitTime and bypassDocumentValidation; DistinctQuery has hint and comment; MongoCollection has
estimatedCount(options) and the five search-index methods; MongoClient has bulkWrite; Repository has
insertIfAbsent and replace. A class of your own that implements one of them has these to write; code that only
calls them, or extends BaseMongoRepository, is unaffected.
Positional paths are typed
field / "$[]" / "qty" still renders the same path, but its segments are stored names, outside the collection's
naming. field.each / qtyField, field.matched / qtyField and field.where(element) / qtyField are the typed forms,
and an array filter built on the same Field.element replaces a hand-spelled Field.stored("low.qty").
Behaviour that changed under the same signatures
containsAll(Nil)matches nothing, the way$all: []does on the server. It matched everything before. If an empty list meant "no constraint" in your code, test for it before building the filter.- A
sortonupdateMany— or on a bulkUpdateMany— is refused with anIllegalArgumentException. The driver would have dropped it, since a multi-document update has no one document to pick. ensureKeyIndexon a key stored as_idbuilds nothing and answers_id_, where it used to fail on the server.- A projection that includes a field and slices it keeps it once, sliced, instead of returning every field; the
combination of only sliced fields and no
_idis refused. - The fake refuses more: positional updates, tailable cursors, aggregation expressions, Atlas Search, a write for
the client-level
bulkWrite, auniqueindex on_idalone, and partial indexes the server refuses. Each of them answered wrong, or not at all, before. - The fake simulates update-based upserts where it threw
UnsupportedOperationExceptionbefore, and a replacement upsert takes its_idfrom the filter, as the server does, instead of generating one. A test that relied on the refusal now sees the insert.
Java and the server
Nothing changed in what runs where, but it is stated now: the class files target Java 17, mongo4s-kyo needs JDK 25
at runtime because kyo's own class files do, and features from newer servers say which server they need — see
Modules.
Upgrading from 4.0.x to 4.1.0
Binary compatible: MiMa checks 4.1.0 against 4.0.0 and finds nothing, and no exclusion was needed. Two things a
recompile can show:
Accumulator,Filter,UpdateandStagehave new cases. Code that matches over one of them exhaustively gets a warning naming the new cases. Nothing that only builds them is affected.setWindowFieldstakes(String, WindowField[E])*.WindowOutputis aWindowField, so every call compiles as it did; only code that tookSetWindowFields.outputapart and treated each entry as aWindowOutputneeds a match onWindowOutputversusWindowFunction.
Migrating from 3.x to 4.0.0
4.0.0 keeps Scala 3.9 LTS. It is a major release because a handful of shapes changed and a few calls now refuse
what they used to accept quietly; each is a section below. The ones ordinary calling code is likely to meet are
findPage returning a KeysetPage, watchAttempting returning ChangeEvent[DecodeResult[A]], and an Update that
changes one path twice being refused. The testkit changed its constructors.
An update that changes one path twice is refused
One operator document holds one change per path, so Update.inc(n, 1).and(Update.inc(n, 2)) used to render as
{"$inc": {"n": 2}} — the first change lost without a word. Rendering it now throws IllegalArgumentException
naming the path. So does an update whose paths overlap across operators — set(a) with unset(a), unset(a) with
set(a.b), a $rename onto a path something else sets — which the server refused anyway, as a write error.
If you relied on the last change winning, keep only that change.
Update has a Pipeline case
Pipeline updates are Update.pipeline(stages*), taken by updateOne, updateMany, findOneAndUpdate and a bulk
UpdateOne/UpdateMany. They are a new case of the Update enum, so an exhaustive match over it needs one more
branch. toBson on a pipeline throws — it is a list of stages, not an operator document — and toPipeline is where
its stages come from. A pipeline and an operator update do not combine.
MongoCollection and Stage grew
MongoCollection has three new abstract members — createIndexes, dropIndexes and renameCollection — so a class
of your own that implements it has three more to write; code that only calls it is unaffected. Stage has four new
cases — GeoNear, Unset, SortByCount and BucketAuto — which an exhaustive match over it has to handle.
distinct answers with an array's elements
distinct[B](field: Field[A, B], ...) became distinct[C, B](field: Field[A, C], ...)(using DistinctOf[C, B], ...).
Over a field typed as a collection, B is its element type — what the server answers with — so a call that used to
be typed List[List[String]] and fail to decode is now List[String] and works. A scalar field is unchanged.
A call that named the type argument explicitly, distinct[String](...), names both now or neither.
watchAttempting yields ChangeEvent[DecodeResult[A]]
It used to be DecodeResult[ChangeEvent[A]], which lost the whole event, resume token included, when its document
did not decode. Now the event always arrives and the decode result is on the document:
// 3.x
collection.watchAttempting().collect { case Right(event) => event.fullDocument }
// 4.0
collection.watchAttempting().map(_.fullDocument.flatMap(_.toOption))watchAsAttempting on the client and the database changed the same way.
ChangeEvent gained namespace and destination
The collection an event happened in, and where a rename moved it to, as Option[Namespace]. ChangeEvent is a case
class, so code that constructs one positionally — test fixtures, mostly — has two more arguments to pass.
Operators that apply to one kind of field compile only on it
regex needs a field that holds text (String, Option[String], a collection of strings), mod a numeric one, and
hasSize, Update.popFirst and Update.popLast a collection. On any other field they no longer compile. A call that
stops compiling was rendering a filter or update the server accepts and that does nothing; Filter.Regex(path, ...)
and the other AST cases remain for the rare field whose type does not say what it holds.
Limits, skips and batch sizes are checked
A limit or batchSize below one throws, and so does a negative skip — on FindQuery, CountOptions,
WatchOptions, Stage.limit/skip/sample and a repository Page. MongoDB reads a limit of 0 as no limit and a
negative one as a single batch, which is rarely what the number meant. Leave the option unset for the server's
default.
findPage returns a KeysetPage
findPage(limit, after, filter) returned the rows; it now returns KeysetPage(items, next), where next is the key
to pass as after — None on the last page. It also takes descending:
// 3.x
val first = users.findPage(100)
val next = users.findPage(100, after = Some(first.last.id))
// 4.0
val first = users.findPage(100)
val next = users.findPage(100, after = first.next)WireCodec.spellsAs is gone
It was asked nothing since getDirectCollection lost its naming parameter in 3.0. A codec that overrode it can
drop the override; fieldNaming is what a collection reads.
The fake streams through the RsBridge
FakeMongoCollection and FakeRepository no longer take an emit function; they take the runtime's RsBridge from
scope, as a real client does:
// 3.x
FakeMongoCollection[IO, S, User](codec, users => fs2.Stream.emits(users))
FakeRepository[IO, S, User, String](users => fs2.Stream.emits(users), batchSize = 100)
// 4.0
FakeMongoCollection[IO, S, User](codec)
FakeRepository[IO, S, User, String](batchSize = 100)A stream now reads the collection when it runs rather than when it is built, and aggregate, distinct and
selectAs stream too.
The fake answers more like the server
Tests that leaned on the fake's old behaviour may need adjusting. A duplicate _id in insertMany or a bulkWrite
is a MongoError.BulkWriteFailed naming the command, rather than a DuplicateKey; a unique index is enforced; a
collation is refused rather than ignored; a findOneAnd* whose projection leaves a document the codec cannot read
fails rather than answering None; an update that changes nothing reports modifiedCount = 0; and a replacement
keeps the replaced document's _id. Each of these is checked against a real server by a parity spec.
listIndexes describes each index the way the server does — v, key, name and its options, after the _id_
index — where it used to list bare key documents, so a test that read an entry as the keys reads its key now.
Creating an index that already exists changes nothing rather than recording it twice, one that clashes with an
existing index's name or keys fails, and dropIndex of an index that is not there, or of _id_, fails.
Behaviour that changed under the same signatures
These need no code change, but they are worth knowing when upgrading a running service:
- A sum type's codec, and a codec mapped with
imap/iemap, report the naming they write with, so a direct collection of either now spells its queries as it spells its documents. Queries that used to find nothing on asnake_caseenum entity, or on an entity codec wrapped iniemap, now find what they should. - A batched repository write that fails in a later batch reports the failing command by its position in the whole
sequence, and
BulkWriteFailed.resultcounts the earlier batches. - A transaction commit that ran out of
maxCommitTimeis no longer retried, and whole-transaction retries pause briefly between attempts, as the driver specification requires. - A
findOneAndUpdateprojection you pass to a repository wins over itsdefaultProjection. Effecthas asleepwith a default implementation; every runtime module overrides it.
What it buys
A few more of the things the API promised are now true at compile time or at the call site rather than on the server, and the testkit agrees with the server in the places tests most often lean on.
Migrating from 2.x to 3.0.0
3.0.0 requires Scala 3.9 LTS. Most of what it adds is additive, but a number of things did move, and each
one is a section below. The changes that reach ordinary calling code are: compound PrimaryKeys became named
tuples, WireCodec stopped deriving itself for any case class it met, and driver exceptions are now MongoError.
The rest touch code that pattern matches the query AST, constructs an options value positionally, or implements one
of the public traits.
Why this is a major release
Scala 3 TASTy is backward but not forward compatible, and a Scala 3 artifact carries the same _3 suffix whatever
minor compiled it. So artifacts built on 3.9 cannot be read from a 3.3 LTS project, while nothing about the
bytecode changed. MiMa cannot see that break — it compares signatures, not TASTy versions — which is exactly why it
takes a major release rather than a silent minor.
3.3 LTS is no longer supported. The replacement is the current LTS, not a fast-release line.
Compound keys are named tuples
compound used to pair a positional tuple with a separate list of names and extractors, and compound3/compound4
extended it one arity at a time. All three are replaced by one compound over a named tuple:
// 2.x
given PrimaryKey[Order, (String, Int)] =
PrimaryKey.compound(o => (o.userId, o.seq))("user_id", _._1)("seq", _._2)
// 3.0
given PrimaryKey[Order, (userId: String, seq: Int)] =
PrimaryKey.compound(o => (userId = o.userId, seq = o.seq), FieldNaming.snakeCase)The labels are the field names, so the two lists that had to agree became one, and _._1/_._2 — which type-check
just as happily when swapped, as long as the two fields share a type — are gone. There is no arity ceiling either,
so compound3 and compound4 have nothing left to do and were removed.
Field names are still stored names. The FieldNaming argument is what lets the Scala labels stay idiomatic while
the stored spelling differs; leave it out and the labels are used verbatim, exactly as the old string arguments were.
Key values are written with labels at every call site — users.findOne((userId = "u1", seq = 3)) — and their order
is part of the key's type, so a call that writes them in another order does not compile.
Transactions retry by default
withTransaction now does what the driver's own does: a TransientTransactionError restarts the whole transaction,
an UnknownTransactionCommitResult retries just the commit, both bounded by one 120-second deadline taken before
the first attempt. In 2.x the first failure was reported and nothing was retried.
This is a behaviour change with no compile error behind it, so it is the one item here to read twice. It only
affects failures the server itself labelled — an error from your own code inside the body still fails the
transaction immediately. If you already retry around withTransaction, you now have two layers; drop yours, or:
client.withTransaction(body, TransactionOptions.withoutRetries) // exactly the 2.x behaviourBoth withTransaction methods gained an optional TransactionOptions parameter, which also carries the
transaction's readConcern, writeConcern, readPreference and maxCommitTime. The block form is unchanged:
client.withTransaction { ... } still compiles.
Effect gained monotonic, needed to bound the retries. It has a default implementation reading System.nanoTime,
so an Effect you implement yourself keeps compiling; override it if your runtime has a clock worth substituting in
tests, as mongo4s-cats and mongo4s-zio do.
WatchOptions stopped being a case class
It is a final class with a private constructor, so WatchOptions(...), .copy(...), unapply and the Product
methods are gone. Build one from WatchOptions.default[E] and chain, exactly as before:
WatchOptions[User](pipeline = stages) // 2.x
WatchOptions.default[User].withPipeline(stages) // 3.0Reading a field — options.pipeline, options.batchSize — is unchanged.
The reason is the one Index and WireCodecConfig already carry: a case class cannot gain a field without
breaking apply/copy in every release, and change-stream options keep arriving. withExpandedEvents is the first
one that had to, so the treatment happened now rather than being restated as a hazard.
Streamable.instance is internal
A given Streamable[S, A] = Streamable.instance written outside mongo4s no longer compiles. It never worked on kyo
anyway — that backend carries its element Tags in the instance and threw when handed a forged one. Import the
runtime module's givens instead. A backend implemented outside this library needs its Streamable to come from
its own bridge, as the four in-tree ones do.
Effect.traverse is now Effect.flatTraverse
Same signature, honest name: the function returns F[List[B]] and the results are flattened. Rename the call.
Repository's …By methods are …ByField or …ByFilter
// 2.x // 3.0.0
repo.findBy(field, value) repo.findByField(field, value)
repo.getBy(filter) repo.getByFilter(filter)
repo.updateBy(filter, update) repo.updateByFilter(filter, update)
repo.deleteBy(filter) repo.deleteByFilter(filter)findByFilter is unchanged. By used to mean a field in findBy and a filter everywhere else, which made
findByFilter and deleteBy two names for the same idea; the suffix now names the argument in every case. Renames
only — the signatures and behaviour are untouched, so each call site is a search and replace.
getByField, updateByField and deleteByField are new, and are each the …ByFilter method over
field.equalTo(value).
Repository.insertOne/insertMany return the key
// 2.x
def insertOne(entity: E)(using Option[ClientSession]): F[Option[org.bson.BsonValue]]
def insertMany(entities: List[E])(using Option[ClientSession]): F[List[org.bson.BsonValue]]
// 3.0.0
def insertOne(entity: E)(using Option[ClientSession]): F[K]
def insertMany(entities: List[E])(using Option[ClientSession]): F[List[K]]A Repository[F, S, E, K] carries a PrimaryKey[E, K], so it can name the key of what it just inserted without
asking the driver for an id to decode. The keys come back in insertion order, across batchSize batches.
Anything that ignored the result — _ <- repo.insertOne(entity) — compiles unchanged. Anything that decoded the
BsonValue drops the decode:
// 2.x
repo.insertOne(person).map(_.flatMap(BsonDecoder[String].decode(_).toOption))
// 3.0.0
repo.insertOne(person)MongoCollection.insertOne/insertMany are unchanged: a collection has no key type, so InsertOneResult and
InsertManyResult keep reporting what the driver reports. Reach for those if you need the server's _id itself.
Repository.bulkWrite takes ordered
bulkWrite(commands) is unchanged and still ordered. bulkWrite(commands, ordered = false) now behaves the way the
collection does — one command, everything the server can apply — instead of batching and stopping at the first
failure. Only an implementation of Repository outside this library has to follow the added parameter.
aggregate takes a decoder
aggregate[B] now requires BsonDocumentDecoder[B] instead of BsonDocumentCodec[B]. Every codec is a decoder, so
existing calls compile unchanged; a read model that only decodes no longer needs an encoder written for it. Only an
implementation of MongoCollection outside this library has to follow.
getDirectCollection no longer takes a FieldNaming
database.getDirectCollection[Person]("people", FieldNaming.snakeCase) // 2.x
database.getDirectCollection[Person]("people") // 3.0The codec decides the spelling, because the codec is what wrote it. A derived WireCodec reports the
WireCodecConfig it was built with. A hand-written codec that spells fields differently from its Scala labels says
so by overriding fieldNaming:
given WireCodec[Legacy] = new WireCodec[Legacy]:
override def fieldNaming: FieldNaming = FieldNaming.kebabCase
...getCollection is unchanged and still takes the parameter.
WireCodec is no longer derived automatically
A case class, enum or sealed trait used to get a WireCodec from implicit scope alone. Now it needs to be asked:
final case class Address(city: String) // 2.x: derived on demand
final case class Address(city: String) derives WireCodec // 3.0The failure is a compile error naming the type, never a change in behaviour, so the compiler walks you through it.
Types reached through a BsonEncoder/BsonDecoder pair — a medeia, calypso or zio-bson model — are
unaffected; that bridge is unchanged. So are the cases of an enum and the leaves of a sealed trait, which derive
with their parent.
Filter.Near lost its spherical flag to a second case
Filter.Near(path, geometry, min, max, spherical) is now two cases — Near and NearSphere — each with the same
four fields and no flag. field.near(...) and field.nearSphere(...) are unchanged, so only code that pattern
matched on the AST has to follow:
case Filter.Near(path, geometry, min, max, spherical) => ... // 2.x
case Filter.Near(path, geometry, min, max) => ... // 3.0
case Filter.NearSphere(path, geometry, min, max) => ...The four AST enums also gained a key member naming the operator each case renders as. It is additive.
Sort carries a SortOrder, and Projection carries slices
Sort[E].fields is List[(FieldPath, SortOrder)] rather than List[(FieldPath, Boolean)], so a third direction —
$meta textScore — is expressible. Sort.asc/Sort.desc are unchanged; only code that read or built fields by
hand has to follow.
The three Projection cases each gained a slices list, so a pattern match on them needs the extra binder:
case Projection.Include(fields, withId) => ... // 2.x
case Projection.Include(fields, withId, slices) => ... // 3.0Constructing them is unchanged — the new parameter is defaulted.
FindQuery and AggregateQuery gained explain
Both are public traits, so anything implementing one outside this library has to add the method. Calling code is unaffected.
The direct path stopped refusing narrower numbers
getDirectCollection used to demand the exact BSON width its encoder writes — a Long field had to be an Int64
on disk. It now follows the same rule getCollection always did: any numeric type is accepted as long as the value
survives the conversion whole and in range.
Nothing to change at the call site. What changes is that reads which used to fail now succeed, so a collection with
mixed widths for a field no longer has to be read through getCollection. If you were relying on the failure to
detect drifted data, find(...).attempting no longer reports it — compare BSON types explicitly instead.
A wrong type — a string where a number is modelled — is still an error, and is now a BsonError.TypeMismatch
rather than the driver's BsonInvalidOperationException.
Operations raise MongoError, not driver exceptions
Anything the server reports now arrives as mongo4s.MongoError. Code that caught the driver's own types has to
change shape, and it gets shorter:
// 2.x
insert.recoverWith {
case e: MongoWriteException if e.getError.getCategory == ErrorCategory.DUPLICATE_KEY => replace
}
// 3.0
insert.recoverWith { case MongoError.DuplicateKey(_) => replace }The driver's exception is still there as cause, so a case e: MongoError if e.cause.isInstanceOf[...] escape
hatch exists for anything not modelled. BsonError.DecodingFailure and RsBridgeError are unchanged — only
failures that came from the server are translated.
This is the one change in this release that is a behaviour change rather than a compile error: code catching
com.mongodb.MongoException still compiles and stops matching. Search for com.mongodb.Mongo*Exception in your
recover/catch blocks.
RsBridgeConfig stopped being a case class
Same treatment, same reason. RsBridgeConfig(...) and .copy(...) are gone, and RsBridgeConfig.Default is now
RsBridgeConfig.default, matching every other options type in the library:
given RsBridgeConfig = RsBridgeConfig(bufferSize = 512, strictSingleResult = true) // 2.x
given RsBridgeConfig = RsBridgeConfig.default.withBufferSize(512).withStrictSingleResult // 3.0Reading a field — config.bufferSize, config.timeout — is unchanged, and the given in the companion still
supplies the default, so code that never configured the bridge is untouched.
It was the last public case class among the option types, which made it the one place where a new knob would have
cost a major. A bufferSize of zero is now rejected on construction instead of hanging the first stream that used
it.
createCollection takes options
MongoDatabase.createCollection(name) gained a defaulted CreateCollectionOptions parameter, so calls are
unchanged at the source level — but a parameter is never binary-compatible, and MongoDatabase is a trait, so
anything implementing it outside this library has to follow.
ChangeEvent gained two fields
wallTime and splitEvent. Reading an event is unaffected; constructing one by hand — which really only happens in
tests — needs the two extra arguments. It stays a case class, because an event is data a consumer destructures
rather than configuration a caller builds, and copy/unapply are worth having there.
What it buys
Every module is now built with one Scala version. mongo4s-bson-calypso, mongo4s-kyo and mongo4s-rapid used to
be pinned to a fast-release 3.8 because their upstream dependencies required it, which put them outside what a
3.3 LTS project could use at all. They are now on the LTS line with everything else.
Internally the codebase moved to the given syntax introduced by SIP-64 in 3.6 — how instances are declared,
not what they are. Nothing about that is visible to a caller.
Migrating from 1.x to 2.0.0
2.0.0 is a breaking release. The compiler catches every item in this list — none of it changes behaviour silently.
Operation options moved into values
Write operations used to take their options as parameters. Each family now takes one immutable options value,
built by chaining from default:
collection.updateOne(filter, update, upsert = true) // 1.x
collection.updateOne(filter, update, UpdateOptions.upsert) // 2.0
collection.findOneAndUpdate(filter, update, returnUpdated = false) // 1.x
collection.findOneAndUpdate(filter, update, FindOneAndUpdateOptions.default[A].returningPrevious) // 2.0The new types are UpdateOptions, ReplaceOptions, DeleteOptions, CountOptions, and the entity-typed
FindOneAndUpdateOptions[A], FindOneAndReplaceOptions[A], FindOneAndDeleteOptions[A]. deleteOne,
deleteMany and count gained an options parameter they did not have before.
This is the change the rest of the release depends on. A parameter cannot be added to a published method without
breaking binary compatibility, so every option that arrived in 2.0.0 — and every one that arrives later — would
have forced another major release. Carried in a value, they are additive forever.
WriteCommand.UpdateOne/UpdateMany/ReplaceOne carry the same options instead of a bare upsert, and
Repository.updateOne/findOneAndUpdate follow.
Projection is two types
include, exclude and withoutId moved off Projection[E] onto its cases, so a projection that mixes inclusion
with exclusion no longer compiles rather than silently discarding what you listed first:
Projection.empty[User].include(nameField).exclude(secretField) // 1.x: silently {"secret": 0}
// 2.0: does not compileProjection.empty now returns Projection.Everything[E] and Projection.excludeId returns Projection.Exclude[E].
Code that holds a projection as Projection[E] and builds on it must keep the narrower type instead. _id is still
the exception: include(...).withoutId gives {"field": 1, "_id": 0}.
Index stopped being a case class
It is a final class with a private constructor, so Index(...), .copy(...), unapply and the Product methods
are gone. Build one from Index.empty[E] or the named constructors and chain the builders, exactly as before. The
reason is the same as WireCodecConfig's in 1.1.0: a case class cannot gain a field without breaking
apply/copy again in every release, and index options keep arriving.
FakeMongoCollection moved to its own module
It now lives in the published mongo4s-testkit module, under mongo4s.testkit, alongside the new FakeRepository:
libraryDependencies += "org.mongo4s" %% "mongo4s-testkit" % "2.0.0" % TestIn 1.x it sat in mongo4s-repositories' test sources and was not published at all, so consumers could not use it.
BsonTypeName is a public enum
Field.hasType used to take a String — MongoDB's $type alias, which a typo turns into a filter that silently
matches nothing. It now takes BsonTypeName, which was internal before:
scoreField.hasType("int") // 1.x
scoreField.hasType(BsonTypeName.Int) // 2.0BsonError.TypeMismatch carries BsonTypeName for both expected and actual for the same reason; the rendered
message is unchanged.
New methods on MongoCollection and MongoDatabase
withReadConcern, withWriteConcern and withReadPreference are abstract, so anything implementing those traits
outside this library has to provide them. Stage gained LookupPipeline and GraphLookup cases, which matters
only if you pattern-match a Stage exhaustively.
Corrected behaviour
These fix results that were wrong before. No source change is needed, but the output differs:
- A sealed hierarchy stored as the root entity of a direct collection now reads back. The discriminator is located anywhere in the document instead of being required first, which the server never guaranteed.
- Writes under an unacknowledged write concern (
w=0) return empty results instead of throwingUnsupportedOperationException. SortandIndexlet the last mention of a field win its position, not just its direction.insertManyreturns ids ordered by command position rather than by the driver's map iteration order.aggregate(...).firstno longer appends$limitafter a terminal$out/$merge, which the server rejected.Decimal128is read exactly rather than throughDouble; theLongupper bound rejects2^63; non-finite values are an error instead of a thrownArithmeticException.- An explicit
BsonEncoder/BsonDecoderpair now outranks automaticWireCodecderivation. Previously the two were ambiguous and the type would not resolve at all. cats.data.Iordecodes its two sides by field name instead of by position.- kyo's
streamexplains the missingTaginstead of failing as aClassCastException.
Scala versions
Every module is built with Scala 3.9 LTS. There is no longer a subset of the library that a project on an older
compiler has to do without — and, by the same token, no way to consume any of it from 3.3 LTS, since TASTy does
not read forward.
mongo4s-kyo depends on a kyo release candidate. Until kyo reaches 1.0.0 final, that module sits outside the binary
compatibility promise the other artifacts make.
kyo ships Java 25 class files, so mongo4s-kyo needs JDK 25 twice over: to compile, since the kyo.Frame macro
runs inside the compiler and loads kyo's classes there, and to run, since an application on it loads them too. The
other modules' published class files target Java 17 and run on any JDK from 17 up. Since 3.0.0 the build
enforces it for every module: it refuses to load on an older JDK, naming the version it found. One JDK across
the build is deliberate — the alternative is publishing artifacts compiled against different JDKs, and a failure
that only appears once the fifth module is reached.