feat(time): dependency-free Moment on epoch milliseconds #42

Merged
mph merged 5 commits from feature/moment-epoch-millis into main 2026-08-11 17:02:10 +00:00
Owner

Summary

Re-bases Moment from opaque type Moment = Instant to an opaque epoch-millisecond Long, in a new dependency-free cross-built module iw-support-time — exactly the change its scaladoc anticipated ("the time representation might change… or might be different in different contexts").

Why: Instant.parse alone retains scala-java-time's formatter machinery in Scala.js bundles (~158 KB gzip, measured in claude-code-query's cc-chat spike), and no linker option strips it. Consumers of the SDK model also must not inherit ZIO transitively, so the type needed a zero-dependency home.

Design

  • One representation everywhere: epoch-millis Long; JS Date is milli-precision, so JVM parse truncates to match.
  • Shared syntactic guard, platform arithmetic: a shared regex + field-range validation (incl. leap days) owns the ISO-8601 instant grammar; only calendar arithmetic delegates to the platform engine (java.time on JVM, Date built-ins on JS). Date.parse leniency never sees a raw string, so both platforms accept and reject identical inputs.
  • Pinned canonical format: always-milliseconds, always Z (uuuu-MM-ddTHH:mm:ss.SSSZ) — JVM Instant.toString omits zero millis, JS never does; the pin removes the divergence.
  • Tests: golden vectors run on both platforms; JVM adds differential properties against Instant.parse and a parse/format round-trip.
  • Errors carry the diagnosis: parseIso returns Either[Moment.ParseError, Moment] — a mini ADT (Malformed, InvalidDate, InvalidTime, Unrepresentable) with the offending input and a message; parseIsoOption is the throw-it-away convenience. The tapir codec surfaces the real message. Invalid golden vectors pin the specific error, not just rejection.
  • Single-import ergonomics: the companion extends a per-platform MomentPlatform trait, so the platform surface consists of companion members. On the JVM, Moment(instant), Moment(ldt), m.toInstant and both Instant Conversions resolve from Moment's implicit scope with no import at all — the same surface the old companion had. The trait also carries the protected parse/format engine.
  • Moment.now keeps its syntax via an extension on Moment.type living in core (where ZIO is); reads Clock.currentTime(MILLISECONDS) so no java.time value is touched on any platform.
  • tapir JsonCodec[Moment]/Schema[Moment] rebuilt on parseIso/toIsoString — same ISO-string wire format, but java.time is no longer reachable from JS bundles through the codecs.

Breaking

  • Moment.now needs the extension in scope — the usual import works.iterative.core.* covers it; a bare import works.iterative.core.Moment no longer does.
  • Sub-millisecond precision truncates.
  • parseIso returns Either[Moment.ParseError, Moment]; callers that want Option use parseIsoOption.
  • The java.time surface (Moment(instant), m.toInstant, Conversions) exists on the JVM only — unchanged call syntax there, absent on JS by construction.

Downstream

claude-code-query already builds against this locally (its ConversationLogEntry.timestamp becomes Moment); its branch needs this merged and a snapshot in the forge registry before its CI can resolve iw-support-time.

🤖 Generated with Claude Code

