SENDLINE open source
open source / mongo4s / compatibility & migration

Compatibility

← back to the README

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 Effect and RsBridge carry 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.

scala
// 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:

scala
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 $unset rather than $set: null, and setOnInsert(None) adds nothing. A query matching {field: null} still finds the unset field; one asking $type: "null" no longer does. setNull writes the null where it is wanted.
  • A filter that matches nothing renders {_id: {$in: []}} rather than {$nor: [{}]} — in(Nil), containsAll(Nil), Filter.none, an empty KeyRef.inFilter, Filter.Or(Nil) and !Filter.all. A test that compared the rendered document needs the new one.
  • MongoError files more failures where operations teams look for them: a client timeoutMS that ran out is ExecutionTimeout, not Unavailable; refused credentials Unauthorized; a stepped-down primary, a recovering node, a cleared pool and a closed server Unavailable. A match that relied on the old case moves.
  • Decoding failures carry their path. A WireCodec failure, and a BsonDecoder collection's, is wrapped in BsonError.Nested segments naming the field, position or key; a match on a bare TypeMismatch finds it inside.
  • Filter has a new case, TextSearch, which an exhaustive match over Filter has to handle.
  • Accumulator.sum and avg over a field compile only on a number or an Option of one. Code compiled against 5.0 keeps linking; code that summed a non-numeric field — which the server answers with 0 — no longer compiles.
  • where takes only an array filter identifier, and refuses Field.self, which used to render $[].
  • Refusals that were thrown where an operation was built are raised in F — an aggregation or distinct whose 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. A try around building the call no longer sees them; attempt on the effect does.
  • On zio, a defect stays a defect. attempt and handleErrorWith recover failures only, and a throw inside map, flatMap or suspend is 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, $all on 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, $pull by a document, number widths in the array operators, distinct and $group, projections through arrays, an _id stored first, modifiedCount of an identical replacement, every bulk write error as a BulkWriteFailed, and explain and watch refused 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) — by is a GroupKey (All, Path, Evaluated, Compound) instead of an Option[FieldPath].
  • Stage.AddFields(fields) holds Computed values; Stage.Redact(expression) an Expr; Stage.Unwind gained includeArrayIndex and Stage.UnionWith a pipeline.
  • Filter.Expr(expression) holds an Expr[T, Boolean]. Filter.expr(bsonDocument) still works, and wraps the document in Expr.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.Include gained the computed fields it carries.
  • WriteCommand.DeleteOne(filter, options) and DeleteMany(filter, options) carry DeleteOptions, defaulting to none, so WriteCommand.DeleteOne(filter) still compiles; a pattern on the old arity does not.
  • Accumulator.Expression and Stage have 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 sort on updateMany — or on a bulk UpdateMany — is refused with an IllegalArgumentException. The driver would have dropped it, since a multi-document update has no one document to pick.
  • ensureKeyIndex on a key stored as _id builds 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 _id is refused.
  • The fake refuses more: positional updates, tailable cursors, aggregation expressions, Atlas Search, a write for the client-level bulkWrite, a unique index on _id alone, 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 UnsupportedOperationException before, and a replacement upsert takes its _id from 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, Update and Stage have new cases. Code that matches over one of them exhaustively gets a warning naming the new cases. Nothing that only builds them is affected.
  • setWindowFields takes (String, WindowField[E])*. WindowOutput is a WindowField, so every call compiles as it did; only code that took SetWindowFields.output apart and treated each entry as a WindowOutput needs a match on WindowOutput versus WindowFunction.

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:

scala
// 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:

scala
// 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:

scala
// 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 a snake_case enum entity, or on an entity codec wrapped in iemap, 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.result counts the earlier batches.
  • A transaction commit that ran out of maxCommitTime is no longer retried, and whole-transaction retries pause briefly between attempts, as the driver specification requires.
  • A findOneAndUpdate projection you pass to a repository wins over its defaultProjection.
  • Effect has a sleep with 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:

scala
// 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:

scala
client.withTransaction(body, TransactionOptions.withoutRetries) // exactly the 2.x behaviour

Both 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:

scala
WatchOptions[User](pipeline = stages)                 // 2.x
WatchOptions.default[User].withPipeline(stages)       // 3.0

Reading 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

scala
// 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

scala
// 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:

scala
// 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

scala
database.getDirectCollection[Person]("people", FieldNaming.snakeCase)   // 2.x
database.getDirectCollection[Person]("people")                          // 3.0

The 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:

scala
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:

scala
final case class Address(city: String)                                      // 2.x: derived on demand
final case class Address(city: String) derives WireCodec                    // 3.0

The 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:

scala
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:

scala
case Projection.Include(fields, withId)         => ...  // 2.x
case Projection.Include(fields, withId, slices) => ...  // 3.0

Constructing 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:

scala
// 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:

scala
given RsBridgeConfig = RsBridgeConfig(bufferSize = 512, strictSingleResult = true)          // 2.x
given RsBridgeConfig = RsBridgeConfig.default.withBufferSize(512).withStrictSingleResult    // 3.0

Reading 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:

scala
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.0

The 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:

scala
Projection.empty[User].include(nameField).exclude(secretField) // 1.x: silently {"secret": 0}
                                                              // 2.0: does not compile

Projection.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:

scala
libraryDependencies += "org.mongo4s" %% "mongo4s-testkit" % "2.0.0" % Test

In 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:

scala
scoreField.hasType("int")                 // 1.x
scoreField.hasType(BsonTypeName.Int)      // 2.0

BsonError.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 throwing UnsupportedOperationException.
  • Sort and Index let the last mention of a field win its position, not just its direction.
  • insertMany returns ids ordered by command position rather than by the driver's map iteration order.
  • aggregate(...).first no longer appends $limit after a terminal $out/$merge, which the server rejected.
  • Decimal128 is read exactly rather than through Double; the Long upper bound rejects 2^63; non-finite values are an error instead of a thrown ArithmeticException.
  • An explicit BsonEncoder/BsonDecoder pair now outranks automatic WireCodec derivation. Previously the two were ambiguous and the type would not resolve at all.
  • cats.data.Ior decodes its two sides by field name instead of by position.
  • kyo's stream explains the missing Tag instead of failing as a ClassCastException.

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.

built from COMPATIBILITY.md at v6.0.0 edit this page on GitHub