diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74d397a..c7c25d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: java-version: '25' - name: Set up Gradle - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 - name: Build and test run: ./gradlew build --stacktrace diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 7ddadde..cba94ae 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -36,7 +36,7 @@ jobs: - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@a26b07d8c778b0c43f64618e5fd29fd0f8af72e8 # v1.0.250 + uses: anthropics/claude-code-action@8041f1d17e0e7d2f2cfe35002962e9f26237c0bb # v1.0.263 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index e3bcece..38b67be 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -32,7 +32,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@a26b07d8c778b0c43f64618e5fd29fd0f8af72e8 # v1.0.250 + uses: anthropics/claude-code-action@8041f1d17e0e7d2f2cfe35002962e9f26237c0bb # v1.0.263 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 3ebdb86..44b93a5 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -16,7 +16,7 @@ A service that declares nothing is in `Application`. * A `StartOrder`, passed to `Bootable`, defines the phases and how each service's ordering is read, so an app can add phases, replace them, or place a service it didn't write. The default is `DefaultStartOrder`. * Services left unordered start in the order of a `StartOrder.Tiebreaker`: `ByName` (the default), or `Shuffled(seed)`, which surfaces missing milestones and logs its seed. -`boot.service.order.tiebreaker.type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled`) shuffles them, e.g. in `application-local.conf` or test config, and `boot.service.order.tiebreaker.seed` replays a logged shuffle; `ServiceOrderConfig` holds these settings. +`boot.service.order.tiebreaker._type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled`) shuffles them, e.g. in `application-local.conf` or test config, and `boot.service.order.tiebreaker.seed` replays a logged shuffle; `ServiceOrderConfig` holds these settings. * `startPlan(services, startOrder, tiebreaker)` computes the order, so tests can check it. A plan that can't be met throws a `StartOrderException` listing every problem: an unknown phase, a missing required milestone, a milestone provided in a later phase, or a cycle. * `boot-db-jdbc` adds `DbMilestones.DataSourceReady` and `DbMilestones.SchemaMigrated`. @@ -38,21 +38,17 @@ With Kodein, `bootableModule` uses `StartOrder` and `StartOrder.Tiebreaker` bind `LifecycleController.priority()` is unchanged. -=== Config enum values match in any case +=== Config enum values and sealed subtype names match in any case `hopliteConfigBinder` matches enum values against the entries' names ignoring case, so `schemaVerification = fail`, `Fail`, and `FAIL` all read the same. This applies to every enum in config, so config values needn't follow how a Kotlin enum's entries are cased. +Sealed subtype names match ignoring case too, so `Shuffled` and `shuffled` select the same subtype. -=== Config selects sealed subtypes with `type` +=== Environment variables select sealed subtypes -`hopliteConfigBinder` selects a sealed type's subtype by a `type` key, instead of Hoplite's default `_type`, so an environment variable can select one too: `_` separates the path, so no environment variable can spell `_type`. -For example, `tiebreaker { type = Shuffled, seed = 42 }`, or `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled` and `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`. -Subtype names match only in their own case. - -==== Breaking changes - -* Config that selects a sealed subtype with `_type` must use `type` instead. -* A sealed subtype can't have a property named `type`, as that key now selects the subtype. +Bootable now uses Hoplite 3.0.5, where an environment variable selects a sealed type's subtype by naming it as the sealed type's own value, since no environment variable can spell `_type`: `_` separates the path. +For example, `tiebreaker { _type = Shuffled, seed = 42 }` in config, or `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled` and `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`. +An environment variable that selects a subtype overrides the one config selects, and config can still set the subtype's properties. === A failed start shuts down the services that started diff --git a/README.adoc b/README.adoc index 25d2d36..508be01 100644 --- a/README.adoc +++ b/README.adoc @@ -36,7 +36,7 @@ NOTE: Importing the Metro DI artifact does bring annotations to your application * Allows registration of lifecycle controllers, which can be used to control the lifecycle of application services in various ways. The basic built-in implementation of this is the `AppStartStopLifecycleController` which starts application services on app start, and shuts them down on app close, in reverse. Services start phase by phase (infrastructure, initialization, application, serving), and, within a phase, after the milestones they declare; see link:docs/service-ordering.adoc[Service ordering]. -Services left unordered start by name, or shuffled with `boot.service.order.tiebreaker.type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled`). +Services left unordered start by name, or shuffled with `boot.service.order.tiebreaker._type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled`). Shuffling is useful in `application-local.conf` and test config, to surface services that rely on an order they don't declare. An app can pass its own `StartOrder` to add phases; see link:./examples/start-order[examples/start-order], which adds a phase after `Serving` for a service that announces the app is ready once its server is listening. Another basic implementation, intended to be used in conjunction with `AppStartStopLifecycleController`, is the `StopSignalHandlerLifecycleController`, which registers signal handlers, which, when received, cleanly shuts the system down. diff --git a/boot-config-hoplite/src/main/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinder.kt b/boot-config-hoplite/src/main/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinder.kt index 2fd23b7..415b0e7 100644 --- a/boot-config-hoplite/src/main/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinder.kt +++ b/boot-config-hoplite/src/main/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinder.kt @@ -58,11 +58,11 @@ private val shellPromptEnvVars = setOf("PS1", "PS2", "PS4", "PROMPT_COMMAND") * * [environmentVariables] supplies the environment variables to read (defaults to [System.getenv]). * - * Enum values match the entries' names in any case, so `fail`, `Fail`, and `FAIL` are all `Fail`. + * A sealed type is chosen by its subtype's name, in a `_type` key next to the subtype's properties, e.g. + * `tiebreaker { _type = Shuffled, seed = 42 }`, or, in the environment, as the sealed type's own value, e.g. + * `TIEBREAKER=Shuffled` and `TIEBREAKER_SEED=42`. * - * A sealed type is chosen by its subtype's name, in a `type` key next to the subtype's properties, e.g. - * `tiebreaker { type = Shuffled, seed = 42 }`, or `TIEBREAKER_TYPE=Shuffled` and `TIEBREAKER_SEED=42` in the - * environment. Unlike enum values, subtype names match only in their own case. + * Enum values and sealed subtype names match in any case, so `fail`, `Fail`, and `FAIL` are all `Fail`. * * Returns a plain [ConfigBinder]; decode config sections with `binder.config("key")`: * @@ -111,8 +111,7 @@ fun hopliteConfigBinder( strict() } } - // `type`, not Hoplite's default `_type`: an environment variable can't spell `_type`, as `_` separates the path - .withExplicitSealedTypes("type") + .withExplicitSealedTypes() .withResolveTypesCaseInsensitive() .build() .configBinder() diff --git a/boot-config-hoplite/src/test/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinderTest.kt b/boot-config-hoplite/src/test/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinderTest.kt index a0696fb..b19fae6 100644 --- a/boot-config-hoplite/src/test/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinderTest.kt +++ b/boot-config-hoplite/src/test/kotlin/com/github/rocketraman/bootable/config/HopliteConfigBinderTest.kt @@ -62,26 +62,43 @@ val ServiceOrderConfigTest by testSuite { assert(serviceOrderConfig(binder(emptyMap())) == ServiceOrderConfig()) } - test("is read from environment variables, the tiebreaker by its type") { - val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER_TYPE" to "Shuffled", "BOOT_SERVICE_ORDER_TIEBREAKER_SEED" to "42") + test("is read from environment variables, the tiebreaker by its own value") { + val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "Shuffled", "BOOT_SERVICE_ORDER_TIEBREAKER_SEED" to "42") assert(serviceOrderConfig(binder(env)).tiebreaker == StartOrder.Tiebreaker.Shuffled(42)) - val byName = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER_TYPE" to "ByName") + val byName = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "ByName") assert(serviceOrderConfig(binder(byName)).tiebreaker == StartOrder.Tiebreaker.ByName) } test("shuffles with a new seed when none is given") { - val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER_TYPE" to "Shuffled") + val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "Shuffled") assert(serviceOrderConfig(binder(env)).tiebreaker is StartOrder.Tiebreaker.Shuffled) } test("is read from config") { - val binder = hocon("boot.service.order.tiebreaker { type = Shuffled, seed = 42 }") + val binder = hocon("boot.service.order.tiebreaker { _type = Shuffled, seed = 42 }") + assert(serviceOrderConfig(binder).tiebreaker == StartOrder.Tiebreaker.Shuffled(42)) + } + + test("the tiebreaker's subtype name matches in any case") { + listOf("shuffled", "SHUFFLED").forEach { + val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to it, "BOOT_SERVICE_ORDER_TIEBREAKER_SEED" to "42") + assert(serviceOrderConfig(binder(env)).tiebreaker == StartOrder.Tiebreaker.Shuffled(42)) + } + } + + test("an environment variable selects the tiebreaker over config") { + val conf = "boot.service.order.tiebreaker { _type = ByName, seed = 42 }" + val binder = hopliteConfigBinder( + report = false, + environmentVariables = { mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "Shuffled") }, + withConfigStage2 = { addPropertySource(PropertySource.string(conf, "conf")) }, + ) assert(serviceOrderConfig(binder).tiebreaker == StartOrder.Tiebreaker.Shuffled(42)) } test("in strict mode, a seed without a shuffle fails") { - val binder = hocon("boot.service.order.tiebreaker { type = ByName, seed = 42 }", strict = true) + val binder = hocon("boot.service.order.tiebreaker { _type = ByName, seed = 42 }", strict = true) val e = runCatching { serviceOrderConfig(binder) }.exceptionOrNull() val message = e?.message.orEmpty() assert(e is ConfigException && "'boot.service.order.tiebreaker.seed'" in message && "unused" in message) diff --git a/boot/src/main/kotlin/com/github/rocketraman/bootable/boot/ServiceOrderConfig.kt b/boot/src/main/kotlin/com/github/rocketraman/bootable/boot/ServiceOrderConfig.kt index 6d37a93..4aa4eb0 100644 --- a/boot/src/main/kotlin/com/github/rocketraman/bootable/boot/ServiceOrderConfig.kt +++ b/boot/src/main/kotlin/com/github/rocketraman/bootable/boot/ServiceOrderConfig.kt @@ -10,17 +10,17 @@ import com.github.rocketraman.bootable.config.config * ``` * boot.service.order { * tiebreaker { - * type = Shuffled # or ByName, the default + * _type = Shuffled # or ByName, the default * seed = 42 # optional: replays a logged shuffle * } * } * ``` * - * or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled` and - * `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`. The `type` is the [StartOrder.Tiebreaker] subtype's name, and the other + * or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled` and + * `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`. The `_type` is the [StartOrder.Tiebreaker] subtype's name, and the other * keys are its properties. * - * Set them like any other config, e.g. `type = Shuffled` in `application-local.conf` or test config, where a + * Set them like any other config, e.g. `_type = Shuffled` in `application-local.conf` or test config, where a * surprise is cheap, and the default in production, so it starts in the same order on every run. */ data class ServiceOrderConfig( diff --git a/build.gradle.kts b/build.gradle.kts index 6848a0b..b591d61 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -528,7 +528,7 @@ fun Project.configureDiVariants() { if (!hasMetro && !hasKodein) return val metroRuntime = "dev.zacsweers.metro:runtime:${rootProject.libs.versions.metro.get()}" - val kodeinDi = "org.kodein.di:kodein-di:7.25.0" + val kodeinDi = "org.kodein.di:kodein-di:7.33.0" if (hasMetro) { apply(plugin = "dev.zacsweers.metro") diff --git a/docs/service-ordering.adoc b/docs/service-ordering.adoc index ed70059..0bed9ca 100644 --- a/docs/service-ordering.adoc +++ b/docs/service-ordering.adoc @@ -447,13 +447,13 @@ on `boot`, and the environment is only set during `appInit`, after the plan is m ---- boot.service.order { tiebreaker { - type = Shuffled # ByName, the default, if unset + _type = Shuffled # ByName, the default, if unset seed = 42 # optional: replays a logged shuffle } } ---- -Or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled` and +Or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled` and `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`. [source,kotlin] @@ -466,10 +466,10 @@ fun serviceOrderConfig(binder: ConfigBinder): ServiceOrderConfig = binder.config Bootable(appServices, tiebreaker = serviceOrderConfig(binder).tiebreaker) ---- -Config names the sealed `StartOrder.Tiebreaker` itself: `type` is the subtype's name, and the other keys are its -properties, so the seed is `tiebreaker.seed`, where it means something. `hopliteConfigBinder` selects sealed -subtypes by a `type` key rather than Hoplite's default `_type`, which no environment variable can spell, since `_` -separates the path. Subtype names match only in their own case (`Shuffled`, not `shuffled`), unlike enum values. +Config names the sealed `StartOrder.Tiebreaker` itself: `_type` is the subtype's name, and the other keys are its +properties, so the seed is `tiebreaker.seed`, where it means something. No environment variable can spell `_type`, +since `_` separates the path, so an environment variable names the subtype as the tiebreaker's own value instead. +Subtype names match in any case, as enum values do. `boot`'s Kodein and Metro flavors read it this way when the app has a `ConfigBinder`, so an app sets it in config and writes no code; one without a `ConfigBinder` gets `ByName`. Keeping the tiebreaker apart from the `StartOrder` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 420c644..e3e98dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,22 +2,22 @@ compose-multiplatform = "1.12.2" dokka = "2.2.0" exposed = "1.5.1" -flyway = "13.10.0" -graalvm-buildtools = "0.10.6" +flyway = "13.11.1" +graalvm-buildtools = "0.11.5" hikari = "7.1.0" -hoplite = "3.0.3" +hoplite = "3.0.5" # 4.0.0 is out, but npm tags it next rather than latest, and Ktor's HTMX attributes follow htmx 2 htmx = "2.0.11" kilua = "0.0.36" kotlin = "2.4.21" -kotlinx-coroutines = "1.10.1" +kotlinx-coroutines = "1.11.0" kotlinx-html = "0.12.0" -kotlinx-serialization = "1.9.0" +kotlinx-serialization = "1.12.0" ktor = "3.6.0" log4j = "2.24.3" log4j-kotlin-api = "1.5.0" metro = "1.4.5" -mysql-connector-j = "26.7.0" +mysql-connector-j = "26.10.0" postgresql = "42.7.14" sqlite-jdbc = "3.53.4.0" testcontainers = "2.0.5" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8747320..988da1a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-all.zip networkTimeout=10000 retries=0 retryBackOffMs=500