## Summary Re-bases `Moment` from `opaque type Moment = Instant` to an opaque epoch-millisecond `Long`, in a new dependency-free cross-built module `iw-support-time` — exactly the change its scaladoc anticipated ("the time representation might change… or might be different in different contexts"). **Why:** `Instant.parse` alone retains scala-java-time's formatter machinery in Scala.js bundles (~158 KB gzip, measured in claude-code-query's cc-chat spike), and no linker option strips it. Consumers of the SDK model also must not inherit ZIO transitively, so the type needed a zero-dependency home. ## Design - **One representation everywhere:** epoch-millis `Long`; JS `Date` is milli-precision, so JVM parse truncates to match. - **Shared syntactic guard, platform arithmetic:** a shared regex + field-range validation (incl. leap days) owns the ISO-8601 instant grammar; only calendar arithmetic delegates to the platform engine (java.time on JVM, `Date` built-ins on JS). `Date.parse` leniency never sees a raw string, so both platforms accept and reject identical inputs. - **Pinned canonical format:** always-milliseconds, always `Z` (`uuuu-MM-ddTHH:mm:ss.SSSZ`) — JVM `Instant.toString` omits zero millis, JS never does; the pin removes the divergence. - **Tests:** golden vectors run on **both** platforms; JVM adds differential properties against `Instant.parse` and a parse/format round-trip. - **Errors carry the diagnosis:** `parseIso` returns `Either[Moment.ParseError, Moment]` — a mini ADT (`Malformed`, `InvalidDate`, `InvalidTime`, `Unrepresentable`) with the offending input and a `message`; `parseIsoOption` is the throw-it-away convenience. The tapir codec surfaces the real message. Invalid golden vectors pin the specific error, not just rejection. - **Single-import ergonomics:** the companion extends a per-platform `MomentPlatform` trait, so the platform surface consists of companion members. On the JVM, `Moment(instant)`, `Moment(ldt)`, `m.toInstant` and both `Instant` `Conversion`s resolve from `Moment`'s implicit scope with **no import at all** — the same surface the old companion had. The trait also carries the protected parse/format engine. - `Moment.now` keeps its syntax via an extension on `Moment.type` living in `core` (where ZIO is); reads `Clock.currentTime(MILLISECONDS)` so no `java.time` value is touched on any platform. - tapir `JsonCodec[Moment]`/`Schema[Moment]` rebuilt on `parseIso`/`toIsoString` — same ISO-string wire format, but `java.time` is no longer reachable from JS bundles through the codecs. ## Breaking - `Moment.now` needs the extension in scope — the usual `import works.iterative.core.*` covers it; a bare `import works.iterative.core.Moment` no longer does. - Sub-millisecond precision truncates. - `parseIso` returns `Either[Moment.ParseError, Moment]`; callers that want `Option` use `parseIsoOption`. - The java.time surface (`Moment(instant)`, `m.toInstant`, `Conversion`s) exists on the JVM only — unchanged call syntax there, absent on JS by construction. ## Downstream claude-code-query already builds against this locally (its `ConversationLogEntry.timestamp` becomes `Moment`); its branch needs this merged and a snapshot in the forge registry before its CI can resolve `iw-support-time`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
New cross-built iw-support-time module. Moment is an opaque epoch-
millisecond Long everywhere; a shared syntactic guard owns ISO-8601
grammar and field ranges, and only calendar arithmetic delegates to the
platform engine (java.time on JVM, Date built-ins on JS) — so both
platforms accept and reject identical inputs and no JS consumer needs
scala-java-time. Golden vectors run on both platforms; JVM pins the
behavior differentially against Instant.parse. java.time interop
(Instant, LocalDateTime, Conversions) is JVM-only via MomentInterop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moment's definition moves to iw-support-time; core depends on it and
keeps the ZIO-flavored current-time read as MomentClock.now (reads
epoch millis directly, no Instant). The tapir JsonCodec/Schema for
Moment go through parseIso/toIsoString instead of JsonCodec.instant —
same ISO-string wire format, but java.time is no longer reachable from
JS bundles through these codecs.

BREAKING: Moment.now is MomentClock.now; Moment.apply(Instant),
Moment.apply(LocalDateTime), toInstant and the Instant Conversions are
JVM-only now, via works.iterative.core.MomentInterop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refactor(core): restore Moment.now syntax via an extension on the companion
Some checks failed
CI / Check Formatting (pull_request) Successful in 1m19s
CI / Compile (pull_request) Failing after 1m26s
CI / Run Tests (pull_request) Has been skipped
CI / Check Linting (pull_request) Failing after 1m15s
4c31f8adfd
Moment.now returns as an extension on Moment.type living in core (where
ZIO is), so the time module stays dependency-free and call sites keep
the original syntax through the usual works.iterative.core wildcard
import. MomentClock as a separate call site is gone; EventRecord reads
Moment.now again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refactor(time): java.time interop on the Moment companion via platform trait
Some checks failed
CI / Check Formatting (pull_request) Successful in 16m4s
CI / Compile (pull_request) Failing after 16m20s
CI / Run Tests (pull_request) Has been skipped
CI / Check Linting (pull_request) Failing after 59s
b87fb4cf89
The Moment companion now extends a per-platform MomentPlatform trait, so
the platform surface is companion members: m.toInstant and both
Conversions resolve from Moment's implicit scope with no import, and
Moment(instant) / Moment(ldt) restore the companion constructors the
pre-epoch-millis API had. MomentInterop is gone; the parse/format engine
methods move into the trait as protected members.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(time): parseIso reports what went wrong via Moment.ParseError
All checks were successful
CI / Check Formatting (pull_request) Successful in 59s
CI / Compile (pull_request) Successful in 2m45s
CI / Check Linting (pull_request) Successful in 3m12s
CI / Run Tests (pull_request) Successful in 2m31s
f2afe79c3d
parseIso returns Either[ParseError, Moment]; the mini ADT (Malformed,
InvalidDate, InvalidTime, Unrepresentable) carries the offending input
and fields, each with a human-readable message. parseIsoOption keeps the
throw-it-away convenience for callers that only care whether it parsed.
The tapir JsonCodec surfaces the real message instead of a generic one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mph force-pushed feature/moment-epoch-millis from f2afe79c3d
All checks were successful
CI / Check Formatting (pull_request) Successful in 59s
CI / Compile (pull_request) Successful in 2m45s
CI / Check Linting (pull_request) Successful in 3m12s
CI / Run Tests (pull_request) Successful in 2m31s
to 2e3a055e54
All checks were successful
CI / Check Formatting (pull_request) Successful in 56s
CI / Compile (pull_request) Successful in 2m18s
CI / Check Linting (pull_request) Successful in 2m25s
CI / Run Tests (pull_request) Successful in 2m30s
2026-08-11 16:51:54 +00:00
Compare
mph merged commit 4cac0760c3 into main 2026-08-11 17:02:10 +00:00
mph deleted branch feature/moment-epoch-millis 2026-08-11 17:02:14 +00:00
mph referenced this pull request from a commit 2026-08-11 17:08:00 +00:00
Sign in to join this conversation.
No description provided.