automata
Cron, RRULE, retries, filesystem events, and finite automata for Gleam. Pure data: every module produces the same answers on the BEAM and on the JavaScript target.
Features
automata— deterministic finite automaton helper.automata/cron— UNIX 5-field cron with separate parse, validate, normalise, match, iterate, and next phases.automata/rrule— RFC 5545 RRULE subset (FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTH, BYMONTHDAY, BYHOUR, BYMINUTE) anchored on aValidDateTime.automata/schedule— one matcher / iterator / next-after API across compiled cron, RRULE, fixed-interval, and one-shot schedules.automata/event— typedEvent(body)values with source kinds, correlation / causation / trace metadata, and filter / match combinators.automata/fsevent— fsnotify-style Create / Write / Remove / Rename / Chmod ops derived from two filesystem snapshots, with file_id-based rename detection.automata/retry— deterministic retry policies (fixed, exponential, capped exponential) with optional jitter and a step-by-stepDecisioninterface.
Install
gleam add automata
Cron
import automata/cron
import automata/schedule/ast as schedule_ast
import gleam/io
pub fn main() {
let assert Ok(raw) = cron.parse("*/15 9-17 * * MON-FRI")
let assert Ok(spec) = cron.validate(raw)
let assert Ok(after) =
schedule_ast.try_valid_datetime(
year: 2026,
month: 5,
day: 11,
hour: 9,
minute: 7,
second: 0,
)
cron.next_after(spec, after: after) |> io.debug
}
Reuse a normalised plan when you evaluate the same spec many times:
let plan = cron.normalize(spec)
cron.matches_plan(plan: plan, at: now)
cron.next_after_plan(plan: plan, after: now)
Builder form, without importing the validator submodule:
import automata/cron
pub fn business_hours() {
cron.builder()
|> cron.with_minute(cron.every(15))
|> cron.with_hour(cron.between(from: 9, to: 17))
|> cron.with_day_of_week(cron.one_of([cron.item_range(from: 1, to: 5)]))
|> cron.build
}
RRULE
import automata/rrule
import automata/rrule/validator as rule_validator
import automata/schedule/ast as schedule_ast
pub fn every_other_week() {
let assert Ok(anchor) =
schedule_ast.try_valid_datetime(
year: 2026,
month: 1,
day: 5,
hour: 9,
minute: 30,
second: 0,
)
let assert Ok(spec) =
rrule.builder(rule_validator.Weekly)
|> rrule.with_interval(2)
|> rrule.with_by_day([
rrule.weekday(day: schedule_ast.Monday),
rrule.weekday(day: schedule_ast.Wednesday),
])
|> rrule.with_by_hour([9])
|> rrule.with_by_minute([30])
|> rrule.build
rrule.next_after(spec, anchor: anchor, after: anchor)
}
Anchor seconds are preserved end-to-end. BYSECOND, BYYEARDAY,
BYWEEKNO, BYSETPOS, WKST, DTSTART, RDATE, and EXDATE are
not in the supported subset. rrule.normalize/2 returns an
RRulePlan for use with rrule.matches_plan / iterator_after_plan
/ next_after_plan when you reuse the same spec/anchor pair.
Schedule
import automata/cron
import automata/schedule
pub fn shared_api(now, after) {
let assert Ok(raw) = cron.parse("*/30 9-17 * * 1-5")
let assert Ok(spec) = cron.validate(raw)
let compiled = schedule.from_cron(spec)
schedule.matches(compiled, at: now)
schedule.next_after(compiled, after: after)
}
schedule.from_every(interval_seconds:, anchor:) builds a
fixed-interval schedule; schedule.from_once(at:) fires exactly
once. All four constructors share the same matcher / iterator /
next-after API.
Events
import automata/event/builtin/body
import automata/event/builtin/filter as builtin_filter
import automata/event/filter
import automata/fsevent/ast as fs_ast
import automata/schedule/ast as schedule_ast
pub fn cron_event(now) {
body.new(
id: "evt-001",
occurred_at: now,
source_id: "daily-report",
body: body.scheduled(
plan_id: "daily-report",
fired_at: now,
schedule_kind: body.CronSchedule,
),
)
}
pub fn watch_logs() {
filter.all_of([
builtin_filter.is_file_with_op(op: fs_ast.Write),
builtin_filter.by_path_prefix(prefix: "/var/log/"),
filter.negate(builtin_filter.by_path_suffix(suffix: ".tmp")),
])
}
body.new/4 derives the canonical SourceKind from the body, so
Scheduled pairs with ScheduleSource, FileSystem(_) with
FileSystemSource, and Custom("vendor.kind", _) with
CustomSource("vendor.kind"). Event.occurred_at is a
ValidDateTime, which rejects impossible calendar dates such as
2026-02-30. event.continue_from/5 chains a child event from a
parent, copying correlation_id and trace_id and setting
causation_id automatically.
Filesystem events
automata/fsevent reproduces fsnotify-style operations as a pure
function over two Snapshot values. The library does not touch the
filesystem; the caller produces snapshots from a real walk, mocks,
or a log replay.
import automata/fsevent
import gleam/option.{Some}
pub fn detect_change() {
let assert Ok(p) = fsevent.normalize("/tmp/a.log")
let assert Ok(prev) =
fsevent.entry_file(
path: p,
size: 100,
mtime: 1_700_000_000,
mode: 0o644,
content_hash: Some("abc"),
file_id: Some("inode-1"),
)
let assert Ok(curr) =
fsevent.entry_file(
path: p,
size: 200,
mtime: 1_700_000_500,
mode: 0o644,
content_hash: Some("def"),
file_id: Some("inode-1"),
)
let assert Ok(prev_snap) = fsevent.from_entries([prev])
let assert Ok(curr_snap) = fsevent.from_entries([curr])
fsevent.diff(prev: prev_snap, curr: curr_snap, watch: fsevent.watch())
}
When both snapshots carry the same file_id at different paths, a
move is reported as a single Rename event with renamed_from
attached. Without file_id, the move falls back to Remove plus
Create. Op masks (fsevent.with_ops, fsevent.unwatch_op) drop
unwanted ops before they reach the result list.
Retry
import automata/retry
import automata/retry/ast as retry_ast
pub fn http_backoff() {
let assert Ok(initial) = retry_ast.from_milliseconds(milliseconds: 100)
let assert Ok(cap) = retry_ast.from_seconds(seconds: 30)
let assert Ok(base) =
retry.capped_exponential(
initial: initial,
multiplier: 2,
cap: cap,
max_attempts: 6,
)
retry.with_jitter(policy: base, jitter: retry_ast.FullJitter)
}
pub fn drive(policy) {
let ctx0 = retry.start(policy: policy, seed: 12_345)
case retry.decide(ctx: ctx0, failure: retry_ast.Transient) {
retry.Retry(delay: delay, next: _next) -> delay
retry.GiveUp(reason: _reason) -> retry_ast.unsafe_milliseconds(value: 0)
}
}
max_attempts: N means N total attempts (one initial try plus
N - 1 retries). The retry module never sleeps; the caller drives
the loop and sums retry.cumulative_delay/1 to apply a wall-clock
deadline. The bundled PRNG runs in pure Gleam, so the same
(policy, seed, failure-sequence) triple produces the same
Decision list on the BEAM and the JavaScript target.
Validated date-time
automata/schedule/ast exposes try_datetime/6 and
try_valid_datetime/6, which return Result when components fall
outside the Gregorian range. The opaque ValidDateTime then flows
through cron, RRULE, schedule, and event APIs without further
revalidation.
Documentation
Full API reference: https://hexdocs.pm/automata.
Development setup, code style, and the contribution flow are documented in CONTRIBUTING.md. Released changes are tracked in CHANGELOG.md.
License
MIT