Time-zone-aware cron scheduling for Kotlin Multiplatform.
Find a file
NillPoe 961beeb5ff
All checks were successful
Publish Package (solera) TeamCity build finished
Initial Solera implementation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:26:09 +09:00
gradle Initial Solera implementation 2026-09-29 15:26:09 +09:00
kotlin-js-store Initial Solera implementation 2026-09-29 15:26:09 +09:00
solera Initial Solera implementation 2026-09-29 15:26:09 +09:00
solera-compiler-plugin Initial Solera implementation 2026-09-29 15:26:09 +09:00
solera-coroutines Initial Solera implementation 2026-09-29 15:26:09 +09:00
solera-gradle-plugin Initial Solera implementation 2026-09-29 15:26:09 +09:00
.gitattributes Initial Solera implementation 2026-09-29 15:26:09 +09:00
.gitignore Initial Solera implementation 2026-09-29 15:26:09 +09:00
build.gradle.kts Initial Solera implementation 2026-09-29 15:26:09 +09:00
gradle.properties Initial Solera implementation 2026-09-29 15:26:09 +09:00
gradlew Initial Solera implementation 2026-09-29 15:26:09 +09:00
gradlew.bat Initial Solera implementation 2026-09-29 15:26:09 +09:00
LICENSE Initial Solera implementation 2026-09-29 15:26:09 +09:00
README.md Initial Solera implementation 2026-09-29 15:26:09 +09:00
settings.gradle.kts Initial Solera implementation 2026-09-29 15:26:09 +09:00

Solera

Solera is a cron library for Kotlin Multiplatform. A schedule carries its own time zone, finds the next and previous run as an Instant, and reads Quartz extensions and hashed fields. An optional compiler plugin checks constant expressions while you type.

val nightly = Solera.parse("CRON_TZ=Asia/Seoul 0 4 * * MON-FRI")

nightly.next(after = Clock.System.now())   // Instant
nightly.upcoming(after = now).take(5)       // Sequence<Instant>
nightly.toString()                          // CRON_TZ=Asia/Seoul 0 4 * * MON-FRI

Or without a string:

val nightly = solera(zone = TimeZone.of("Asia/Seoul")) {
    hour(4)
    dayOfWeek(MONDAY..FRIDAY)
}

A solera is a way of ageing wine and sherry: at fixed times, part of each barrel is drawn and topped up from the next one.

Contents

Setup

Solera publishes to the Forgejo package registry at https://forgejo.verchase.com/api/packages/nillpoe/maven. Add it to your repositories:

// settings.gradle.kts
pluginManagement {
    repositories {
        maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
        mavenCentral()
    }
}

Then apply the plugin next to the Kotlin plugin:

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.4.10" // or kotlin("multiplatform")
    id("xyz.nillpoe.solera") version "1.0.0-2.4.10"
}

dependencies {
    implementation("xyz.nillpoe.solera:solera-coroutines:1.0.0-2.4.10") // optional
}

The plugin adds the xyz.nillpoe.solera:solera library to implementation (or commonMainImplementation) and runs the compile-time checks. Without the checks, depend on xyz.nillpoe.solera:solera directly; the plugin is optional.

The library supports JVM, JS, Wasm and Kotlin/Native. It depends on kotlinx-datetime and kotlinx-serialization-core.

Expressions

[CRON_TZ=<zone>] [<second>] <minute> <hour> <day of month> <month> <day of week>

With five fields the second is 0. With six fields the second comes first.

Field Values Names
second 0-59
minute 0-59
hour 0-23
day of month 1-31
month 1-12 JAN-DEC
day of week 0-7, 0 and 7 are Sunday SUN-SAT

Names are case-insensitive. Each field is a list of items separated by ,:

Item Meaning
* every value
? every value, only in day of month and day of week
5 one value
1-5 a range; 22-2 wraps around: 22, 23, 0, 1, 2
*/15, 1-30/5, 5/15 every 15th value, from the start of the field, the range, or 5
L day of month: the last day
L-3 day of month: 3 days before the last day
15W day of month: the weekday nearest to the 15th, in the same month
LW day of month: the last weekday
FRI#2 day of week: the second Friday of the month
FRIL or 5L day of week: the last Friday of the month
H, H(0-29), H/15, H(0-29)/15 a hashed value

Macros stand for the whole expression: @yearly (@annually), @monthly, @weekly, @daily (@midnight) and @hourly.

When both day of month and day of week are restricted, a date runs if either matches, as in Vixie cron: 0 0 1 * MON runs on the 1st and on every Monday. A field is restricted unless it covers every value (*, ?, 1-31, 0-6, ...).

