From 5205ee0623ae0275ed14ccef8c7cf87e0739177f Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Mon, 7 Jun 2021 16:46:43 +0200
Subject: [PATCH 1/5] Store solr migration state in a solr document

---
 .../scala/docspell/ftsclient/FtsClient.scala  | 29 ++++--
 .../scala/docspell/ftssolr/JsonCodec.scala    | 31 ++++++
 .../docspell/ftssolr/SolrFtsClient.scala      |  7 +-
 .../docspell/ftssolr/SolrMigration.scala      | 70 +++++++++++++
 .../scala/docspell/ftssolr/SolrQuery.scala    | 12 +++
 .../scala/docspell/ftssolr/SolrSetup.scala    | 98 +++++++++++--------
 .../scala/docspell/ftssolr/SolrUpdate.scala   |  7 ++
 .../scala/docspell/ftssolr/VersionDoc.scala   | 11 +++
 .../scala/docspell/joex/fts/FtsWork.scala     |  4 +-
 .../docspell/joex/fts/MigrationTask.scala     | 10 +-
 10 files changed, 224 insertions(+), 55 deletions(-)
 create mode 100644 modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala
 create mode 100644 modules/fts-solr/src/main/scala/docspell/ftssolr/VersionDoc.scala

diff --git a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
index dcf2d88f..2619eec6 100644
--- a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
+++ b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
@@ -17,12 +17,26 @@ import org.log4s.getLogger
   */
 trait FtsClient[F[_]] {
 
-  /** Initialization tasks. This is called exactly once at the very
-    * beginning when initializing the full-text index and then never
-    * again (except when re-indexing everything). It may be used to
-    * setup the database.
+  /** Initialization tasks. This can be used to setup the fulltext
+    * search engine. The implementation is expected to keep track of
+    * run migrations, so that running these is idempotent. For
+    * example, it may be run on each application start.
+    *
+    * Initialization may involve re-indexing all data, therefore it
+    * must run outside the scope of this client. The migration may
+    * include a task that applies any work and/or it can return a
+    * result indicating that after this task a re-index is necessary.
     */
-  def initialize: List[FtsMigration[F]]
+  def initialize: F[List[FtsMigration[F]]]
+
+  /** A list of initialization tasks that are meant to run when there
+    * was no setup at all or when re-creating the index.
+    *
+    * This is not run on startup, but only when required, for example
+    * when re-creating the entire index. These tasks don't need to
+    * preserve the data in the index.
+    */
+  def initializeNew: List[FtsMigration[F]]
 
   /** Run a full-text search. */
   def search(q: FtsQuery): F[FtsResult]
@@ -116,7 +130,10 @@ object FtsClient {
     new FtsClient[F] {
       private[this] val logger = Logger.log4s[F](getLogger)
 
-      def initialize: List[FtsMigration[F]] =
+      def initialize: F[List[FtsMigration[F]]] =
+        Sync[F].pure(Nil)
+
+      def initializeNew: List[FtsMigration[F]] =
         Nil
 
       def search(q: FtsQuery): F[FtsResult] =
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala
index 4c639668..a7b5d8a9 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala
@@ -53,6 +53,37 @@ trait JsonCodec {
   ): Encoder[TextData] =
     Encoder(_.fold(ae.apply, ie.apply))
 
+  implicit def versionDocEncoder: Encoder[VersionDoc] =
+    new Encoder[VersionDoc] {
+      final def apply(d: VersionDoc): Json =
+        Json.fromFields(
+          List(
+            (VersionDoc.Fields.id.name, d.id.asJson),
+            (
+              VersionDoc.Fields.currentVersion.name,
+              Map("set" -> d.currentVersion.asJson).asJson
+            )
+          )
+        )
+    }
+
+  implicit def decoderVersionDoc: Decoder[VersionDoc] =
+    new Decoder[VersionDoc] {
+      final def apply(c: HCursor): Decoder.Result[VersionDoc] =
+        for {
+          id      <- c.get[String](VersionDoc.Fields.id.name)
+          version <- c.get[Int](VersionDoc.Fields.currentVersion.name)
+        } yield VersionDoc(id, version)
+    }
+
+  implicit def versionDocDecoder: Decoder[Option[VersionDoc]] =
+    new Decoder[Option[VersionDoc]] {
+      final def apply(c: HCursor): Decoder.Result[Option[VersionDoc]] =
+        c.downField("response")
+          .get[List[VersionDoc]]("docs")
+          .map(_.headOption)
+    }
+
   implicit def docIdResultsDecoder: Decoder[DocIdResult] =
     new Decoder[DocIdResult] {
       final def apply(c: HCursor): Decoder.Result[DocIdResult] =
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala
index f8f7fd3b..b1c7e90d 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala
@@ -17,8 +17,11 @@ final class SolrFtsClient[F[_]: Effect](
     solrQuery: SolrQuery[F]
 ) extends FtsClient[F] {
 
-  def initialize: List[FtsMigration[F]] =
-    solrSetup.setupSchema
+  def initialize: F[List[FtsMigration[F]]] =
+    solrSetup.remainingSetup.map(_.map(_.value))
+
+  def initializeNew: List[FtsMigration[F]] =
+    solrSetup.setupSchema.map(_.value)
 
   def search(q: FtsQuery): F[FtsResult] =
     solrQuery.query(q)
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala
new file mode 100644
index 00000000..f1d02a66
--- /dev/null
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala
@@ -0,0 +1,70 @@
+package docspell.ftssolr
+import cats.implicits._
+import cats.{Applicative, Functor}
+
+import docspell.common._
+import docspell.ftsclient.FtsMigration
+
+final case class SolrMigration[F[_]](value: FtsMigration[F], dataChangeOnly: Boolean) {
+  def isSchemaChange: Boolean = !dataChangeOnly
+}
+
+object SolrMigration {
+  private val solrEngine = Ident.unsafe("solr")
+
+  def deleteData[F[_]: Functor](version: Int, solrUpdate: SolrUpdate[F]): SolrMigration[F] =
+    apply(version, "Delete all data", solrUpdate.delete("*:*", Option(0)))
+
+  def writeVersion[F[_]: Functor](
+      solrUpdate: SolrUpdate[F],
+      doc: VersionDoc
+  ): SolrMigration[F] =
+    apply(
+      Int.MaxValue,
+      s"Write current version: ${doc.currentVersion}",
+      solrUpdate.updateVersionDoc(doc)
+    )
+
+  def reIndexAll[F[_]: Applicative](
+      versionNumber: Int,
+      description: String
+  ): SolrMigration[F] =
+    SolrMigration(
+      FtsMigration(
+        versionNumber,
+        solrEngine,
+        description,
+        FtsMigration.Result.reIndexAll.pure[F]
+      ),
+      true
+    )
+
+  def indexAll[F[_]: Applicative](
+      versionNumber: Int,
+      description: String
+  ): SolrMigration[F] =
+    SolrMigration(
+      FtsMigration(
+        versionNumber,
+        solrEngine,
+        description,
+        FtsMigration.Result.indexAll.pure[F]
+      ),
+      true
+    )
+
+  def apply[F[_]: Functor](
+      version: Int,
+      description: String,
+      task: F[Unit]
+  ): SolrMigration[F] =
+    SolrMigration(
+      FtsMigration(
+        version,
+        solrEngine,
+        description,
+        task.map(_ => FtsMigration.Result.workDone)
+      ),
+      false
+    )
+}
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrQuery.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrQuery.scala
index ae286220..11c08954 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrQuery.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrQuery.scala
@@ -17,6 +17,8 @@ trait SolrQuery[F[_]] {
   def query(q: QueryData): F[FtsResult]
 
   def query(q: FtsQuery): F[FtsResult]
+
+  def findVersionDoc(id: String): F[Option[VersionDoc]]
 }
 
 object SolrQuery {
@@ -54,6 +56,16 @@ object SolrQuery {
         )
         query(fq)
       }
+
+      def findVersionDoc(id: String): F[Option[VersionDoc]] = {
+        val fields = List(
+          Field.id,
+          Field("current_version_i")
+        )
+        val query = QueryData(s"id:$id", "", 1, 0, fields, Map.empty)
+        val req   = Method.POST(query.asJson, url)
+        client.expect[Option[VersionDoc]](req)
+      }
     }
   }
 }
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala
index 95bcb3a7..2ed54e68 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala
@@ -4,7 +4,6 @@ import cats.effect._
 import cats.implicits._
 
 import docspell.common._
-import docspell.ftsclient.FtsMigration
 
 import _root_.io.circe._
 import _root_.io.circe.generic.semiauto._
@@ -16,12 +15,14 @@ import org.http4s.client.dsl.Http4sClientDsl
 
 trait SolrSetup[F[_]] {
 
-  def setupSchema: List[FtsMigration[F]]
+  def setupSchema: List[SolrMigration[F]]
+
+  def remainingSetup: F[List[SolrMigration[F]]]
 
 }
 
 object SolrSetup {
-  private val solrEngine = Ident.unsafe("solr")
+  private val versionDocId = "6d8f09f4-8d7e-4bc9-98b8-7c89223b36dd"
 
   def apply[F[_]: ConcurrentEffect](cfg: SolrConfig, client: Client[F]): SolrSetup[F] = {
     val dsl = new Http4sClientDsl[F] {}
@@ -32,62 +33,75 @@ object SolrSetup {
       val url = (Uri.unsafeFromString(cfg.url.asString) / "schema")
         .withQueryParam("commitWithin", cfg.commitWithin.toString)
 
-      def setupSchema: List[FtsMigration[F]] =
+      def remainingSetup: F[List[SolrMigration[F]]] =
+        for {
+          current <- SolrQuery(cfg, client).findVersionDoc(versionDocId)
+          migs = current match {
+            case None => setupSchema
+            case Some(ver) =>
+              val verDoc =
+                VersionDoc(versionDocId, allMigrations.map(_.value.version).max)
+              val solrUp = SolrUpdate(cfg, client)
+              val remain = allMigrations.filter(v => v.value.version > ver.currentVersion)
+              if (remain.isEmpty) remain
+              else remain :+ SolrMigration.writeVersion(solrUp, verDoc)
+          }
+        } yield migs
+
+      def setupSchema: List[SolrMigration[F]] = {
+        val verDoc = VersionDoc(versionDocId, allMigrations.map(_.value.version).max)
+        val solrUp = SolrUpdate(cfg, client)
+        val writeVersion = SolrMigration.writeVersion(solrUp, verDoc)
+        val deleteAll = SolrMigration.deleteData(0, solrUp)
+        val indexAll = SolrMigration.indexAll[F](Int.MaxValue, "Index all data")
+
+        deleteAll :: (allMigrations.filter(_.isSchemaChange) ::: List(indexAll, writeVersion))
+      }
+
+      private def allMigrations: List[SolrMigration[F]] =
         List(
-          FtsMigration[F](
+          SolrMigration[F](
             1,
-            solrEngine,
             "Initialize",
-            setupCoreSchema.map(_ => FtsMigration.Result.workDone)
+            setupCoreSchema
           ),
-          FtsMigration[F](
-            3,
-            solrEngine,
+          SolrMigration[F](
+            2,
             "Add folder field",
-            addFolderField.map(_ => FtsMigration.Result.workDone)
+            addFolderField
           ),
-          FtsMigration[F](
+          SolrMigration.indexAll(3, "Index all from database after adding folder field"),
+          SolrMigration[F](
             4,
-            solrEngine,
-            "Index all from database",
-            FtsMigration.Result.indexAll.pure[F]
-          ),
-          FtsMigration[F](
-            5,
-            solrEngine,
             "Add content_fr field",
-            addContentField(Language.French).map(_ => FtsMigration.Result.workDone)
+            addContentField(Language.French)
           ),
-          FtsMigration[F](
+          SolrMigration
+            .indexAll(5, "Index all from database after adding french content field"),
+          SolrMigration[F](
             6,
-            solrEngine,
-            "Index all from database",
-            FtsMigration.Result.indexAll.pure[F]
-          ),
-          FtsMigration[F](
-            7,
-            solrEngine,
             "Add content_it field",
-            addContentField(Language.Italian).map(_ => FtsMigration.Result.reIndexAll)
+            addContentField(Language.Italian)
           ),
-          FtsMigration[F](
+          SolrMigration.reIndexAll(7, "Re-Index after adding italian content field"),
+          SolrMigration[F](
             8,
-            solrEngine,
             "Add content_es field",
-            addContentField(Language.Spanish).map(_ => FtsMigration.Result.reIndexAll)
+            addContentField(Language.Spanish)
           ),
-          FtsMigration[F](
-            9,
-            solrEngine,
-            "Add more content fields",
-            addMoreContentFields.map(_ => FtsMigration.Result.reIndexAll)
-          ),
-          FtsMigration[F](
+          SolrMigration.reIndexAll(9, "Re-Index after adding spanish content field"),
+          SolrMigration[F](
             10,
-            solrEngine,
+            "Add more content fields",
+            addMoreContentFields
+          ),
+          SolrMigration.reIndexAll(11, "Re-Index after adding more content fields"),
+          SolrMigration[F](
+            12,
             "Add latvian content field",
-            addContentField(Language.Latvian).map(_ => FtsMigration.Result.reIndexAll)
-          )
+            addContentField(Language.Latvian)
+          ),
+          SolrMigration.reIndexAll(13, "Re-Index after adding latvian content field")
         )
 
       def addFolderField: F[Unit] =
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala
index b5b5e642..7fa7db41 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala
@@ -23,6 +23,8 @@ trait SolrUpdate[F[_]] {
 
   def updateFolder(itemId: Ident, collective: Ident, folder: Option[Ident]): F[Unit]
 
+  def updateVersionDoc(doc: VersionDoc): F[Unit]
+
   def delete(q: String, commitWithin: Option[Int]): F[Unit]
 }
 
@@ -48,6 +50,11 @@ object SolrUpdate {
         client.expect[Unit](req)
       }
 
+      def updateVersionDoc(doc: VersionDoc): F[Unit] = {
+        val req = Method.POST(List(doc).asJson, url)
+        client.expect[Unit](req)
+      }
+
       def updateFolder(
           itemId: Ident,
           collective: Ident,
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/VersionDoc.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/VersionDoc.scala
new file mode 100644
index 00000000..4d733340
--- /dev/null
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/VersionDoc.scala
@@ -0,0 +1,11 @@
+package docspell.ftssolr
+
+final case class VersionDoc(id: String, currentVersion: Int)
+
+object VersionDoc {
+
+  object Fields {
+    val id             = Field("id")
+    val currentVersion = Field("current_version_i")
+  }
+}
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
index 7ddfa99d..d50b0acc 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
@@ -18,7 +18,9 @@ object FtsWork {
   def reInitializeTasks[F[_]: Monad]: FtsWork[F] =
     FtsWork { ctx =>
       val migrations =
-        ctx.fts.initialize.map(fm => fm.changeResult(_ => FtsMigration.Result.workDone))
+        ctx.fts.initializeNew.map(fm =>
+          fm.changeResult(_ => FtsMigration.Result.workDone)
+        )
 
       NonEmptyList.fromList(migrations) match {
         case Some(nel) =>
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala b/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala
index c887b1a1..d8c4e4db 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala
@@ -20,8 +20,10 @@ object MigrationTask {
       .log[F, Unit](_.info(s"Running full-text-index migrations now"))
       .flatMap(_ =>
         Task(ctx =>
-          Migration[F](cfg, fts, ctx.store, ctx.logger)
-            .run(migrationTasks[F](fts))
+          for {
+            migs <- migrationTasks[F](fts)
+            res  <- Migration[F](cfg, fts, ctx.store, ctx.logger).run(migs)
+          } yield res
         )
       )
 
@@ -44,7 +46,7 @@ object MigrationTask {
       Some(DocspellSystem.migrationTaskTracker)
     )
 
-  def migrationTasks[F[_]: Effect](fts: FtsClient[F]): List[Migration[F]] =
-    fts.initialize.map(fm => Migration.from(fm))
+  def migrationTasks[F[_]: Effect](fts: FtsClient[F]): F[List[Migration[F]]] =
+    fts.initialize.map(_.map(fm => Migration.from(fm)))
 
 }

From 3ee0846e1920b9a67530c810c22f1a6184febb48 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Mon, 7 Jun 2021 17:53:47 +0200
Subject: [PATCH 2/5] Remove fts_migration table

It is now stored it SOLR instead.
---
 .../scala/docspell/joex/fts/Migration.scala   | 51 ++++----------
 .../h2/V1.24.0__drop_fts_migration.sql        |  1 +
 .../mariadb/V1.24.0__drop_fts_migration.sql   |  1 +
 .../V1.24.0__drop_fts_migration.sql           |  1 +
 .../store/records/RFtsMigration.scala         | 68 -------------------
 5 files changed, 16 insertions(+), 106 deletions(-)
 create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.24.0__drop_fts_migration.sql
 create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.24.0__drop_fts_migration.sql
 create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.24.0__drop_fts_migration.sql
 delete mode 100644 modules/store/src/main/scala/docspell/store/records/RFtsMigration.scala

diff --git a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
index 5ad9d028..7d1370d8 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
@@ -1,6 +1,6 @@
 package docspell.joex.fts
 
-import cats.data.{Kleisli, OptionT}
+import cats.data.Kleisli
 import cats.effect._
 import cats.implicits._
 import cats.{Applicative, FlatMap, Traverse}
@@ -8,13 +8,13 @@ import cats.{Applicative, FlatMap, Traverse}
 import docspell.common._
 import docspell.ftsclient._
 import docspell.joex.Config
-import docspell.store.records.RFtsMigration
-import docspell.store.{AddResult, Store}
+import docspell.store.Store
 
 /** Migrating the index from the previous version to this version.
   *
-  * The sql database stores the outcome of a migration task. If this
-  * task has already been applied, it is skipped.
+  * The migration asks the fulltext search client for a list of
+  * migration tasks to run. It may be empty when there is no migration
+  * required.
   */
 case class Migration[F[_]](
     version: Int,
@@ -35,41 +35,16 @@ object Migration {
       logger: Logger[F]
   ): Kleisli[F, List[Migration[F]], Unit] = {
     val ctx = FtsContext(cfg, store, fts, logger)
-    Kleisli(migs => Traverse[List].sequence(migs.map(applySingle[F](ctx))).map(_ => ()))
+    Kleisli { migs =>
+      if (migs.isEmpty) logger.info("No fulltext search migrations to run.")
+      else Traverse[List].sequence(migs.map(applySingle[F](ctx))).map(_ => ())
+    }
   }
 
   def applySingle[F[_]: Effect](ctx: FtsContext[F])(m: Migration[F]): F[Unit] = {
-    val insertRecord: F[Option[RFtsMigration]] =
-      for {
-        rec <- RFtsMigration.create(m.version, m.engine, m.description)
-        res <- ctx.store.add(
-          RFtsMigration.insert(rec),
-          RFtsMigration.exists(m.version, m.engine)
-        )
-        ret <- res match {
-          case AddResult.Success         => rec.some.pure[F]
-          case AddResult.EntityExists(_) => None.pure[F]
-          case AddResult.Failure(ex)     => Effect[F].raiseError(ex)
-        }
-      } yield ret
-
-    (for {
-      _   <- OptionT.liftF(ctx.logger.info(s"Apply ${m.version}/${m.description}"))
-      rec <- OptionT(insertRecord)
-      res <- OptionT.liftF(m.task.run(ctx).attempt)
-      ret <- OptionT.liftF(res match {
-        case Right(()) => ().pure[F]
-        case Left(ex) =>
-          ctx.logger.error(ex)(
-            s"Applying index migration ${m.version}/${m.description} failed"
-          ) *>
-            ctx.store.transact(RFtsMigration.deleteById(rec.id)) *> Effect[F]
-              .raiseError[Unit](
-                ex
-              )
-      })
-    } yield ret).getOrElseF(
-      ctx.logger.info(s"Migration ${m.version}/${m.description} already applied.")
-    )
+    for {
+      _   <- ctx.logger.info(s"Apply ${m.version}/${m.description}")
+      _ <- m.task.run(ctx)
+    } yield ()
   }
 }
diff --git a/modules/store/src/main/resources/db/migration/h2/V1.24.0__drop_fts_migration.sql b/modules/store/src/main/resources/db/migration/h2/V1.24.0__drop_fts_migration.sql
new file mode 100644
index 00000000..4cda5432
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/h2/V1.24.0__drop_fts_migration.sql
@@ -0,0 +1 @@
+DROP TABLE "fts_migration";
diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.24.0__drop_fts_migration.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.24.0__drop_fts_migration.sql
new file mode 100644
index 00000000..745e4cbb
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/mariadb/V1.24.0__drop_fts_migration.sql
@@ -0,0 +1 @@
+ DROP TABLE `fts_migration`;
diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.24.0__drop_fts_migration.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.24.0__drop_fts_migration.sql
new file mode 100644
index 00000000..4cda5432
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/postgresql/V1.24.0__drop_fts_migration.sql
@@ -0,0 +1 @@
+DROP TABLE "fts_migration";
diff --git a/modules/store/src/main/scala/docspell/store/records/RFtsMigration.scala b/modules/store/src/main/scala/docspell/store/records/RFtsMigration.scala
deleted file mode 100644
index 18183b1b..00000000
--- a/modules/store/src/main/scala/docspell/store/records/RFtsMigration.scala
+++ /dev/null
@@ -1,68 +0,0 @@
-package docspell.store.records
-
-import cats.data.NonEmptyList
-import cats.effect._
-import cats.implicits._
-
-import docspell.common._
-import docspell.store.qb.DSL._
-import docspell.store.qb._
-
-import doobie._
-import doobie.implicits._
-
-final case class RFtsMigration(
-    id: Ident,
-    version: Int,
-    ftsEngine: Ident,
-    description: String,
-    created: Timestamp
-)
-
-object RFtsMigration {
-
-  def create[F[_]: Sync](
-      version: Int,
-      ftsEngine: Ident,
-      description: String
-  ): F[RFtsMigration] =
-    for {
-      newId <- Ident.randomId[F]
-      now   <- Timestamp.current[F]
-    } yield RFtsMigration(newId, version, ftsEngine, description, now)
-
-  final case class Table(alias: Option[String]) extends TableDef {
-    val tableName = "fts_migration"
-
-    val id          = Column[Ident]("id", this)
-    val version     = Column[Int]("version", this)
-    val ftsEngine   = Column[Ident]("fts_engine", this)
-    val description = Column[String]("description", this)
-    val created     = Column[Timestamp]("created", this)
-
-    val all = NonEmptyList.of[Column[_]](id, version, ftsEngine, description, created)
-  }
-
-  val T = Table(None)
-  def as(alias: String): Table =
-    Table(Some(alias))
-
-  def insert(v: RFtsMigration): ConnectionIO[Int] =
-    DML
-      .insertFragment(
-        T,
-        T.all,
-        Seq(fr"${v.id},${v.version},${v.ftsEngine},${v.description},${v.created}")
-      )
-      .updateWithLogHandler(LogHandler.nop)
-      .run
-
-  def exists(vers: Int, engine: Ident): ConnectionIO[Boolean] =
-    run(select(count(T.id)), from(T), T.version === vers && T.ftsEngine === engine)
-      .query[Int]
-      .unique
-      .map(_ > 0)
-
-  def deleteById(rId: Ident): ConnectionIO[Int] =
-    DML.delete(T, T.id === rId)
-}

From ac7d00c28fbc9ff01fab168514c823efc0a66826 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Mon, 7 Jun 2021 21:17:29 +0200
Subject: [PATCH 3/5] Refactor re-index task

---
 .../scala/docspell/ftsclient/FtsClient.scala  |  7 ++---
 .../docspell/ftssolr/SolrMigration.scala      |  5 ++-
 .../scala/docspell/ftssolr/SolrSetup.scala    | 11 ++++---
 .../scala/docspell/joex/fts/FtsWork.scala     | 31 ++++++++++---------
 .../scala/docspell/joex/fts/Migration.scala   |  5 ++-
 .../scala/docspell/joex/fts/ReIndexTask.scala |  7 +----
 6 files changed, 33 insertions(+), 33 deletions(-)

diff --git a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
index 2619eec6..54065e3c 100644
--- a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
+++ b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala
@@ -29,12 +29,11 @@ trait FtsClient[F[_]] {
     */
   def initialize: F[List[FtsMigration[F]]]
 
-  /** A list of initialization tasks that are meant to run when there
-    * was no setup at all or when re-creating the index.
+  /** A list of initialization tasks that can be run when re-creating
+    * the index.
     *
     * This is not run on startup, but only when required, for example
-    * when re-creating the entire index. These tasks don't need to
-    * preserve the data in the index.
+    * when re-creating the entire index.
     */
   def initializeNew: List[FtsMigration[F]]
 
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala
index f1d02a66..d812beb3 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrMigration.scala
@@ -12,7 +12,10 @@ final case class SolrMigration[F[_]](value: FtsMigration[F], dataChangeOnly: Boo
 object SolrMigration {
   private val solrEngine = Ident.unsafe("solr")
 
-  def deleteData[F[_]: Functor](version: Int, solrUpdate: SolrUpdate[F]): SolrMigration[F] =
+  def deleteData[F[_]: Functor](
+      version: Int,
+      solrUpdate: SolrUpdate[F]
+  ): SolrMigration[F] =
     apply(version, "Delete all data", solrUpdate.delete("*:*", Option(0)))
 
   def writeVersion[F[_]: Functor](
diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala
index 2ed54e68..422c964f 100644
--- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala
+++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala
@@ -49,13 +49,14 @@ object SolrSetup {
         } yield migs
 
       def setupSchema: List[SolrMigration[F]] = {
-        val verDoc = VersionDoc(versionDocId, allMigrations.map(_.value.version).max)
-        val solrUp = SolrUpdate(cfg, client)
+        val verDoc       = VersionDoc(versionDocId, allMigrations.map(_.value.version).max)
+        val solrUp       = SolrUpdate(cfg, client)
         val writeVersion = SolrMigration.writeVersion(solrUp, verDoc)
-        val deleteAll = SolrMigration.deleteData(0, solrUp)
-        val indexAll = SolrMigration.indexAll[F](Int.MaxValue, "Index all data")
+        val deleteAll    = SolrMigration.deleteData(0, solrUp)
+        val indexAll     = SolrMigration.indexAll[F](Int.MaxValue, "Index all data")
 
-        deleteAll :: (allMigrations.filter(_.isSchemaChange) ::: List(indexAll, writeVersion))
+        deleteAll :: (allMigrations
+          .filter(_.isSchemaChange) ::: List(indexAll, writeVersion))
       }
 
       private def allMigrations: List[SolrMigration[F]] =
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
index d50b0acc..8180ab7e 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala
@@ -11,22 +11,23 @@ import docspell.joex.scheduler.Context
 import docspell.store.queries.{QAttachment, QItem}
 
 object FtsWork {
+  import syntax._
+
   def apply[F[_]](f: FtsContext[F] => F[Unit]): FtsWork[F] =
     Kleisli(f)
 
-  /** Runs all migration tasks unconditionally and inserts all data as last step. */
+  /** Runs migration tasks to re-create the index. */
   def reInitializeTasks[F[_]: Monad]: FtsWork[F] =
     FtsWork { ctx =>
-      val migrations =
-        ctx.fts.initializeNew.map(fm =>
-          fm.changeResult(_ => FtsMigration.Result.workDone)
-        )
-
+      val migrations = ctx.fts.initializeNew
       NonEmptyList.fromList(migrations) match {
         case Some(nel) =>
           nel
-            .map(fm => from[F](fm.task))
-            .append(insertAll[F](None))
+            .map(fm =>
+              log[F](_.debug(s"Apply (${fm.engine.id}): ${fm.description}")) ++ from[F](
+                fm.task
+              )
+            )
             .reduce(semigroup[F])
             .run(ctx)
         case None =>
@@ -34,8 +35,6 @@ object FtsWork {
       }
     }
 
-  /**
-    */
   def from[F[_]: FlatMap: Applicative](t: F[FtsMigration.Result]): FtsWork[F] =
     Kleisli.liftF(t).flatMap(transformResult[F])
 
@@ -67,16 +66,20 @@ object FtsWork {
   def log[F[_]](f: Logger[F] => F[Unit]): FtsWork[F] =
     FtsWork(ctx => f(ctx.logger))
 
-  def clearIndex[F[_]](coll: Option[Ident]): FtsWork[F] =
+  def clearIndex[F[_]: FlatMap](coll: Option[Ident]): FtsWork[F] =
     coll match {
       case Some(cid) =>
-        FtsWork(ctx => ctx.fts.clear(ctx.logger, cid))
+        log[F](_.debug(s"Clearing index data for collective '${cid.id}'")) ++ FtsWork(
+          ctx => ctx.fts.clear(ctx.logger, cid)
+        )
       case None =>
-        FtsWork(ctx => ctx.fts.clearAll(ctx.logger))
+        log[F](_.debug("Clearing all index data!")) ++ FtsWork(ctx =>
+          ctx.fts.clearAll(ctx.logger)
+        )
     }
 
   def insertAll[F[_]: FlatMap](coll: Option[Ident]): FtsWork[F] =
-    FtsWork
+    log[F](_.info("Inserting all data to index")) ++ FtsWork
       .all(
         FtsWork(ctx =>
           ctx.fts.indexData(
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
index 7d1370d8..ad71c1ad 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala
@@ -41,10 +41,9 @@ object Migration {
     }
   }
 
-  def applySingle[F[_]: Effect](ctx: FtsContext[F])(m: Migration[F]): F[Unit] = {
+  def applySingle[F[_]: Effect](ctx: FtsContext[F])(m: Migration[F]): F[Unit] =
     for {
-      _   <- ctx.logger.info(s"Apply ${m.version}/${m.description}")
+      _ <- ctx.logger.info(s"Apply ${m.version}/${m.description}")
       _ <- m.task.run(ctx)
     } yield ()
-  }
 }
diff --git a/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala b/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala
index 3e983575..66751b1b 100644
--- a/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala
@@ -40,12 +40,7 @@ object ReIndexTask {
             FtsWork.insertAll[F](collective)
 
         case None =>
-          FtsWork
-            .clearIndex(None)
-            .recoverWith(
-              FtsWork.log[F](_.info("Clearing data failed. Continue re-indexing."))
-            ) ++
-            FtsWork.log[F](_.info("Running index initialize")) ++
+          FtsWork.log[F](_.info("Running re-create index")) ++
             FtsWork.reInitializeTasks[F]
       })
 }

From 481d31ee747e3ca820a160150c19701b6c466f06 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Mon, 7 Jun 2021 21:30:41 +0200
Subject: [PATCH 4/5] Hide content search field when fulltext is not enabled

---
 modules/webapp/src/main/elm/Page/Home/View2.elm | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm
index f1706ecc..7f8767aa 100644
--- a/modules/webapp/src/main/elm/Page/Home/View2.elm
+++ b/modules/webapp/src/main/elm/Page/Home/View2.elm
@@ -100,7 +100,7 @@ itemsBar texts flags settings model =
 
 
 defaultMenuBar : Texts -> Flags -> UiSettings -> Model -> Html Msg
-defaultMenuBar texts _ settings model =
+defaultMenuBar texts flags settings model =
     let
         btnStyle =
             S.secondaryBasicButton ++ " text-sm"
@@ -127,12 +127,20 @@ defaultMenuBar texts _ settings model =
                     , Maybe.map value searchInput
                         |> Maybe.withDefault (value "")
                     , class (String.replace "rounded" "" S.textInput)
-                    , class "py-1 text-sm border-r-0 rounded-l"
+                    , class "py-2 text-sm"
+                    , if flags.config.fullTextSearchEnabled then
+                        class " border-r-0 rounded-l"
+
+                      else
+                        class "border rounded"
                     ]
                     []
                 , a
                     [ class S.secondaryBasicButtonPlain
                     , class "text-sm px-4 py-2 border rounded-r"
+                    , classList
+                        [ ( "hidden", not flags.config.fullTextSearchEnabled )
+                        ]
                     , href "#"
                     , onClick ToggleSearchType
                     ]

From 5d05d19d32eb81ed6593c54edf5538a0a1d2fe9c Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Mon, 7 Jun 2021 21:55:00 +0200
Subject: [PATCH 5/5] Update docs

---
 website/site/content/docs/configure/_index.md | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/website/site/content/docs/configure/_index.md b/website/site/content/docs/configure/_index.md
index 8438f196..6629a0c1 100644
--- a/website/site/content/docs/configure/_index.md
+++ b/website/site/content/docs/configure/_index.md
@@ -191,13 +191,17 @@ it is empty (the default), this call is disabled (all admin routes).
 Otherwise, the POST request will submit a system task that is executed
 by a joex instance eventually.
 
-Using this endpoint, the index will be re-created. This is sometimes
-necessary, for example if you upgrade SOLR or delete the core to
-provide a new one (see
+Using this endpoint, the entire index (including the schema) will be
+re-created. This is sometimes necessary, for example if you upgrade
+SOLR or delete the core to provide a new one (see
 [here](https://solr.apache.org/guide/8_4/reindexing.html) for
-details). Note that a collective can also re-index their data using a
-similiar endpoint; but this is only deleting their data and doesn't do
-a full re-index.
+details). Another way is to restart docspell (while clearing the
+index). If docspell detects an empty index at startup, it will submit
+a task to build the index automatically.
+
+Note that a collective can also re-index their data using a similiar
+endpoint; but this is only deleting their data and doesn't do a full
+re-index.
 
 The solr index doesn't contain any new information, it can be
 regenerated any time using the above REST call. Thus it doesn't need