Atoll separates elapsed spans from absolute instants, and both from civil
calendar values. Every core temporal type is a @builtin struct over a single
int, so it costs one machine word and crosses the SQL temporal boundary with
no unit conversion.
| Type | Backing value | Meaning |
|---|---|---|
Duration |
microseconds | A signed span — “how long” |
DateTime |
Unix-epoch microseconds, UTC | An instant |
Date |
days since 1970-01-01 | A calendar day |
Time |
microseconds since midnight | A time of day |
TimeZone |
IANA zone id | A zone rule set |
ZonedDateTime |
local microseconds + offset | An instant seen from a zone |
Durations
Every constructor names its unit. There are no bare-number durations.
timeout := Duration.of_seconds(30)
retry := Duration.of_millis(250)
combined := timeout.plus(retry)
println("${combined.total_millis()} ms")The full constructor set is of_nanos, of_micros, of_millis,
of_seconds, of_minutes, of_hours, of_days. Nanosecond input is
truncated to the microsecond storage base — Duration.of_nanos(1500) is
1 microsecond, and total_nanos() reports 1000, not 1500.
println("${Duration.of_nanos(1500).total_micros()}") // 1
println("${Duration.of_nanos(1500).total_nanos()}") // 1000
println("${Duration.of_days(2).total_hours()}") // 48
println("${Duration.of_millis(1500).total_seconds()}") // 1 — truncatesEvery total_* accessor truncates toward zero. Arithmetic and comparison are
named methods:
short := Duration.of_millis(250)
long := Duration.of_seconds(30)
println("${long.minus(short).total_millis()}") // 29750
println("${short.times(4).total_seconds()}") // 1
println("${long.negated().is_negative()}") // true
println("${Duration.of_micros(0).is_zero()}") // true
println("${short.less_than(long)}") // true
println("${long.equals(Duration.of_minutes(1))}") // falseBecause a Duration is backed by an int, the arithmetic and comparison
operators also work directly, and they stay type-safe:
a := Duration.of_seconds(30)
b := Duration.of_seconds(1)
println("${(a + b).total_seconds()}") // 31
println("${b < a}") // true
println("${a == a}") // trueMixing a Duration with a raw number is rejected:
a := Duration.of_seconds(30)
x := a + 5
println("${x.total_seconds()}")That is ATOLL2002: type mismatch: expected Duration, got int. Wrap the
number in the constructor that names its unit.
Instants
DateTime is a UTC instant measured from the Unix epoch. DateTime.now()
reads the host wall clock, so it suspends.
started := DateTime.now()
deadline := started.plus(Duration.of_seconds(30))
elapsed := DateTime.now().diff(started)
println("elapsed ${elapsed.total_micros()} us")
println("overdue = ${DateTime.now().is_after(deadline)}")plus and minus take a Duration; diff returns one. is_before,
is_after, and equals compare two instants.
At protocol boundaries, convert through the epoch accessors. Each
from_epoch_* constructor has a matching epoch_* reader:
t := DateTime.from_epoch_seconds(1700000000)
println("${t.epoch_seconds()}") // 1700000000
println("${t.epoch_millis()}") // 1700000000000
println("${t.epoch_micros()}") // 1700000000000000
println("${t.epoch_nanos()}") // 1700000000000000000
println("${t.epoch_days()}") // 19675
same := DateTime.from_epoch_millis(1700000000000)
println("${same.equals(t)}") // truefrom_epoch_nanos truncates to microseconds, exactly as Duration.of_nanos
does. epoch_millis and epoch_seconds truncate in the other direction.
UTC calendar components come straight off the instant, and date() / time()
project it onto the civil types:
t := DateTime.from_epoch_seconds(1700000000)
println("${t.year()}-${t.month()}-${t.day()} ${t.hour()}:${t.minute()}:${t.second()}")
d := t.date()
clock := t.time()
println("day ${d.epoch_days()}, ${clock.micros_of_day()} us into the day")DateTime.now() is a wall clock, not a monotonic timer. A host clock
adjustment between two calls can make the difference negative; check with
is_negative() before trusting an elapsed span.
Civil values
Date holds a calendar day with no time and no zone. Day arithmetic is
explicit — there is no month or year arithmetic in the current surface.
today := Date.today()
next_week := today.plus_days(7)
yesterday := today.minus_days(1)
println("${next_week.year()}-${next_week.month()}-${next_week.day()}")
println("${yesterday.is_before(today)}") // true
println("${today.equals(today)}") // trueDate.from_epoch_days and epoch_days convert to and from the day count
since 1970-01-01:
epoch_day := Date.from_epoch_days(0)
println("${epoch_day.year()}-${epoch_day.month()}-${epoch_day.day()}") // 1970-1-1Time is a time of day, built from hour/minute/second:
t := Time.of_hms(9, 30, 15)
println("${t.hour()}:${t.minute()}:${t.second()}")
println("${t.micros_of_day()}")Time.of_hms does not validate its arguments — it just multiplies. Range-check
untrusted hour, minute, and second input yourself before constructing one.
Time zones
TimeZone.utc() needs no lookup. TimeZone.of(name) resolves an IANA name
through the host and returns None for an unknown one, so it is an Option:
zone := TimeZone.of("Asia/Kuala_Lumpur")
match zone {
Some(z) => {
local := DateTime.now().in_zone(z)
println("${local.hour()}:${local.minute()}")
}
None => println("unknown zone name")
}When a fallback is acceptable, ?? is shorter:
zone := TimeZone.of("America/New_York") ?? TimeZone.utc()
local := DateTime.now().in_zone(zone)
println("${local.year()}-${local.month()}-${local.day()} ${local.hour()}h")in_zone yields a ZonedDateTime whose accessors read local components.
to_utc() recovers the original instant and offset() reports the zone’s UTC
offset at that instant:
zone := TimeZone.utc()
instant := DateTime.from_epoch_seconds(1700000000)
local := instant.in_zone(zone)
println("offset ${local.offset().total_seconds()} s")
println("round trip = ${local.to_utc().equals(instant)}") // true
println("local date ${local.date().epoch_days()}")Offset and daylight-saving status depend on both the zone and the instant,
so both queries take a DateTime:
instant := DateTime.from_epoch_seconds(1700000000)
match TimeZone.of("Europe/Berlin") {
Some(z) => {
println("offset hours = ${z.offset_at(instant).total_hours()}")
println("dst = ${z.is_dst(instant)}")
}
None => println("no tz database entry")
}Zone lookup and offset queries reach the host time-zone database and suspend.
Store absolute events as DateTime; apply a TimeZone only when displaying
them or making a calendar decision.
A worked example
Deadlines are the common case: an instant plus a span, compared against the clock.
struct Lease {
holder: string
acquired: DateTime
ttl: Duration
}
fn expires_at(lease: Lease): DateTime {
return lease.acquired.plus(lease.ttl)
}
fn is_expired(lease: Lease, at: DateTime): bool {
return expires_at(lease).is_before(at)
}
fn remaining(lease: Lease, at: DateTime): Duration {
left := expires_at(lease).diff(at)
return if left.is_negative() { Duration.of_micros(0) } else { left }
}
fn main(): void {
now := DateTime.now()
stale := Lease {
holder: "worker-3",
acquired: now.minus(Duration.of_minutes(90)),
ttl: Duration.of_hours(1),
}
println("expired = ${is_expired(stale, now)}")
println("remaining = ${remaining(stale, now).total_seconds()}s")
fresh := Lease { holder: "worker-4", acquired: now, ttl: Duration.of_hours(1) }
println("fresh remaining = ${remaining(fresh, now).total_minutes()}m")
zone := TimeZone.of("America/New_York") ?? TimeZone.utc()
local := expires_at(fresh).in_zone(zone)
println("lease expires at ${local.hour()}:${local.minute()} local")
}DateTime.now() and TimeZone.of are host calls, so every function on the
path to them inherits the Suspend effect. You do not annotate that — the
compiler infers it, and the resulting ATOLL2032 warning is expected. See
Effects.
Current limits
The installed temporal surface has no parsing, no formatting, no month or year
arithmetic, no day-of-week values, no date ranges, and no constructor that
resolves a local civil time back into an instant — ZonedDateTime is only ever
produced by DateTime.in_zone. TimeZone also has no name() accessor and no
fixed-offset form; the zone is an opaque id.
Keep that policy in application code or a host integration until the prelude grows the API.