From 87a6fd539800a16285cc682ab0ae299d9629532e Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Mon, 11 May 2026 21:28:06 -0500 Subject: [PATCH] Baseline project from which to work. --- .forgejo/workflows/pull_request.yaml | 40 ++ .forgejo/workflows/release.yaml | 54 +++ .gitignore | 4 + .pre-commit-config.yaml | 16 + .scalafmt.conf | 72 ++++ README.md | 68 ++++ build.sbt | 111 ++++++ modules/api/src/main/resources/logback.xml | 17 + .../scala/gs/respite/api/RespiteApi.scala | 34 ++ .../main/scala/gs/respite/db/KeySpaceDb.scala | 98 +++++ .../main/scala/gs/respite/db/MemoryDb.scala | 209 +++++++++++ .../gs/respite/db/MemoryKeySpaceDb.scala | 59 +++ .../main/scala/gs/respite/db/RespiteDb.scala | 192 ++++++++++ .../src/main/scala/gs/respite/model/Key.scala | 299 +++++++++++++++ .../gs/respite/model/KeySpaceConstraint.scala | 36 ++ .../scala/gs/respite/model/KeySpaceName.scala | 41 ++ .../main/scala/gs/respite/model/KeyType.scala | 50 +++ .../gs/respite/model/RespiteException.scala | 35 ++ .../main/scala/gs/respite/model/Value.scala | 349 ++++++++++++++++++ .../scala/gs/respite/model/ValueType.scala | 58 +++ project/build.properties | 1 + project/plugins.sbt | 33 ++ 22 files changed, 1876 insertions(+) create mode 100644 .forgejo/workflows/pull_request.yaml create mode 100644 .forgejo/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .scalafmt.conf create mode 100644 README.md create mode 100644 build.sbt create mode 100644 modules/api/src/main/resources/logback.xml create mode 100644 modules/api/src/main/scala/gs/respite/api/RespiteApi.scala create mode 100644 modules/db/src/main/scala/gs/respite/db/KeySpaceDb.scala create mode 100644 modules/db/src/main/scala/gs/respite/db/MemoryDb.scala create mode 100644 modules/db/src/main/scala/gs/respite/db/MemoryKeySpaceDb.scala create mode 100644 modules/db/src/main/scala/gs/respite/db/RespiteDb.scala create mode 100644 modules/model/src/main/scala/gs/respite/model/Key.scala create mode 100644 modules/model/src/main/scala/gs/respite/model/KeySpaceConstraint.scala create mode 100644 modules/model/src/main/scala/gs/respite/model/KeySpaceName.scala create mode 100644 modules/model/src/main/scala/gs/respite/model/KeyType.scala create mode 100644 modules/model/src/main/scala/gs/respite/model/RespiteException.scala create mode 100644 modules/model/src/main/scala/gs/respite/model/Value.scala create mode 100644 modules/model/src/main/scala/gs/respite/model/ValueType.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt diff --git a/.forgejo/workflows/pull_request.yaml b/.forgejo/workflows/pull_request.yaml new file mode 100644 index 0000000..ef3461d --- /dev/null +++ b/.forgejo/workflows/pull_request.yaml @@ -0,0 +1,40 @@ +on: + pull_request: + types: [opened, synchronize, reopened] + +defaults: + run: + shell: bash + +jobs: + library_snapshot: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Test Application Snapshot' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message (SNAPSHOT)" + sbtn -Dsnapshot=true "api/calVerInfo" + - name: 'Unit Tests and Code Coverage' + run: | + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + sbtn coverageAggregate diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml new file mode 100644 index 0000000..ba6fd0b --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -0,0 +1,54 @@ +on: + push: + branches: + - main + +defaults: + run: + shell: bash + +jobs: + application_release: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Release Application' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message" + sbtn -Drelease=true api/calVerInfo + - name: 'Unit Tests and Code Coverage' + run: | + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + sbtn coverageAggregate + - name: 'Publish Release' + run: | + sbtn coverageOff + sbtn clean + sbtn api/calVerWriteVersionToFile + selected_version="$(cat .version)" + echo "PRODUCING A RELEASE IS CURRENTLY TURNED OFF -- $selected_version" + - name: 'Create Git Tag' + run: | + selected_version="$(cat .version)" + echo "TAGGING IS CURRENTLY TURNED OFF -- $selected_version" + #git tag "$selected_version" + #git push origin "$selected_version" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e79824 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +project/target/ +project/project/ +.version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fbcf79c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: fix-byte-order-marker + - id: mixed-line-ending + args: ['--fix=lf'] + description: Enforces using only 'LF' line endings. + - id: trailing-whitespace + - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala + rev: v1.0.1 + hooks: + - id: scalafmt diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..9c7929b --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,72 @@ +// See: https://github.com/scalameta/scalafmt/tags for the latest tags. +version = 3.8.1 +runner.dialect = scala3 +maxColumn = 80 + +rewrite { + rules = [RedundantBraces, RedundantParens, Imports, SortModifiers] + imports.expand = true + imports.sort = scalastyle + redundantBraces.ifElseExpressions = true + redundantBraces.stringInterpolation = true +} + +indent { + main = 2 + callSite = 2 + defnSite = 2 + extendSite = 4 + withSiteRelativeToExtends = 2 + commaSiteRelativeToExtends = 2 +} + +align { + preset = more + openParenCallSite = false + openParenDefnSite = false +} + +newlines { + implicitParamListModifierForce = [before,after] + topLevelStatementBlankLines = [ + { + blanks = 1 + } + ] + afterCurlyLambdaParams = squash +} + +danglingParentheses { + defnSite = true + callSite = true + ctrlSite = true + exclude = [] +} + +verticalMultiline { + atDefnSite = true + arityThreshold = 2 + newlineAfterOpenParen = true +} + +comments { + wrap = standalone +} + +docstrings { + style = "SpaceAsterisk" + oneline = unfold + wrap = yes + forceBlankLineBefore = true +} + +project { + excludePaths = [ + "glob:**target/**", + "glob:**.metals/**", + "glob:**.bloop/**", + "glob:**.bsp/**", + "glob:**metals.sbt", + "glob:**.git/**" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9b92d5 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# respite + +> _respite_ +> An interruption in the intensity or amount of something. + +This project is a way to disrupt the stress. It is also an in-memory key/value +store. + +## At a Glance + +- Single instance (no clustered mode) +- Concurrency support and atomic operations +- No Schema +- No Pub/Sub +- Supports specific types +- Supports key and value constraints +- HTTP API + +### Supported Key Types + +The sizes below omit the size of heap references (64 bits). + +| Type | Size in Bytes | Description | +| -------- | ------------- | ---------------------- | +| `int64` | `8` | Signed 64-bit integer. | +| `string` | Variable | UTF-16 | +| `uuid` | `16` | N/A | +| `date` | `8` | Year, month, day. | + +### Supported Value Types + +The sizes below omit the size of heap references (64 bits). Booleans do not have +precisely defined sizes on the JVM, but 1 byte is typical. + +| Type | Size in Bytes | Description | +| -------- | ------------- | ---------------------- | +| `int64` | `8` | Signed 64-bit integer. | +| `string` | Variable | UTF-16 | +| `uuid` | `16` | N/A | +| `date` | `8` | Year, month, day. | +| `bool` | `1` | True/false. | +| `byte` | Variable | Any byte array. | + +### Supported Operations + +- Get +- Set +- Delete +- Increment +- Decrement +- Compare and Swap +- Insert +- Contains + +### Supported Constraints + +Any of the following constraints may be applied to a single key space. If any +operation on the key space violates a constraint, that operation fails. + +| Constraint | Description | +| ------------------- | -------------------------------------------------------------------------- | +| `single_key_type` | Only the given type is accepted for keys. | +| `single_value_type` | Only the given type is accepted for values. | +| `write_once` | Each key may be written exactly once. Deletes and reassignments will fail. | + +## HTTP API + +Respite provides an HTTP API that uses JSON to transmit data. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..0963b5d --- /dev/null +++ b/build.sbt @@ -0,0 +1,111 @@ +val scala3: String = "3.8.3" + +ThisBuild / scalaVersion := scala3 +ThisBuild / gsProjectName := "respite" + +ThisBuild / externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +lazy val sharedSettings = Seq( + scalaVersion := scala3, + version := calVer.value, + publish / skip := true, + publishLocal / skip := true, + publishArtifact := false +) + +val Deps = new { + val Cats = new { + val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0" + val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.7.0" + } + + val Fs2 = new { + private val Version: String = "3.13.0" + + val Core: ModuleID = "co.fs2" %% "fs2-core" % Version + val IO: ModuleID = "co.fs2" %% "fs2-io" % Version + } + + val Gs = new { + val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.2" + val Std: ModuleID = "gs" %% "gs-std-core-v0" % "0.1.2" + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.1" + } + + val Http4s = new { + private val Version: String = "1.0.0-M45" + + val Core: ModuleID = "org.http4s" %% "http4s-core" % Version + val Dsl: ModuleID = "org.http4s" %% "http4s-dsl" % Version + val Circe: ModuleID = "org.http4s" %% "http4s-circe" % Version + val EmberServer: ModuleID = "org.http4s" %% "http4s-ember-server" % Version + } + + val Log4Cats = new { + val Slf4j: ModuleID = "org.typelevel" %% "log4cats-slf4j" % "2.8.0" + } + + val LogbackClassic: ModuleID = "ch.qos.logback" % "logback-classic" % "1.5.32" + + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.3.0" +} + +lazy val testSettings = Seq( + libraryDependencies ++= Seq( + Deps.MUnit % Test, + Deps.Gs.Datagen % Test + ) +) + +lazy val respite = project + .in(file(".")) + .aggregate(model, db, api) + .settings(sharedSettings) + .settings(name := s"${gsProjectName.value}") + +lazy val model = project + .in(file("modules/model")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-model") + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Core, + Deps.Gs.Uuid, + Deps.Gs.Std + ) + ) + +lazy val db = project + .in(file("modules/db")) + .dependsOn(model) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-db") + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Core, + Deps.Cats.Effect + ) + ) + +lazy val api = project + .in(file("modules/api")) + .dependsOn(model, db) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-api") + .settings(fork := true) + .settings( + libraryDependencies ++= Seq( + Deps.Http4s.Core, + Deps.Http4s.Dsl, + Deps.Http4s.Circe, + Deps.Http4s.EmberServer, + Deps.Log4Cats.Slf4j, + Deps.LogbackClassic + ) + ) diff --git a/modules/api/src/main/resources/logback.xml b/modules/api/src/main/resources/logback.xml new file mode 100644 index 0000000..0439cf1 --- /dev/null +++ b/modules/api/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n + + + + + + + diff --git a/modules/api/src/main/scala/gs/respite/api/RespiteApi.scala b/modules/api/src/main/scala/gs/respite/api/RespiteApi.scala new file mode 100644 index 0000000..053ba7a --- /dev/null +++ b/modules/api/src/main/scala/gs/respite/api/RespiteApi.scala @@ -0,0 +1,34 @@ +package gs.respite.api + +import cats.effect.ExitCode +import cats.effect.IO +import cats.effect.IOApp +import com.comcast.ip4s._ +import org.http4s.HttpRoutes +import org.http4s.Method +import org.http4s.dsl.io.* +import org.http4s.ember.server.EmberServerBuilder +import org.typelevel.log4cats.LoggerFactory +import org.typelevel.log4cats.slf4j.Slf4jFactory + +object RespiteApi extends IOApp: + given CanEqual[Method, Method] = CanEqual.derived + given CanEqual[org.http4s.Uri.Path, org.http4s.Uri.Path] = CanEqual.derived + + implicit val loggerFactory: LoggerFactory[IO] = Slf4jFactory.create[IO] + + private val RespiteService = HttpRoutes + .of[IO] { case GET -> Root => + Ok("Hello, Respite!") + } + .orNotFound + + override def run(args: List[String]): cats.effect.IO[ExitCode] = + EmberServerBuilder + .default[IO] + .withHost(ipv4"0.0.0.0") + .withPort(port"8080") + .withHttpApp(RespiteService) + .build + .use(_ => IO.never) + .as(ExitCode.Success) diff --git a/modules/db/src/main/scala/gs/respite/db/KeySpaceDb.scala b/modules/db/src/main/scala/gs/respite/db/KeySpaceDb.scala new file mode 100644 index 0000000..2a8441e --- /dev/null +++ b/modules/db/src/main/scala/gs/respite/db/KeySpaceDb.scala @@ -0,0 +1,98 @@ +package gs.respite.db + +import cats.Applicative +import cats.effect.Async +import gs.respite.model.KeySpaceName + +/** Database for key spaces -- each key space is a [[RespiteDb]]. Keys may not + * be duplicated _within_ a key space, but _may_ be duplicated across key + * spaces. + * + * Each key space is a named, isolated, set of data. + */ +trait KeySpaceDb[F[_]]: + /** @return + * The default key space. + */ + def default: RespiteDb[F] + + /** Get the key space with the given name. + * + * @param name + * The name of the key space. + * @return + * The [[RespiteDb]] that stores the key space, or `None` if the name does + * not exist. + */ + def get(name: KeySpaceName): F[Option[RespiteDb[F]]] + + /** Create a new key space with the given name. Fails if the name already + * exists. + * + * @param name + * The unique name of the key space. + * @param db + * The database implementation for this key space. + * @return + * True if creation succeeds, false otherwise. + */ + def create( + name: KeySpaceName, + db: RespiteDb[F] + ): F[Boolean] + + /** Delete the key space with the given name. Implicitly removes all data + * stored within the key space. + * + * @param name + * The name of the key space to delete. + * @return + * Side-effect describing the operation. + */ + def delete(name: KeySpaceName): F[Unit] + +end KeySpaceDb + +object KeySpaceDb: + + /** @return + * An instance of [[KeySpaceDb]] that does nothing. + */ + def noop[F[_]: Applicative]: KeySpaceDb[F] = new Noop[F] + + /** @return + * An instance of [[MemoryKeySpaceDb]] that stores data in memory and + * supports concurrent access. + */ + def memory[F[_]: Async]: F[KeySpaceDb[F]] = + MemoryKeySpaceDb.initialize[F] + + /** An implementation of [[KeySpaceDb]] that does nothing. + */ + final class Noop[F[_]: Applicative] extends KeySpaceDb[F]: + + private val defaultDb = RespiteDb.noop[F](KeySpaceName.Default) + + /** @inheritDocs + */ + override def default: RespiteDb[F] = defaultDb + + /** @inheritDocs + */ + override def get(name: KeySpaceName): F[Option[RespiteDb[F]]] = + Applicative[F].pure(None) + + /** @inheritDocs + */ + override def create( + name: KeySpaceName, + db: RespiteDb[F] + ): F[Boolean] = + Applicative[F].pure(false) + + /** @inheritDocs + */ + override def delete(name: KeySpaceName): F[Unit] = + Applicative[F].unit + +end KeySpaceDb diff --git a/modules/db/src/main/scala/gs/respite/db/MemoryDb.scala b/modules/db/src/main/scala/gs/respite/db/MemoryDb.scala new file mode 100644 index 0000000..c728c08 --- /dev/null +++ b/modules/db/src/main/scala/gs/respite/db/MemoryDb.scala @@ -0,0 +1,209 @@ +package gs.respite.db + +import cats.effect.Async +import cats.effect.std.MapRef +import cats.syntax.all.* +import gs.respite.model.ConstraintException +import gs.respite.model.Int64Value +import gs.respite.model.Key +import gs.respite.model.KeySpaceName +import gs.respite.model.KeyType +import gs.respite.model.Value +import gs.respite.model.ValueType + +/** Implementation of [[RespiteDb]] base on Cats Effect and in-memory + * concurrency primitives. Safe for concurrent use across threads. + * + * Use the `initialize` function to instantiate this class. + * + * @param kv + * The underlying key-value store. + */ +final class MemoryDb[F[_]: Async] private ( + private val kv: MapRef[F, Key, Option[Value]], + val keySpace: KeySpaceName, + val singleKeyType: Option[KeyType], + val singleValueType: Option[ValueType], + val isWriteOnce: Boolean +) extends RespiteDb[F]: + + val enforceSingleKeyType: (Key) => F[Key] = + singleKeyType match + case None => key => Async[F].pure(key) + case Some(kt) => + k => + if k.keyType == kt then Async[F].pure(k) + else + Async[F].raiseError( + ConstraintException.InvalidKeyException(k, kt, keySpace) + ) + + val enforceSingleValueType: (Key, Value) => F[Value] = + singleValueType match + case None => + ( + _, + value + ) => Async[F].pure(value) + case Some(vt) => + ( + k, + v + ) => + if v.valueType == vt then Async[F].pure(v) + else + Async[F].raiseError( + ConstraintException.InvalidValueException(k, v, vt, keySpace) + ) + + /** @inheritDocs + */ + override def get(key: Key): F[Option[Value]] = + for + k <- enforceSingleKeyType(key) + result <- kv.apply(k).get + yield result + + /** @inheritDocs + */ + override def set( + key: Key, + value: Value + ): F[Option[Value]] = + for + k <- enforceSingleKeyType(key) + v <- enforceSingleValueType(k, value) + result <- kv.apply(k).getAndUpdate { + case None => Some(v) + case Some(oldValue) => + // TODO: Rip out to pre-calculated function + if isWriteOnce then + throw ConstraintException.WriteOnceException(key, keySpace) + else Some(v) + } + yield result + + /** @inheritDocs + */ + override def delete(key: Key): F[Option[Value]] = + if isWriteOnce then + Async[F].raiseError(ConstraintException.WriteOnceException(key, keySpace)) + else + for + k <- enforceSingleKeyType(key) + result <- kv.apply(k).get.flatMap(v => kv.unsetKey(k).as(v)) + yield result + + /** @inheritDocs + */ + override def increment(key: Key): F[Option[Value]] = + for + k <- enforceSingleKeyType(key) + result <- kv.apply(k).updateAndGet { + case Some(value) => + // TODO: Rip out to pre-calculated function + if isWriteOnce then + throw ConstraintException.WriteOnceException(key, keySpace) + else + value match + case lv: Int64Value => Some(lv.increment()) + case _ => None + case None => None + } + yield result + + /** @inheritDocs + */ + override def decrement(key: Key): F[Option[Value]] = + for + k <- enforceSingleKeyType(key) + result <- kv.apply(key).updateAndGet { + case Some(value) => + // TODO: Rip out to pre-calculated function + if isWriteOnce then + throw ConstraintException.WriteOnceException(key, keySpace) + else + value match + case lv: Int64Value => Some(lv.decrement()) + case _ => None + case None => None + } + yield result + + /** @inheritDocs + */ + override def cas( + key: Key, + expectedValue: Value, + newValue: Value + ): F[Boolean] = + if isWriteOnce then + Async[F].raiseError(ConstraintException.WriteOnceException(key, keySpace)) + else + for + k <- enforceSingleKeyType(key) + ev <- enforceSingleValueType(k, expectedValue) + nv <- enforceSingleValueType(k, newValue) + result <- kv + .apply(k) + .updateAndGet { + case Some(oldValue) => + if oldValue == ev then Some(nv) + else Some(oldValue) + case None => None + } + .map { + case None => + // This operation explicitly does not work on unset keys. + false + case Some(outcome) => + // If the outcome is the `newValue`, that means we successfully + // updated using CAS. + outcome == newValue + } + yield result + + /** @inheritDocs + */ + override def insert( + key: Key, + value: Value + ): F[Option[Value]] = + for + k <- enforceSingleKeyType(key) + v <- enforceSingleValueType(k, value) + result <- kv.apply(k).updateAndGet { + case None => Some(v) + case Some(alreadyThere) => Some(alreadyThere) + } + yield result + + /** @inheritDocs + */ + override def contains(key: Key): F[Boolean] = + for + k <- enforceSingleKeyType(key) + result <- kv.apply(k).get.map(_.isDefined) + yield result + +object MemoryDb: + + /** Initialize a new in-memory database, backed by a `ConcurrentHashMap`. + * + * @return + * The new database. + */ + def initialize[F[_]: Async](keySpace: KeySpaceName): F[RespiteDb[F]] = + MapRef + .ofConcurrentHashMap[F, Key, Value](initialCapacity = 4096) + .map(kv => + new MemoryDb[F]( + kv = kv, + keySpace = keySpace, + singleKeyType = None, + singleValueType = None, + isWriteOnce = false + ) + ) + +end MemoryDb diff --git a/modules/db/src/main/scala/gs/respite/db/MemoryKeySpaceDb.scala b/modules/db/src/main/scala/gs/respite/db/MemoryKeySpaceDb.scala new file mode 100644 index 0000000..2f977d5 --- /dev/null +++ b/modules/db/src/main/scala/gs/respite/db/MemoryKeySpaceDb.scala @@ -0,0 +1,59 @@ +package gs.respite.db + +import cats.effect.Async +import cats.effect.std.MapRef +import cats.syntax.all.* +import gs.respite.model.KeySpaceName + +/** Implementation of [[KeySpace]] base on Cats Effect and in-memory concurrency + * primitives. Safe for concurrent use across threads. + * + * Use the `initialize` function to instantiate this class. + * + * @param kv + * The underlying key-value store. + */ +final class MemoryKeySpaceDb[F[_]] private ( + private val defaultDb: RespiteDb[F], + private val kv: MapRef[F, KeySpaceName, Option[RespiteDb[F]]] +) extends KeySpaceDb[F]: + + /** @inheritDocs + */ + override def default: RespiteDb[F] = defaultDb + + /** @inheritDocs + */ + override def get(name: KeySpaceName): F[Option[RespiteDb[F]]] = + kv.apply(name).get + + /** @inheritDocs + */ + override def create( + name: KeySpaceName, + db: RespiteDb[F] + ): F[Boolean] = + kv.apply(name).tryUpdate { + case None => Some(db) + case Some(existing) => Some(existing) + } + + /** @inheritDocs + */ + override def delete(name: KeySpaceName): F[Unit] = + kv.unsetKey(name) + +object MemoryKeySpaceDb: + + /** Initialize a new in-memory database, backed by a `ConcurrentHashMap`. + * + * @return + * The new database. + */ + def initialize[F[_]: Async]: F[KeySpaceDb[F]] = + for + defaultDb <- RespiteDb.memory[F](KeySpaceName.Default) + kv <- MapRef.ofConcurrentHashMap(initialCapacity = 16) + yield new MemoryKeySpaceDb(defaultDb, kv) + +end MemoryKeySpaceDb diff --git a/modules/db/src/main/scala/gs/respite/db/RespiteDb.scala b/modules/db/src/main/scala/gs/respite/db/RespiteDb.scala new file mode 100644 index 0000000..baf6277 --- /dev/null +++ b/modules/db/src/main/scala/gs/respite/db/RespiteDb.scala @@ -0,0 +1,192 @@ +package gs.respite.db + +import cats.Applicative +import cats.effect.Async +import gs.respite.model.Key +import gs.respite.model.KeySpaceName +import gs.respite.model.Value + +/** Primary database interface for Respite. Defines a single key space -- some + * isolated set of key/value data. + */ +trait RespiteDb[F[_]]: + /** @return + * The name of the key space represented by this database. + */ + def keySpace: KeySpaceName + + /** Get the value assigned to some key. + * + * @param key + * The key to look up. + * @return + * The value of the key, or `None` if the key does not exist. + */ + def get(key: Key): F[Option[Value]] + + /** Insert or update the value for some key. + * + * @param key + * The key. + * @param value + * The value to associate with the key. + * @return + * The previous value, if one was set. + */ + def set( + key: Key, + value: Value + ): F[Option[Value]] + + /** Delete the given key if it exists. + * + * @param key + * The key to delete. + * @return + * The value that was stored in the given key, if one existed. + */ + def delete(key: Key): F[Option[Value]] + + /** Increment the stored value. Only valid for [[LongKey]]. + * + * @param key + * The key to increment. + * @return + * The _current_ value of the key. + */ + def increment(key: Key): F[Option[Value]] + + /** Decrement the stored value. Only valid for [[LongKey]]. + * + * @param key + * The key to decrement. + * @return + * The _current_ value of the key. + */ + def decrement(key: Key): F[Option[Value]] + + /** Compare and Swap. + * + * Compares the value stored in the key to some given value. If they are + * equal, sets the key to some new value. + * + * @param key + * The key. + * @param expectedValue + * The value to match. + * @param newValue + * The value to assign to the key, if the stored value matches the expected + * value. + * @return + * True if the value was set, false otherwise. + */ + def cas( + key: Key, + expectedValue: Value, + newValue: Value + ): F[Boolean] + + /** Insert a new key. If the key already exists, the value is not updated and + * the current value is returned. + * + * @param key + * The key to insert. + * @param value + * The value to associate with the key. + * @return + * The set value, or the current value if the key was already assigned. + */ + def insert( + key: Key, + value: Value + ): F[Option[Value]] + + /** Check if the given key exists. + * + * @param key + * They key to look up. + * @return + * True if the key exists, false otherwise. + */ + def contains(key: Key): F[Boolean] + +end RespiteDb + +object RespiteDb: + + /** @return + * An instance of [[RespiteDb]] that does nothing. + */ + def noop[F[_]: Applicative](keySpace: KeySpaceName): RespiteDb[F] = + new Noop[F](keySpace) + + /** Instantiate a new in-memory database. + * + * @param keySpace + * The name of the key space. + * @return + * The new [[RespiteDb]] instance. + */ + def memory[F[_]: Async](keySpace: KeySpaceName): F[RespiteDb[F]] = + MemoryDb.initialize(keySpace) + + /** An implementation of [[RespiteDb]] that does nothing. + * + * @param keySpace + * The name of the key space. + */ + final class Noop[F[_]: Applicative]( + val keySpace: KeySpaceName + ) extends RespiteDb[F]: + + /** @inheritDocs + */ + override def get(key: Key): F[Option[Value]] = + Applicative[F].pure(None) + + /** @inheritDocs + */ + override def set( + key: Key, + value: Value + ): F[Option[Value]] = + Applicative[F].pure(None) + + /** @inheritDocs + */ + override def delete(key: Key): F[Option[Value]] = + Applicative[F].pure(None) + + /** @inheritDocs + */ + override def increment(key: Key): F[Option[Value]] = + Applicative[F].pure(None) + + /** @inheritDocs + */ + override def decrement(key: Key): F[Option[Value]] = + Applicative[F].pure(None) + + /** @inheritDocs + */ + override def cas( + key: Key, + expectedValue: Value, + newValue: Value + ): F[Boolean] = + Applicative[F].pure(false) + + /** @inheritDocs + */ + override def insert( + key: Key, + value: Value + ): F[Option[Value]] = + Applicative[F].pure(None) + + /** @inheritDocs + */ + override def contains(key: Key): F[Boolean] = + Applicative[F].pure(false) + +end RespiteDb diff --git a/modules/model/src/main/scala/gs/respite/model/Key.scala b/modules/model/src/main/scala/gs/respite/model/Key.scala new file mode 100644 index 0000000..f2a179e --- /dev/null +++ b/modules/model/src/main/scala/gs/respite/model/Key.scala @@ -0,0 +1,299 @@ +package gs.respite.model + +import gs.uuid.v0.UUID +import java.time.LocalDate + +/** Represents valid ways to provide an address to some [[Value]]. + * + * All valid types are recorded (and serialized) using [[KeyType]]. + */ +sealed trait Key: + /** @return + * The named type for this [[Key]]. + */ + def keyType: KeyType + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case sv: StringKey => + this match + case sv1: StringKey => sv1.value == sv.value + case _ => false + case uv: UUIDKey => + this match + case uv1: UUIDKey => uv1.value == uv.value + case _ => false + case lv: Int64Key => + this match + case lv1: Int64Key => lv1.value == lv.value + case _ => false + case dv: DateKey => + this match + case dv1: DateKey => dv1.value.isEqual(dv.value) + case _ => false + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = + this match + case v: StringKey => v.hashCode() + case v: UUIDKey => v.hashCode() + case v: Int64Key => v.hashCode() + case v: DateKey => v.hashCode() + +end Key + +/** Represents valid keys to address values. + */ +object Key: + given CanEqual[Key, Key] = CanEqual.derived + + /** Instantiate a new [[Key]] backed by a `String`. + * + * @param value + * The underlying value. + * @return + * The new [[Key]]. + */ + def string(value: String): Key = StringKey(value) + + /** Instantiate a new [[Key]] backed by a `UUID`. + * + * @param value + * The underlying value. + * @return + * The new [[Key]]. + */ + def uuid(value: UUID): Key = UUIDKey(value) + + /** Instantiate a new [[Key]] backed by a generated `UUID` using the v7 + * algorithm. + * + * @return + * The new [[Key]]. + */ + def uuidV7(): Key = UUIDKey.v7() + + /** Instantiate a new [[Key]] backed by a random `UUID`. + * + * @return + * The new [[Key]]. + */ + def uuidRandom(): Key = UUIDKey.random() + + /** Instantiate a new [[Key]] backed by a `Long`. + * + * @param value + * The underlying value. + * @return + * The new [[Key]]. + */ + def long(value: Long): Key = Int64Key(value) + + /** Instantiate a new [[Key]] backed by a `LocalDate`. + * + * @param value + * The underlying value. + * @return + * The new [[Key]]. + */ + def date(value: LocalDate): Key = DateKey(value) + +end Key + +/** `String`-based [[Key]]. + * + * @param value + * The underlying `String`. + */ +final class StringKey private (val value: String) extends Key: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: StringKey => other.value == value + case other: String => other == value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value + + /** @inheritDocs + */ + override def keyType: KeyType = KeyType.string + +end StringKey + +/** `String`-based [[Key]]. + */ +object StringKey: + + given CanEqual[StringKey, StringKey] = CanEqual.derived + + /** Instantiate a [[Key]] from the given `String`. + * + * @param value + * The `String` to express as a [[Key]]. + * @return + * The new [[Key]] instance. + */ + def apply(value: String): Key = + new StringKey(value) + +end StringKey + +/** `UUID`-based [[Key]]. + * + * @param value + * The underlying `UUID`. + */ +final class UUIDKey private (val value: UUID) extends Key: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: UUIDKey => other.value == value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.withDashes() + + /** @inheritDocs + */ + override def keyType: KeyType = KeyType.uuid + +end UUIDKey + +/** `UUID`-based [[Key]]. + */ +object UUIDKey: + + given CanEqual[UUIDKey, UUIDKey] = CanEqual.derived + + /** Instantiate a [[Key]] from the given `UUID`. + * + * @param value + * The `UUID` to express as a [[Key]]. + * @return + * The new [[Key]] instance. + */ + def apply(value: UUID): UUIDKey = new UUIDKey(value) + + /** Generate a new UUIDv7 expressed as a [[Key]]. + * + * @return + * The new [[Key]] instance. + */ + def v7(): UUIDKey = new UUIDKey(UUID.v7()) + + /** Generate a new UUIDv4 expressed as a [[Key]]. + * + * @return + * The new [[Key]] instance. + */ + def random(): UUIDKey = new UUIDKey(UUID.v4()) + +end UUIDKey + +/** `Long`-based [[Key]]. + * + * @param value + * The underlying `Long`. + */ +final class Int64Key private (val value: Long) extends Key: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Int64Key => other.value == value + case other: Long => other == value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.toString() + + /** @inheritDocs + */ + override def keyType: KeyType = KeyType.int64 + +end Int64Key + +object Int64Key: + + given CanEqual[Int64Key, Int64Key] = CanEqual.derived + + /** Instantiate a [[Key]] from the given `Long`. + * + * @param value + * The `Long` to express as a [[Key]]. + * @return + * The new [[Key]] instance. + */ + def apply(value: Long): Int64Key = new Int64Key(value) + +end Int64Key + +/** `LocalDate`-based [[Key]]. + * + * @param value + * The underlying `Date`. + */ +final class DateKey private (val value: LocalDate) extends Key: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: DateKey => other.value.isEqual(value) + case other: LocalDate => other.isEqual(value) + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.toString() + + /** @inheritDocs + */ + override def keyType: KeyType = KeyType.date + +end DateKey + +object DateKey: + + given CanEqual[DateKey, DateKey] = CanEqual.derived + + /** Instantiate a [[Key]] from the given `Date`. + * + * @param value + * The `Date` to express as a [[Key]]. + * @return + * The new [[Key]] instance. + */ + def apply(value: LocalDate): DateKey = new DateKey(value) + +end DateKey diff --git a/modules/model/src/main/scala/gs/respite/model/KeySpaceConstraint.scala b/modules/model/src/main/scala/gs/respite/model/KeySpaceConstraint.scala new file mode 100644 index 0000000..68d9a20 --- /dev/null +++ b/modules/model/src/main/scala/gs/respite/model/KeySpaceConstraint.scala @@ -0,0 +1,36 @@ +package gs.respite.model + +/** Enumeration for constraints that can be applied to key spaces. + * + * @param name + * The formal name of the constraint. + */ +sealed abstract class KeySpaceConstraint(val name: String) + +object KeySpaceConstraint: + + /** Only the given type is accepted for keys. Calls that provide other key + * types will fail. + * + * @param keyType + * The selected [[KeyType]]. + */ + case class SingleKeyType( + keyType: KeyType + ) extends KeySpaceConstraint("single_key_type") + + /** Only the given type is accepted for values. Calls that provide other value + * types will fail. + * + * @param valueType + * The selected [[ValueType]]. + */ + case class SingleValueType( + valueType: ValueType + ) extends KeySpaceConstraint("single_value_type") + + /** Each key may be written exactly once. Deletes and reassignments will fail. + */ + case object WriteOnce extends KeySpaceConstraint("write_once") + +end KeySpaceConstraint diff --git a/modules/model/src/main/scala/gs/respite/model/KeySpaceName.scala b/modules/model/src/main/scala/gs/respite/model/KeySpaceName.scala new file mode 100644 index 0000000..43c2649 --- /dev/null +++ b/modules/model/src/main/scala/gs/respite/model/KeySpaceName.scala @@ -0,0 +1,41 @@ +package gs.respite.model + +/** Opaque type for a string that represents the unique name of some key space. + * + * Each key space is a named, isolated, set of data. + */ +opaque type KeySpaceName = String + +/** Opaque type for a string that represents the unique name of some key space. + * + * Each key space is a named, isolated, set of data. + */ +object KeySpaceName: + + /** Instantiate a new [[KeySpaceName]]. + * + * @param value + * The string value. + * @return + * The new name. + */ + def apply(value: String): KeySpaceName = value + + /** The default key space -- the empty string. + */ + final val Default: KeySpaceName = "" + + given CanEqual[KeySpaceName, KeySpaceName] = CanEqual.derived + + extension (name: KeySpaceName) + /** @return + * The underlying string. + */ + def unwrap(): String = name + + /** @return + * True if this is the default key space, false otherwise. + */ + def isDefault(): Boolean = name == Default + +end KeySpaceName diff --git a/modules/model/src/main/scala/gs/respite/model/KeyType.scala b/modules/model/src/main/scala/gs/respite/model/KeyType.scala new file mode 100644 index 0000000..655adc3 --- /dev/null +++ b/modules/model/src/main/scala/gs/respite/model/KeyType.scala @@ -0,0 +1,50 @@ +package gs.respite.model + +/** Type that describes all supported type for [[Key]]. + * + * @param name + * The name of the type. + */ +sealed abstract class KeyType(val name: String): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: KeyType => other.name == name + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = name.hashCode() + + /** @inheritDocs + */ + override def toString(): String = name + +object KeyType: + + given CanEqual[KeyType, KeyType] = CanEqual.derived + + /** The type for [[StringKey]] + */ + case object string extends KeyType("string") + + /** The type for [[Int64Key]] + */ + case object int64 extends KeyType("int64") + + /** The type for [[UUIDKey]] + */ + case object uuid extends KeyType("uuid") + + /** The type for [[DateKey]] + */ + case object date extends KeyType("date") + + val All: Set[KeyType] = Set(string, int64, uuid, date) + + def parse(candidate: String): Option[KeyType] = + All.find(_.name.equalsIgnoreCase(candidate)) + +end KeyType diff --git a/modules/model/src/main/scala/gs/respite/model/RespiteException.scala b/modules/model/src/main/scala/gs/respite/model/RespiteException.scala new file mode 100644 index 0000000..5b3b9a1 --- /dev/null +++ b/modules/model/src/main/scala/gs/respite/model/RespiteException.scala @@ -0,0 +1,35 @@ +package gs.respite.model + +sealed abstract class RespiteException(message: String) + extends Throwable(message) + +sealed abstract class ConstraintException(message: String) + extends RespiteException(message) + +object ConstraintException: + + final class InvalidKeyException( + val key: Key, + val limit: KeyType, + val keySpace: KeySpaceName + ) extends ConstraintException( + s"Received key of type '$key', but key space '$keySpace' is restricted to key type '$limit'." + ) + + final class InvalidValueException( + val key: Key, + val value: Value, + val limit: ValueType, + val keySpace: KeySpaceName + ) extends ConstraintException( + s"Received value of type '$value' for key '$key', but key space '$keySpace' is restricted to value type '$limit'." + ) + + final class WriteOnceException( + val key: Key, + val keySpace: KeySpaceName + ) extends ConstraintException( + s"Attempted to write key '$key' in key space '$keySpace', but the key already exists and this key space is immutable after write." + ) + +end ConstraintException diff --git a/modules/model/src/main/scala/gs/respite/model/Value.scala b/modules/model/src/main/scala/gs/respite/model/Value.scala new file mode 100644 index 0000000..78d8ff5 --- /dev/null +++ b/modules/model/src/main/scala/gs/respite/model/Value.scala @@ -0,0 +1,349 @@ +package gs.respite.model + +import gs.std.v0.core.Base64Encoder +import gs.uuid.v0.UUID +import java.time.LocalDate + +/** Represents valid ways to represent data referenced by [[Key]]. + * + * All valid types are recorded (and serialized) using [[ValueType]]. + */ +sealed trait Value: + /** @return + * The named type for this [[Value]]. + */ + def valueType: ValueType + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case sv: StringValue => + this match + case sv1: StringValue => sv1.value == sv.value + case _ => false + case uv: UUIDValue => + this match + case uv1: UUIDValue => uv1.value == uv.value + case _ => false + case lv: Int64Value => + this match + case lv1: Int64Value => lv1.value == lv.value + case _ => false + case bv: BooleanValue => + this match + case bv1: BooleanValue => bv1.value == bv.value + case _ => false + case dv: DateValue => + this match + case dv1: DateValue => dv1.value.isEqual(dv.value) + case _ => false + case bv: ByteValue => + this match + case bv1: ByteValue => bv1.value.sameElements(bv.value) + case _ => false + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = + this match + case v: StringValue => v.hashCode() + case v: UUIDValue => v.hashCode() + case v: Int64Value => v.hashCode() + case v: BooleanValue => v.hashCode() + case v: DateValue => v.hashCode() + case v: ByteValue => v.hashCode() + +end Value + +object Value: + + given CanEqual[Value, Value] = CanEqual.derived + +end Value + +/** `String`-based [[Value]]. + * + * @param value + * The underlying `String`. + */ +final class StringValue private (val value: String) extends Value: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: StringValue => other.value == value + case other: String => other == value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value + + /** @inheritDocs + */ + override def valueType: ValueType = ValueType.string + +end StringValue + +/** `String`-based [[Value]]. + */ +object StringValue: + + given CanEqual[StringValue, StringValue] = CanEqual.derived + + /** Instantiate a [[Value]] from the given `String`. + * + * @param value + * The `String` to express as a [[Value]]. + * @return + * The new [[Value]] instance. + */ + def apply(value: String): Value = + new StringValue(value) + +end StringValue + +/** `UUID`-based [[Value]]. + * + * @param value + * The underlying `UUID`. + */ +final class UUIDValue private (val value: UUID) extends Value: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: UUIDValue => other.value == value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.withDashes() + + /** @inheritDocs + */ + override def valueType: ValueType = ValueType.uuid + +end UUIDValue + +/** `UUID`-based [[Value]]. + */ +object UUIDValue: + + given CanEqual[UUIDValue, UUIDValue] = CanEqual.derived + + /** Instantiate a [[Value]] from the given `UUID`. + * + * @param value + * The `UUID` to express as a [[Value]]. + * @return + * The new [[Value]] instance. + */ + def apply(value: UUID): UUIDValue = new UUIDValue(value) + + /** Generate a new UUIDv7 expressed as a [[Value]]. + * + * @return + * The new [[Value]] instance. + */ + def v7(): UUIDValue = new UUIDValue(UUID.v7()) + + /** Generate a new UUIDv4 expressed as a [[Value]]. + * + * @return + * The new [[Value]] instance. + */ + def random(): UUIDValue = new UUIDValue(UUID.v4()) + +end UUIDValue + +/** `Long`-based [[Value]]. + * + * @param value + * The underlying `Long`. + */ +final class Int64Value private (val value: Long) extends Value: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Int64Value => other.value == value + case other: Long => other == value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.toString() + + def increment(): Int64Value = new Int64Value(value + 1L) + + def decrement(): Int64Value = new Int64Value(value - 1L) + + /** @inheritDocs + */ + override def valueType: ValueType = ValueType.int64 + +end Int64Value + +object Int64Value: + + given CanEqual[Int64Value, Int64Value] = CanEqual.derived + + /** Instantiate a [[Value]] from the given `Long`. + * + * @param value + * The `Long` to express as a [[Value]]. + * @return + * The new [[Value]] instance. + */ + def apply(value: Long): Int64Value = new Int64Value(value) + +end Int64Value + +/** `Boolean`-based [[Value]]. + * + * @param value + * The underlying `Boolean`. + */ +final class BooleanValue private (val value: Boolean) extends Value: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: BooleanValue => other.value == value + case other: Boolean => other == value + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.toString() + + /** @inheritDocs + */ + override def valueType: ValueType = ValueType.bool + +end BooleanValue + +object BooleanValue: + + given CanEqual[BooleanValue, BooleanValue] = CanEqual.derived + + /** Instantiate a [[Value]] from the given `Boolean`. + * + * @param value + * The `Boolean` to express as a [[Value]]. + * @return + * The new [[Value]] instance. + */ + def apply(value: Boolean): BooleanValue = new BooleanValue(value) + +end BooleanValue + +/** `Date`-based [[Value]]. + * + * @param value + * The underlying `Date`. + */ +final class DateValue private (val value: LocalDate) extends Value: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: DateValue => other.value.isEqual(value) + case other: LocalDate => other.isEqual(value) + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = value.toString() + + /** @inheritDocs + */ + override def valueType: ValueType = ValueType.date + +end DateValue + +object DateValue: + + given CanEqual[DateValue, DateValue] = CanEqual.derived + + /** Instantiate a [[Value]] from the given `Date`. + * + * @param value + * The `Date` to express as a [[Value]]. + * @return + * The new [[Value]] instance. + */ + def apply(value: LocalDate): DateValue = new DateValue(value) + +end DateValue + +/** `Byte`-based [[Value]]. + * + * @param value + * The underlying `Byte`. + */ +final class ByteValue private (val value: Vector[Byte]) extends Value: + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: ByteValue => other.value.sameElements(value) + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = Base64Encoder.encode(value.toArray).data + + /** @inheritDocs + */ + override def valueType: ValueType = ValueType.date + +end ByteValue + +object ByteValue: + + given CanEqual[ByteValue, ByteValue] = CanEqual.derived + + /** Instantiate a [[Value]] from the given `Byte`. + * + * @param value + * The `Byte` to express as a [[Value]]. + * @return + * The new [[Value]] instance. + */ + def apply(value: Vector[Byte]): ByteValue = new ByteValue(value) + +end ByteValue diff --git a/modules/model/src/main/scala/gs/respite/model/ValueType.scala b/modules/model/src/main/scala/gs/respite/model/ValueType.scala new file mode 100644 index 0000000..107ea69 --- /dev/null +++ b/modules/model/src/main/scala/gs/respite/model/ValueType.scala @@ -0,0 +1,58 @@ +package gs.respite.model + +/** Type that describes all supported type for [[Value]]. + * + * @param name + * The name of the type. + */ +sealed abstract class ValueType(val name: String): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: ValueType => other.name == name + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = name.hashCode() + + /** @inheritDocs + */ + override def toString(): String = name + +object ValueType: + + given CanEqual[ValueType, ValueType] = CanEqual.derived + + /** The type for [[StringValue]] + */ + case object string extends ValueType("string") + + /** The type for [[Int64Value]] + */ + case object int64 extends ValueType("int64") + + /** The type for [[UUIDValue]] + */ + case object uuid extends ValueType("uuid") + + /** The type for [[DateValue]] + */ + case object date extends ValueType("date") + + /** The type for [[BooleanValue]] + */ + case object bool extends ValueType("bool") + + /** The type for [[ByteValue]] + */ + case object byte extends ValueType("byte") + + val All: Set[ValueType] = Set(string, int64, uuid, date, bool, byte) + + def parse(candidate: String): Option[ValueType] = + All.find(_.name.equalsIgnoreCase(candidate)) + +end ValueType diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..dabdb15 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.11 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..18a6411 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,33 @@ +def selectCredentials(): Credentials = + if ((Path.userHome / ".sbt" / ".credentials").exists()) + Credentials(Path.userHome / ".sbt" / ".credentials") + else + Credentials.apply( + realm = "Reposilite", + host = "maven.garrity.co", + userName = sys.env + .get("GS_MAVEN_USER") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable." + ) + ), + passwd = sys.env + .get("GS_MAVEN_TOKEN") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable." + ) + ) + ) + +credentials += selectCredentials() + +externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.4") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.7.0") +addSbtPlugin("gs" % "sbt-gs-calver" % "0.2.0")