Solera.parse throws SoleraSyntaxException with the position and a reason:

Invalid cron expression `0 25 * * *` at index 2: Hour `25` is out of range. Expected 0-23.

An expression that never runs is a syntax error too, so next never has to return null:

Invalid cron expression `0 0 30 2 *` at index 4: The expression never runs: no date has day of month `30` in month `FEB`.

Solera.parseOrNull returns null instead of throwing.

Schedules

val schedule = Solera.parse("0 */6 * * *")

schedule.next(after = instant)        // first run strictly after
schedule.previous(before = instant)   // last run strictly before
schedule.upcoming(after = instant)    // every run after, lazily
schedule.matches(instant)             // is the second containing `instant` scheduled?
schedule.zone                         // TimeZone
schedule.withZone(zone)               // the same fields in another zone
schedule.seeded("reaper")             // see Hashed fields

Schedules are precise to the second. Fractions of a second are ignored when matching.

toString() returns the canonical expression, which parse reads back to an equal schedule. It writes the zone only when it is not UTC, the seconds only when they are not 0, names in capitals, and merges values into ranges and steps:

Parsed Canonical
0 0 4 * * * 0 4 * * *
0,15,30,45 * * * * */15 * * * *
TZ=Asia/Seoul 0 9-17 * * mon-fri CRON_TZ=Asia/Seoul 0 9-17 * * MON-FRI
0 0 * * 1-7 0 0 * * *
@weekly 0 0 * * SUN

Two schedules are equal when their canonical expressions and zones are equal, and, for expressions with H, their seeds.

There are shortcuts for common schedules:

Solera.daily(at = LocalTime(4, 0), zone)
Solera.hourly(minute = 10, zone)
Solera.weekly(on = MONDAY, at = LocalTime(9, 0), zone)

Time zones and daylight saving time

The zone comes from a CRON_TZ= (or TZ=) prefix, or from Solera.parse(expression, zone). Without either, the zone is UTC, never the system default. Giving both is an error.

Local times that do not exist or exist twice follow two rules:

  • A scheduled time skipped when clocks move forward runs at the moment of the change. In New York, 30 2 * * * runs at 03:00 on the day daylight saving time starts.
  • A scheduled time that occurs twice when clocks move back runs only the first time.

On JS and Wasm, zones other than UTC need the time zone database that kotlinx-datetime describes in its README.

Hashed fields

H picks a value from a seed. The value is the same every time for the same seed, and different seeds spread over the range. Use it so that many jobs with the same schedule do not all start at the same second.

val cleanup = Solera.parse("H 4 * * *").seeded("cleanup")   // 04:mm, mm picked from "cleanup"
val reports = Solera.parse("H 4 * * *").seeded("reports")   // most likely another minute
Item Picks
H one value from the whole field; days of month stop at 28
H(0-29) one value from the range
H/15 every 15th value, starting at an offset below 15
H(0-29)/10 every 10th value in the range, from an offset

Without seeded, the seed is the canonical expression. The seed is not part of toString() or the serialized form, so call seeded after reading a schedule from a configuration file.

Builder

solera { } builds a schedule with Kotlin values:

solera(zone = TimeZone.of("Asia/Seoul")) {
    minute(0..59 step 15)
    hour(9..18)
    dayOfWeek(MONDAY..FRIDAY)
}
// CRON_TZ=Asia/Seoul */15 9-18 * * MON-FRI

solera {
    dayOfMonth(last, nearestWeekday(15))
    dayOfWeek(FRIDAY.nth(2), FRIDAY.last)
}
// 0 0 L,15W * FRI#2,FRIL

solera(seed = "reaper") {
    minute(hashed)
    hour(4)
}
// H 4 * * *
  • second, minute, hour, dayOfMonth and month take values (hour(9, 18)), progressions (0..59 step 15) or hashed, hashed(0..29), hashed(step = 15), hashed(0..29, 10).
  • month also takes Month values and ranges; dayOfWeek takes DayOfWeek values and ranges in ISO order (SATURDAY..SUNDAY).
  • Day of month rules: last, last(minus = 3), nearestWeekday(15), lastWeekday. Day of week rules: FRIDAY.nth(2), FRIDAY.last.
  • Calling a field again adds to it.
  • Fields left out are *, except fields finer than the finest one you set, which take their first value. hour(9) alone runs at 09:00:00, not every second from 09:00 to 09:59. month(JANUARY) alone runs at midnight on January 1st. Day of week left out is always *.

Serialization

Solera is @Serializable as its canonical expression:

@Serializable
class Job(val name: String, val schedule: Solera)

Json.encodeToString(Job("reaper", Solera.parse("TZ=Asia/Seoul 0 0 4 * * mon-fri")))
// {"name":"reaper","schedule":"CRON_TZ=Asia/Seoul 0 4 * * MON-FRI"}

An invalid expression fails decoding with a SerializationException that carries the syntax error.

Coroutines

xyz.nillpoe.solera:solera-coroutines adds:

val next: Instant = schedule.delayUntilNext()   // suspends until the next run

schedule.ticks().collect { instant ->            // emits each run when it comes
    cleanUp()
}

Both take an optional Clock. ticks skips runs that pass while the collector is busy instead of emitting them late in a burst.

Compile-time checks

With the Gradle plugin, the compiler checks constants passed to parameters marked @SoleraExpression or @SoleraRange, which Solera's own API uses:

Solera.parse("0 25 * * *")
// error: Invalid cron expression at index 2: Hour `25` is out of range. Expected 0-23.

Solera.parse("CRON_TZ=Mars/Olympus 0 4 * * *")
// error: Unknown time zone `Mars/Olympus`. Use an IANA id such as `Asia/Seoul`.

solera {
    minute(75)          // error: `75` is out of range. Expected a value from 0 to 59.
    hour(30..10)        // error: The range `30..10` is empty or counts down.
}

String literals, const vals and their concatenations are checked, as are integer literals, const vals, and .., until, downTo and step with constant ends. Anything computed at run time is left to Solera.parse, which reports the same errors.

Mark your own parameters to get the same checks:

fun schedule(name: String, @SoleraExpression expression: String) = Solera.parse(expression).seeded(name)

The diagnostics are SOLERA_INVALID_EXPRESSION, SOLERA_UNKNOWN_TIME_ZONE, SOLERA_OUT_OF_RANGE and SOLERA_EMPTY_RANGE. Turn the plugin off with:

solera {
    enabled = false
}

addLibraryDependency = false stops the plugin from adding the library.

IntelliJ IDEA shows the errors while you type once it loads the plugin: use K2 mode, open Help | Find Action, run Registry..., set kotlin.k2.only.bundled.compiler.plugins.enabled to false, and restart the IDE.

Compatibility

Kotlin compiler plugins use unstable compiler APIs, so each release works with one Kotlin version. The version name ends with that Kotlin version: 1.0.0-2.4.10 is Solera 1.0.0 for Kotlin 2.4.10. All modules share the version.

Kotlin Solera
2.4.10 1.0.0-2.4.10

The library itself only needs a compatible Kotlin standard library; the version suffix matters for the compiler plugin.

Building

./gradlew build
Module Contents
solera Parser, schedules, builder and serializer, for every Kotlin target
solera-coroutines delayUntilNext and ticks
solera-compiler-plugin FIR checkers for constant expressions and ranges
solera-gradle-plugin The xyz.nillpoe.solera Gradle plugin

The parser and the calendar arithmetic live in solera/src/commonMain/kotlin/xyz/nillpoe/solera/internal and use only the Kotlin standard library, because the compiler plugin compiles the same files to check constants. Keep that package free of other dependencies.

Library tests are in solera/src/jvmTest and solera-coroutines/src/jvmTest. The compiler tests use the Kotlin compiler's own test framework; each file in solera-compiler-plugin/testData becomes a test:

  • box/: compiles and runs fun box(), which returns "OK" on success.
  • diagnostic/: compares the reported errors with the inline markers and the .fir.diag.txt file.

After changing a message or adding a test, run ./gradlew :solera-compiler-plugin:test -Poverwrite to rewrite the expected files, then review the diff.

The Gradle plugin tests publish all modules to build/testing-repository and build sample projects with Gradle TestKit.

Publishing

The build publishes to the Forgejo package registry with basic authentication. It reads the credentials from two Gradle properties, forgejoUsername and forgejoPassword. For the password, create an access token in Forgejo under Settings | Applications with the write:package scope.

Keep the credentials out of this repository. Put them in your user-level Gradle properties file: ~/.gradle/gradle.properties on macOS and Linux, %USERPROFILE%\.gradle\gradle.properties on Windows.

forgejoUsername=<your Forgejo user name>
forgejoPassword=<access token>

On a CI server, set the environment variables ORG_GRADLE_PROJECT_forgejoUsername and ORG_GRADLE_PROJECT_forgejoPassword instead.

Then publish:

./gradlew publishAllPublicationsToForgejoRepository

License

Apache License 2.0. See LICENSE.