From 204e8a560128014dfe3ff6e187a0be9449af6a58 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 17 Oct 2026 18:02:11 +0200 Subject: [PATCH 1/4] feat(wasm): java.time formatting and parsing on Wasm components dapr4s-wasm now depends on scala-java-time 2.8.0 (default zone UTC without a JS host) and scala-java-locales 1.6.0 (Locale.getDefault no longer reads `window` without a JS host, cquiroz/scala-java-locales#640). Both are on Central, so no source build. counter gets a `java-time` route (parse/toString, ISO and RFC formatters, localized day name, default zone, UTC/offset zones, Duration); e2e.sh and the consumer check assert its answer under daprd. Still blocked, documented in AGENTS.md with how each retires: the wall-clock (scala-wasm javalib js.Date.now; also reached by every DateTimeFormatter.ofPattern via TimeZone) and tzdb region zones (@EnableReflectiveInstantiation -> Reflect js.Array). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 18 ++++++++++++-- docs/DESIGN.md | 4 ++++ docs/WASM-COMPONENTS-PROPOSAL.md | 25 +++++++++++++------- scripts/wasm/e2e.sh | 11 ++++++++- test/wasm/counter/CounterComponent.scala | 26 +++++++++++++++++++++ wasm/build.sbt | 16 ++++++++++--- wiki/index.md | 2 +- wiki/log.md | 3 +++ wiki/scala-js/scala-wasm-component-spike.md | 26 ++++++++++++++++----- 9 files changed, 110 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51f475c..89c624c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -458,8 +458,22 @@ agents push branches / open drafts with an EMPTY description; the user writes th `PUBLISH_WASM_TO_CENTRAL: 'true'` (ON since 2026-10-13, approved by the user); the wasm build keeps `publish / skip` unless that variable is `'true'`, so local `sbt publish` in `wasm/` never reaches Central. The job publishes `coreJS` locally and runs `sbt -Ddapr4s.wasm.core= ci-release` in `wasm/`. -- **Shared code on the `wasm` path** must avoid `java.time` formatting/parsing, wall-clock - (`Instant.now`), `println` and `UUID.randomUUID`: they reach JS and fail to link. +- **java.time on the `wasm` path**: `dapr4s-wasm` depends on scala-java-time >= 2.8.0 (the default zone is + UTC without a JS host) and scala-java-locales >= 1.6.0 (`Locale.getDefault` no longer reads `window`). So + these link AND run (counter's `java-time` route, checked by `scripts/wasm/e2e.sh` and the consumer check): + `toString`/`parse` of the java.time types, the predefined `DateTimeFormatter`s (`ISO_*`, `RFC_1123_DATE_TIME`), + localized text (`getDisplayName(TextStyle, Locale)`), `ZoneOffset`s, `ZoneId.of("UTC")`, `ZoneId.systemDefault()` + (= UTC), `Duration`. +- **Shared code on the `wasm` path** must still avoid, because they fail to link: + - the wall-clock: `Instant.now`, `Clock.system*`, `System.currentTimeMillis` (scala-wasm's javalib calls + `js.Date.now`), and everything that reaches it: `DateTimeFormatter.ofPattern`/`appendPattern` (any pattern + makes zone-text printing reachable → `TimeZone.getTimeZone` → `Instant.now`) and `java.util.TimeZone`. + Retires when scala-wasm's javalib reads the clock through WASI (`wasi:clocks`). + - region zones (`ZoneId.of("Europe/Prague")`) via `scala-java-time-tzdb`: its provider's static initializer + (`@EnableReflectiveInstantiation`, emitted by the Scala 3 Scala.js backend) registers through + `scala.scalajs.reflect.Reflect`, which builds `js.Array`s. Without tzdb, region ids throw at run time. + Retires when scala-wasm's `Reflect` registration is Wasm-only clean. + - `println` (`console`) and `UUID.randomUUID` (no `SecureRandom`): they reach JS. - Commands (tools: `wkg`, `wasm-tools`, `wit-bindgen-scala` for the build; plus Docker, the dapr CLI + daprd from `dapr init --slim`, `wasmtime` and `wac` for the e2e): - `cd wasm && sbt wallWasm/test` (wall unit tests, plain Scala.js on Node; add diff --git a/docs/DESIGN.md b/docs/DESIGN.md index cb32b7a..794bece 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1130,6 +1130,10 @@ plan and status are in `docs/WASM-COMPONENTS-PROPOSAL.md` §6. The rules are in (`coreWasm`, `-Ddapr4s.wasm.core=source`) to test shared-code changes. The app implements `dapr4s.wasm.DaprComponent` (`def app(using StateCapability): DaprApp`) and names it in the fixed-FQN object `dapr4s.wasm.ComponentRoot`. A reactor component has no `main`, so the object is resolved at link time. +- **java.time.** `dapr4s-wasm` depends on scala-java-time 2.8.0 and scala-java-locales 1.6.0, the first versions + whose formatting/parsing path links without a JS host (default zone UTC, default locale `en`). The wall-clock + (`Instant.now`, scala-wasm's javalib uses `js.Date.now`), `DateTimeFormatter.ofPattern` (reaches it through + `TimeZone`) and tzdb region zones do not link yet; AGENTS.md lists the exact rule. - **No connection config and no blocking bridge.** WIT imports are synchronous calls into the composed outbound provider, which reads `DAPR_HTTP_PORT` itself. - **The third wall** (`src/wasm/internal`) is the only place that sees WIT types. It maps WIT `result` variants into diff --git a/docs/WASM-COMPONENTS-PROPOSAL.md b/docs/WASM-COMPONENTS-PROPOSAL.md index e31341d..222315a 100644 --- a/docs/WASM-COMPONENTS-PROPOSAL.md +++ b/docs/WASM-COMPONENTS-PROPOSAL.md @@ -120,7 +120,8 @@ existing JVM example (state + pub/sub + invoke + an actor) so the three platform - **Compiler/IR match**: dapr4s needs a Scala 3.9 nightly. The fork's linker must read that compiler's Scala.js IR. Phase 0 answers this. - **Shared-code deps without JS**: `scala-java-time` (tzdb) and the `JsonCodec` instances must link without - `@JSImport`. Unknown until Phase 0. + `@JSImport`. Phase 0 answered it (§6, item 2); java.time formatting/parsing works since 2026-10-17, the + wall-clock and tzdb region zones do not yet. - **Hosts**: GC + EH components need Spin canary or recent wasmtime. The gRPC provider is Spin-only. - **Fork churn**: the scala-wasm API "is subject to change without notice". Pin versions. @@ -149,11 +150,18 @@ Answers to the risk questions (section 5): reports success). scala-wasm's only Scala 3 build (`3.8.3-RC1-wasm.4`) is stale and rejects the current generator's output. 2. **Shared code without JS: yes, for what dapr4s itself uses.** Opaque types, `JsonCodec`, the capability tiers, - `DaprApp`/`InvokeRoute`/validation and the exceptions link with zero JS. Excluded: `java.time` - formatting/parsing (`Instant.parse`/`toString`, so also `WorkflowSnapshot.toString`): scala-java-time → - `Locale.getDefault` → scala-java-locales `DefaultLocale` → `window`. Also `Instant.now`/`currentTimeMillis`, - `println` and `UUID.randomUUID` (no `SecureRandom`). `java.time.Duration`, `FiniteDuration`, `UUID.fromString` - and `String.format` link. The published POM also brings mainline `scalajs-library`/`javalib` 1.22.0; the `wasm` + `DaprApp`/`InvokeRoute`/validation and the exceptions link with zero JS. `java.time` formatting/parsing + (`Instant.parse`/`toString`, so also `WorkflowSnapshot.toString`) was excluded in Phase 0 (scala-java-time → + `Locale.getDefault` → scala-java-locales `DefaultLocale` → `window`) and **works since 2026-10-17**: scala-java-locales + 1.6.0 ([cquiroz/scala-java-locales#640](https://github.com/cquiroz/scala-java-locales/pull/640)) reads `window` + only with a JS host, scala-java-time 2.8.0 ([cquiroz/scala-java-time#616](https://github.com/cquiroz/scala-java-time/pull/616)) + defaults to UTC without one; `dapr4s-wasm` depends on both and counter's `java-time` route runs it under daprd. + Still excluded: the wall-clock (`Instant.now`/`Clock`/`currentTimeMillis` → scala-wasm javalib `js.Date.now`; also + reached by every `DateTimeFormatter.ofPattern`, through zone-text printing → `TimeZone`), region zones from + `scala-java-time-tzdb` ([cquiroz/scala-java-time#617](https://github.com/cquiroz/scala-java-time/pull/617) ships + portable data, but the provider's `@EnableReflectiveInstantiation` registration builds `js.Array`s in + `scala.scalajs.reflect.Reflect`), `println` and `UUID.randomUUID` (no `SecureRandom`). `java.time.Duration`, + `FiniteDuration`, `UUID.fromString` and `String.format` link. The published POM also brings mainline `scalajs-library`/`javalib` 1.22.0; the `wasm` build must exclude them. 3. **Bindings under the CC flags: they compile with zero warnings under all six flags, but that does not help, because the nightly ignores WIT.** They must be compiled by Scala 2.13 + the scala-wasm plugin. This is the same @@ -182,8 +190,9 @@ with wasmtime's cache. Warm round trip through the sidecar 2.4 ms median, incl. - Upstream (scala-wasm): params via linear memory on both sides; use the configured WIT world, or keep aliases, in the component type; a Scala 3 WIT backend that tracks the library (or upstreaming to Scala.js). Until then: carry the linker patch and the re-embed step, and keep the stdlib overlay per nightly bump. -- dapr4s shared code must avoid `java.time` formatting and wall-clock/random APIs on the `wasm` path (or provide - WASI-backed replacements). +- dapr4s shared code must avoid wall-clock/random APIs, `DateTimeFormatter.ofPattern` and tzdb region zones on the + `wasm` path (or provide WASI-backed replacements). java.time parsing, the predefined formatters, localized text and + fixed-offset zones work (scala-java-locales 1.6.0 + scala-java-time 2.8.0). - Also found: `compose.sh` exits 1 on success (`tmp` assigned in a `$(...)` subshell), and the wasi-http provider's `store-not-found` payload is daprd's raw body, not the store name. Both are fixed upstream (0.7.1: `compose.sh`; 0.7.2: setup-error payloads are daprd's message). 0.7.2 also diff --git a/scripts/wasm/e2e.sh b/scripts/wasm/e2e.sh index c9216a2..47986ad 100755 --- a/scripts/wasm/e2e.sh +++ b/scripts/wasm/e2e.sh @@ -7,7 +7,8 @@ # counterRaw (test/wasm/counter-raw: bindings + glue, no wall) counter -> 1, then 2 # counter (test/wasm/counter: dapr4s's API) counter -> 1, then 2; error mapping: # missing-store -> ComponentNotFoundException(State, "no-such-store"); stale-etag -> -# ETagMismatchException and the stale write does not land (counter -> 3) +# ETagMismatchException and the stale write does not land (counter -> 3); java-time -> java.time +# parsing/formatting/localized text/default zone (UTC) without a JS host # importsProbe (test/wasm/imports-probe): the four imports with > 16 flat params, which only the # patched linker (dapr4s-wasm-linker) lowers: it must import exactly them; `converse` to daprd's # echo component must answer the very text the probe sent (the params reached the host intact); @@ -74,6 +75,11 @@ if [[ "${SKIP_BUILD:-0}" != 1 ]]; then (cd "$repo/wasm" && sbt -batch ${SBT_ARGS:-} counterRaw/dapr4sComponent counter/dapr4sComponent importsProbe/dapr4sComponent) fi +# counter's java-time route: java.time parsing, ISO/RFC formatting, localized text, the default zone, fixed zones. +expected_java_time='"instant=2026-10-17T10:15:30Z;date=2026-10-18;zoned=2026-10-17T12:15:30+02:00;same=true;'\ +'iso=2026-10-17T05:15:30-05:00;rfc=Sat, 17 Oct 2026 12:15:30 +0200;day=Saturday;default=UTC;'\ +'utc=2026-10-17T10:15:30Z[UTC];duration=PT1H45M"' + expected_imports=$'configuration-read: \ninvocation: \nstate: save\nstate-read: get\ntypes: ' # First line of the reply. Not `| head -1`: once head exits, a dapr that still writes gets SIGPIPE, and @@ -121,6 +127,9 @@ for v in ${E2E_ONLY:-counterRaw counter importsProbe}; do third="$(invoke counter)" [[ "$third" == 3 ]] || fail "$v: counter after the stale write returned '$third', expected 3" echo "$v: the stale write did not land (counter -> 3)" + jt="$(invoke java-time)" + echo "$v: java-time -> $jt" + [[ "$jt" == "$expected_java_time" ]] || fail "$v: java-time returned '$jt', expected '$expected_java_time'" fi stop_app done diff --git a/test/wasm/counter/CounterComponent.scala b/test/wasm/counter/CounterComponent.scala index a8b7e06..59d3679 100644 --- a/test/wasm/counter/CounterComponent.scala +++ b/test/wasm/counter/CounterComponent.scala @@ -5,6 +5,10 @@ import dapr4s.invoke.* import dapr4s.state.* import dapr4s.wasm.DaprComponent +import java.time.{Duration, Instant, LocalDate, ZoneId, ZoneOffset, ZonedDateTime} +import java.time.format.{DateTimeFormatter, TextStyle} +import java.util.Locale + /** The counter on dapr4s's API (`StateCapability.Instance.get/save` + `JsonCodec`), plus two error-mapping probes. * Nothing here knows about WIT or scala-wasm. Run by scripts/wasm/e2e.sh. */ @@ -49,6 +53,27 @@ object CounterComponent extends DaprComponent: case Some(e) => s"${e.getClass.getSimpleName}: ${e.getMessage}" case None => "unexpected success" + /** java.time on a Wasm component (scripts/wasm/e2e.sh checks the answer): ISO and pattern formatting with localized + * text, parsing, the default zone (UTC without a JS host) and fixed-offset zones. Not here because they do not link + * yet (AGENTS.md "Wasm component layer rules"): the wall-clock (`Instant.now`, `Clock`) and region zones from + * scala-java-time-tzdb. + */ + def javaTime(ignored: Unit): String = + val instant = Instant.parse("2026-10-17T10:15:30Z") + val local = ZonedDateTime.parse("2026-10-17T12:15:30+02:00") + List( + s"instant=$instant", + s"date=${LocalDate.parse("2026-10-17").plusDays(1)}", + s"zoned=$local", + s"same=${local.toInstant == instant}", + s"iso=${DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(instant.atOffset(ZoneOffset.ofHours(-5)))}", + s"rfc=${DateTimeFormatter.RFC_1123_DATE_TIME.format(local)}", + s"day=${local.getDayOfWeek.getDisplayName(TextStyle.FULL, Locale.ENGLISH)}", + s"default=${ZoneId.systemDefault()}", + s"utc=${ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"))}", + s"duration=${Duration.parse("PT1H30M").plusMinutes(15)}", + ).mkString(";") + override def app(using state: StateCapability): DaprApp = given StateCapability.Instance = state.instance(Store) DaprApp(invokeRoutes = @@ -56,5 +81,6 @@ object CounterComponent extends DaprComponent: InvokeRoute[Unit, Int](InvokeMethodName("counter"))(counter), InvokeRoute[Unit, String](InvokeMethodName("missing-store"))(missingStore), InvokeRoute[Unit, String](InvokeMethodName("stale-etag"))(staleEtag), + InvokeRoute[Unit, String](InvokeMethodName("java-time"))(javaTime), ), ) diff --git a/wasm/build.sbt b/wasm/build.sbt index c83a334..9707f32 100644 --- a/wasm/build.sbt +++ b/wasm/build.sbt @@ -60,6 +60,8 @@ def rootBuildVal(name: String): String = { val scala3Nightly = rootBuildVal("scala3Nightly") val scalaJavaTime = rootBuildVal("scalaJavaTime") val munitVersion = rootBuildVal("munit") +// Only the wasm path needs it explicitly (see wallWasm); on JS, scala-java-time's own dependency suffices. +val scalaJavaLocales = "1.6.0" // Keep in sync with `compilerFlags` in ../build.sbt. lazy val compilerFlags = Seq( @@ -185,9 +187,9 @@ lazy val coreWasm = project .settings(sourcesIn(repoDir / "src" / "shared", repoDir / "src" / "wasm" / "core")) .settings( name := "dapr4s-wasm-core", - // java.time on the Scala.js-family platforms (part of the shared public API). Only - // Duration-style arithmetic links without JS; formatting/parsing reaches scala-java-locales' - // `window` (wiki: "What dapr4s's shared code needs on a no-JS target"). + // java.time on the Scala.js-family platforms (part of the shared public API). wallWasm adds + // scala-java-locales, whose >= 1.6.0 lets formatting/parsing link without a JS host (wiki: "What + // dapr4s's shared code needs on a no-JS target"). libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTime, ) @@ -293,6 +295,14 @@ lazy val wallWasm = withCore( // with its doc comments, and the API a user touches (dapr4s.wasm.DaprComponent) is documented there. // RETIRES: when a Scala 3 nightly fixes the unpickler crash, or when the 2.13 glue goes away. Compile / doc / sources := Nil, + // java.time on the wasm path. dapr4s core brings scala-java-time (part of the shared API); this pins the + // versions a Wasm component needs, in both core modes and for every app (they are in dapr4s-wasm's POM): + // scala-java-time >= 2.8.0 (the default zone is UTC without a JS host) and scala-java-locales >= 1.6.0 + // (`Locale.getDefault`, reached by every formatter/parser, no longer reads `window` without a JS host). + libraryDependencies ++= Seq( + "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTime, + "io.github.cquiroz" %%% "scala-java-locales" % scalaJavaLocales, + ), projectDependencies := projectDependencies.value.filterNot(m => embeddedInWall(m.name)), Compile / packageBin / mappings ++= (wasmApi / Compile / packageBin / mappings).value ++ (wallInert / Compile / packageBin / mappings).value, diff --git a/wiki/index.md b/wiki/index.md index 69fca2c..491726f 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -163,4 +163,4 @@ Compiling Scala 3 (including capture-checked dapr4s) to JavaScript/WebAssembly | [js.async / js.await, JSPI, and the WebAssembly Backend](scala-js/scala-js-async-jspi-wasm.md) | js.async/js.await semantics (1.19.0+, ES2017+, Scala 3.8+), orphan await + allowOrphanJSAwait, Wasm backend restrictions, JSPI runtime matrix (Node 25+/Chrome 137+), no-intervening-JS-frame rule, rejected Atomics.wait/deasync alternatives, dapr4s's virtual-thread-parking analogue + port field notes (JSImport.Default for CJS, per-request js.async re-entry, AsyncGenerator-from-coroutine recipe) + sbt Wasm+JSPI setup (Scala.js 1.22 `withUseJSPI`, parallelExecution EADDRINUSE footgun) + munit-on-Wasm harness notes (js.async{}.toFuture, raw-Promise vacuous-pass footgun, wasm cleanup-bug wrapper, plain-JS linker wedge on orphan-await test sources, ESM resolution hook, --test-only ineffective on JS, UUID.randomUUID doesn't link) | 2026-06-12 | | [ScalablyTyped Facades with Scala CLI](scala-js/scalablytyped-with-scala-cli.md) | Converter CLI (1.0.0-beta45) with scala-cli: flag landmines (pin --scala 3.3.6, full --scalajs version, -s es2022, typescript required), @types/* as top-level deps, deterministic npmVersion-digest coordinates from package-lock + converter tuple, ivy2Local zero-config resolution + CI caching, ESM gotchas (deep-module values, CJS default-export shim), MutableBuilder option traits, TS-vs-wire mismatches, the published-POM consumer problem + options; sbt GenSource plugin migration (bundler requirement, locked node_modules via npmUpdate override, separate facade project, embedding via packageBin mappings) | 2026-10-12 | | [Capture Checking on Scala.js](scala-js/capture-checking-on-scala-js.md) | CC erased in picklerPhases before GenSJSIR; empirical probe (dapr4s nightly + full flag set passes on JS, zero warnings); explicit-nulls × js.native facades; sealed caps.Capability gotcha; munit/upickle _sjs1_3 availability | 2026-06-11 | -| [scala-wasm Component Spike (dapr4s Phase 0)](scala-js/scala-wasm-component-spike.md) | Partial go: dapr4s-API app (nightly + CC flags) as a WASI component via scala-wasm 1.22.1-wasm.6 + dapr-wasm-components 0.7.0, 1 → 2 against daprd; 5 blockers (nightly stdlib JS `===` → overlay; no Scala 3 WIT codegen → bindings/exports in Scala 2.13 + plugin, empty-output-with-success footgun, stale 3.8.3-RC1-wasm.4 fork; reactor root via link-time contract; linker `???` for >16 flat params; dealiased component type → re-embed with `--encoding utf16`); wit-bindgen-scala; wkg OCI fetch; what links without JS (java.time formatting → locales `window`); exports from a library jar; sizes/cold start; now in-repo as the opt-in `wasm/` build with all 2.13 glue generated (same-name facade over `dapr4swit.lowered`, import/export traits, wall unit tests on Node) | 2026-10-13 | +| [scala-wasm Component Spike (dapr4s Phase 0)](scala-js/scala-wasm-component-spike.md) | Partial go: dapr4s-API app (nightly + CC flags) as a WASI component via scala-wasm 1.22.1-wasm.6 + dapr-wasm-components 0.7.0, 1 → 2 against daprd; 5 blockers (nightly stdlib JS `===` → overlay; no Scala 3 WIT codegen → bindings/exports in Scala 2.13 + plugin, empty-output-with-success footgun, stale 3.8.3-RC1-wasm.4 fork; reactor root via link-time contract; linker `???` for >16 flat params; dealiased component type → re-embed with `--encoding utf16`); wit-bindgen-scala; wkg OCI fetch; what links without JS (java.time formatting/parsing works since scala-java-locales 1.6.0 + scala-java-time 2.8.0; wall-clock, `ofPattern` and tzdb region zones still do not); exports from a library jar; sizes/cold start; now in-repo as the opt-in `wasm/` build with all 2.13 glue generated (same-name facade over `dapr4swit.lowered`, import/export traits, wall unit tests on Node) | 2026-10-17 | diff --git a/wiki/log.md b/wiki/log.md index f8b7a16..2fb144f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,8 @@ # Wiki Log +## [2026-10-17] update | java.time on Wasm components +- Updated: scala-js/scala-wasm-component-spike.md — java.time formatting/parsing links and runs with scala-java-locales 1.6.0 + scala-java-time 2.8.0 (evidence: counter `java-time` route under daprd); still blocked: wall-clock (`js.Date.now`; also reached by every `DateTimeFormatter.ofPattern` via `TimeZone`), tzdb region zones (`@EnableReflectiveInstantiation` → `Reflect` `js.Array`) + ## [2026-10-13] update | dapr-wasm-components 0.7.2: probe e2e asserts converse/schedule/timer results - Updated: scala-js/scala-wasm-component-spike.md — 0.7.2 fixed the three 0.7.1 provider gaps; echo conversation component; scheduler container next to a slim daprd; daprd 1.17.7 job GET encoding bug diff --git a/wiki/scala-js/scala-wasm-component-spike.md b/wiki/scala-js/scala-wasm-component-spike.md index da1b5e4..29aa930 100644 --- a/wiki/scala-js/scala-wasm-component-spike.md +++ b/wiki/scala-js/scala-wasm-component-spike.md @@ -1,8 +1,8 @@ # scala-wasm Component Spike (dapr4s Phase 0) -> Sources: dapr4s Phase-0 spike, 2026-10-12, ported into dapr4s 2026-10-13 ([PR #44](https://github.com/sideeffffect/dapr4s/pull/44): `wasm/`, `src/wasm/`, `test/wasm/`, `scripts/wasm/`); scala-wasm project docs "Limitations", 2026-10-03; Rikito Taniguchi (VirtusLab), 2026-08-24 +> Sources: dapr4s Phase-0 spike, 2026-10-12, ported into dapr4s 2026-10-13 ([PR #44](https://github.com/sideeffffect/dapr4s/pull/44): `wasm/`, `src/wasm/`, `test/wasm/`, `scripts/wasm/`); scala-wasm project docs "Limitations", 2026-10-03; Rikito Taniguchi (VirtusLab), 2026-08-24; java.time link/run probe (counter `java-time` route) with scala-java-locales 1.6.0 + scala-java-time 2.8.0, 2026-10-17 > Raw: [spike build log and evidence](../../raw/scala-js/2026-10-12-dapr4s-wasm-component-spike-log.md); [scala-wasm Limitations](../../raw/scala-js/2026-10-03-scala-wasm-limitations.md); [Scala and Wasm Component Model](../../raw/scala-js/2026-08-24-virtuslab-scala-wasm-component-model.md) -> Updated: 2026-10-13 +> Updated: 2026-10-17 ## Overview @@ -49,10 +49,24 @@ fixes (a local patch was used for one of them). - Links: dapr4s opaque types, `JsonCodec`, `StateCapability` tiers, `DaprApp`/`InvokeRoute`/validation, exceptions, `Charsets`, `java.time.Duration`, `FiniteDuration`, `UUID.fromString`, `String.format`. dapr4s's JS wall is never reachable unless something calls `Dapr(...)`. -- Does not link: any `java.time` **formatting/parsing** (`Instant.parse`/`toString`, `ZonedDateTime`, so also - `WorkflowSnapshot.toString`). The chain is scala-java-time → `Locale.getDefault` → scala-java-locales - `DefaultLocale` → `global:window`. Also `Instant.now`/`currentTimeMillis` (`Date.now`), `println` (`console`), - `UUID.randomUUID` (no `SecureRandom`). +- java.time **formatting/parsing** links and runs since 2026-10-17 (it did not in Phase 0: scala-java-time → + `Locale.getDefault` → scala-java-locales `DefaultLocale` → `global:window`). Fixed by scala-java-locales 1.6.0 + ([#640](https://github.com/cquiroz/scala-java-locales/pull/640): `JSHostLanguage` behind `LinkingInfo.linkTimeIf`, + language `en` without a JS host) + scala-java-time 2.8.0 ([#616](https://github.com/cquiroz/scala-java-time/pull/616): + default `TimeZone` UTC without a JS host). `dapr4s-wasm` depends on both. Verified in a component under daprd + (counter's `java-time` route): `Instant`/`LocalDate`/`ZonedDateTime` `parse`/`toString`, `ISO_OFFSET_DATE_TIME`, + `RFC_1123_DATE_TIME`, `DayOfWeek.getDisplayName(FULL, ENGLISH)` = `Saturday`, `ZoneId.systemDefault()` = `UTC`, + `ZoneId.of("UTC")`, `ZoneOffset`s, `Duration.parse`. +- Still does not link (scala-wasm 1.22.1-wasm.6; its `scala-wasm` branch is the same on 2026-10-17): + - The wall-clock: `Instant.now`/`Clock.systemUTC`/`currentTimeMillis` → javalib `System.currentTimeMillis` = + `js.Date.now()`. Trap: `DateTimeFormatter.ofPattern` reaches it too. Any pattern can contain `z`, so the linker + keeps `ZoneTextPrinterParser`, whose print/parse call `TimeZone.getTimeZone` → `TimeZone` computes the standard + offset at `Instant.now`. Use the predefined formatters. + - Region zones via `scala-java-time-tzdb` 2.8.0, even with its Wasm-portable data + ([#617](https://github.com/cquiroz/scala-java-time/pull/617)): `TzdbZoneRulesProvider`'s static initializer + (the `@EnableReflectiveInstantiation` registration the Scala 3 Scala.js backend emits) calls + `Reflect.registerInstantiatableClass(name, class, js.Array[...])` → `js.Array`/`toJSVarArgs`. + - `println` (`console`), `UUID.randomUUID` (no `SecureRandom`). - Whole-program trap: once anything instantiates `js.WrappedDictionary`, virtual stdlib calls such as `List.groupBy` → `newSpecificBuilder` become JS-tainted everywhere (hit in `DaprAppValidation`). - POM hygiene: `dapr4s_sjs1_3` brings mainline `org.scala-js:scalajs-library_2.13:1.22.0` + javalib next to the From 4f933420307c5353bca5a55ba9dfb9f63217cebb Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 17 Oct 2026 19:04:22 +0200 Subject: [PATCH 2/4] feat(wasm): DateTimeFormatter.ofPattern and region zones on Wasm components Both need an unreleased scala-java-time. scripts/wasm/build-scala-java-time.sh builds it from source (main + cquiroz/scala-java-time#618, tzdb compiled with a Scala 3 nightly) and publishLocal's it as 2.8.1-dapr4s-local; CI runs it with a JDK 17, cached by the script's hash. - ofPattern: any pattern kept ZoneTextPrinterParser reachable, whose TimeZone.getTimeZone computed the raw offset at Instant.now (the wall clock, absent without a JS host). #618 computes it lazily. - Region zones: the tzdb provider's @EnableReflectiveInstantiation registration is a static initializer (always linked). Scala 3 <= 3.10.0 emits it with the JS-backed Reflect.registerInstantiatableClass; scala/scala3@3bb676972 (3.10.1) emits the Scala-typed V2. Only counter and the consumer check use the source build; dapr4s-wasm's POM stays on scala-java-time 2.8.0 (Central must have it). Counter's java-time route now converts to Europe/Prague and New York and formats/parses with ofPattern; e2e asserts the exact answer. The wall clock stays blocked (scala-wasm javalib). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 ++++++ AGENTS.md | 24 +++++-- docs/DESIGN.md | 7 +- docs/WASM-COMPONENTS-PROPOSAL.md | 27 ++++--- scripts/wasm/build-scala-java-time.sh | 79 +++++++++++++++++++++ scripts/wasm/consumer-check.sh | 11 ++- scripts/wasm/e2e.sh | 7 +- test/wasm/counter/CounterComponent.scala | 14 +++- wasm/build.sbt | 18 +++++ wiki/index.md | 2 +- wiki/log.md | 3 + wiki/scala-js/scala-wasm-component-spike.md | 34 +++++++-- 12 files changed, 214 insertions(+), 35 deletions(-) create mode 100755 scripts/wasm/build-scala-java-time.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d219f65..b806878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,29 @@ jobs: wasm/target/scala-wasm-linker ~/.cache/dapr4s/scala-wasm-src key: scala-wasm-linker-${{ hashFiles('scripts/wasm/build-patched-linker.sh', 'scripts/wasm/patches/**') }} + # The UNRELEASED scala-java-time the wasm/ build pins (2.8.1-dapr4s-local: main + the open + # https://github.com/cquiroz/scala-java-time/pull/618, tzdb compiled with a Scala 3 nightly; see + # scripts/wasm/build-scala-java-time.sh). Built from source with a JDK 17 and publishLocal'ed; cached + # by the script's hash (it pins the SHAs, the compilers and the version). Goes away with a + # scala-java-time release that has both. + # Both JDKs: the last one (25) stays the default; the script finds 17 through $JAVA_HOME_17_X64. + - name: Install JDK 17 next to 25 (scala-java-time's build) + if: matrix.platform == 'wasm' + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: | + 17 + 25 + - name: Cache the scala-java-time source build + if: matrix.platform == 'wasm' + uses: actions/cache@v4 + with: + path: ~/.ivy2/local/io.github.cquiroz + key: scala-java-time-src-${{ hashFiles('scripts/wasm/build-scala-java-time.sh') }} + - name: Build and publishLocal scala-java-time + if: matrix.platform == 'wasm' + run: scripts/wasm/build-scala-java-time.sh # Source mode: the wall over dapr4s's own src/shared (covers shared-code changes), unit tests + e2e # (counter-raw, counter, imports-probe against daprd + Redis). - name: Format check, wall unit tests (wasm/, source mode) diff --git a/AGENTS.md b/AGENTS.md index ae311b2..f181670 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -464,18 +464,30 @@ agents push branches / open drafts with an EMPTY description; the user writes th `toString`/`parse` of the java.time types, the predefined `DateTimeFormatter`s (`ISO_*`, `RFC_1123_DATE_TIME`), localized text (`getDisplayName(TextStyle, Locale)`), `ZoneOffset`s, `ZoneId.of("UTC")`, `ZoneId.systemDefault()` (= UTC), `Duration`. +- **`DateTimeFormatter.ofPattern` and region zones** (`ZoneId.of("Europe/Prague")`, from `scala-java-time-tzdb`) + link AND run too, but only with an UNRELEASED scala-java-time: `scripts/wasm/build-scala-java-time.sh` builds it + from source and publishLocal's it as `2.8.1-dapr4s-local` (`scalaJavaTimeWasm` in `wasm/build.sbt`; CI runs the + script with a JDK 17 and caches `~/.ivy2/local/io.github.cquiroz` by its hash). Only the test components + (`counter`, the consumer check) depend on it; `dapr4s-wasm`'s POM stays on the released 2.8.0, because Central + must have every version it names. An app gets both by depending on that scala-java-time (+ `-tzdb`). The two + fixes: + - `ofPattern` (any pattern) kept zone-text printing reachable → `TimeZone.getTimeZone` → `Instant.now`. + https://github.com/cquiroz/scala-java-time/pull/618 computes the raw offset lazily. + - tzdb's provider has `@EnableReflectiveInstantiation`. Its registration is a static initializer (always + linked), and Scala 3 <= 3.10.0 emits it with the JS-backed `Reflect.registerInstantiatableClass(js.Array)`. + Scala 3 emits the Scala-typed `registerInstantiatableClassV2` since scala/scala3@3bb676972 (in 3.10.1, not + released yet), so the script compiles the tzdb module with a Scala 3 nightly. + Retires with a scala-java-time release that has #618 and is built with Scala 3 >= 3.10.1: pin it in + `dapr4s-wasm`, drop `scalaJavaTimeWasm`, the script and its CI steps. - **Shared code on the `wasm` path** must still avoid, because they fail to link: - the wall-clock: `Instant.now`, `Clock.system*`, `System.currentTimeMillis` (scala-wasm's javalib calls - `js.Date.now`), and everything that reaches it: `DateTimeFormatter.ofPattern`/`appendPattern` (any pattern - makes zone-text printing reachable → `TimeZone.getTimeZone` → `Instant.now`) and `java.util.TimeZone`. + `js.Date.now`), and `TimeZone.getRawOffset` (it reads it). With the released scala-java-time 2.8.0 also + `DateTimeFormatter.ofPattern`/`appendPattern` and region zones (see above). Retires when scala-wasm's javalib reads the clock through WASI (`wasi:clocks`). - - region zones (`ZoneId.of("Europe/Prague")`) via `scala-java-time-tzdb`: its provider's static initializer - (`@EnableReflectiveInstantiation`, emitted by the Scala 3 Scala.js backend) registers through - `scala.scalajs.reflect.Reflect`, which builds `js.Array`s. Without tzdb, region ids throw at run time. - Retires when scala-wasm's `Reflect` registration is Wasm-only clean. - `println` (`console`) and `UUID.randomUUID` (no `SecureRandom`): they reach JS. - Commands (tools: `wkg`, `wasm-tools`, `wit-bindgen-scala` for the build; plus Docker, the dapr CLI + daprd from `dapr init --slim`, `wasmtime` and `wac` for the e2e): + - `scripts/wasm/build-scala-java-time.sh` first (the unreleased scala-java-time; needs a JDK 17, idempotent) - `cd wasm && sbt wallWasm/test` (wall unit tests, plain Scala.js on Node; add `-Ddapr4s.wasm.core=source` for the source mode) - `cd wasm && sbt counter/dapr4sComponent` → `wasm/target/components/counter.wasm` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f5def85..1671475 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1131,9 +1131,10 @@ plan and status are in `docs/WASM-COMPONENTS-PROPOSAL.md` §6. The rules are in implements `dapr4s.wasm.DaprComponent` (`def app(using StateCapability): DaprApp`) and names it in the fixed-FQN object `dapr4s.wasm.ComponentRoot`. A reactor component has no `main`, so the object is resolved at link time. - **java.time.** `dapr4s-wasm` depends on scala-java-time 2.8.0 and scala-java-locales 1.6.0, the first versions - whose formatting/parsing path links without a JS host (default zone UTC, default locale `en`). The wall-clock - (`Instant.now`, scala-wasm's javalib uses `js.Date.now`), `DateTimeFormatter.ofPattern` (reaches it through - `TimeZone`) and tzdb region zones do not link yet; AGENTS.md lists the exact rule. + whose formatting/parsing path links without a JS host (default zone UTC, default locale `en`). + `DateTimeFormatter.ofPattern` and tzdb region zones need an unreleased scala-java-time (built from source for the + test components). The wall-clock (`Instant.now`, scala-wasm's javalib uses `js.Date.now`) does not link yet; + AGENTS.md lists the exact rule. - **No connection config and no blocking bridge.** WIT imports are synchronous calls into the composed outbound provider, which reads `DAPR_HTTP_PORT` itself. - **The third wall** (`src/wasm/internal`) is the only place that sees WIT types. It maps WIT `result` variants into diff --git a/docs/WASM-COMPONENTS-PROPOSAL.md b/docs/WASM-COMPONENTS-PROPOSAL.md index 97ecf2a..2f898a0 100644 --- a/docs/WASM-COMPONENTS-PROPOSAL.md +++ b/docs/WASM-COMPONENTS-PROPOSAL.md @@ -120,8 +120,10 @@ existing JVM example (state + pub/sub + invoke + an actor) so the three platform - **Compiler/IR match**: dapr4s needs a Scala 3.9 nightly. The fork's linker must read that compiler's Scala.js IR. Phase 0 answers this. - **Shared-code deps without JS**: `scala-java-time` (tzdb) and the `JsonCodec` instances must link without - `@JSImport`. Phase 0 answered it (§6, item 2); java.time formatting/parsing works since 2026-10-17, the - wall-clock and tzdb region zones do not yet. + `@JSImport`. Phase 0 answered it (§6, item 2); java.time formatting/parsing works since 2026-10-17, and + `DateTimeFormatter.ofPattern` + tzdb region zones with an unreleased scala-java-time built from source (pending + a release with [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618), built with + Scala 3 >= 3.10.1). The wall-clock does not (scala-wasm's javalib). - **Hosts**: GC + EH components need Spin canary or recent wasmtime. The gRPC provider is Spin-only. - **Fork churn**: the scala-wasm API "is subject to change without notice". Pin versions. @@ -156,11 +158,15 @@ Answers to the risk questions (section 5): 1.6.0 ([cquiroz/scala-java-locales#640](https://github.com/cquiroz/scala-java-locales/pull/640)) reads `window` only with a JS host, scala-java-time 2.8.0 ([cquiroz/scala-java-time#616](https://github.com/cquiroz/scala-java-time/pull/616)) defaults to UTC without one; `dapr4s-wasm` depends on both and counter's `java-time` route runs it under daprd. - Still excluded: the wall-clock (`Instant.now`/`Clock`/`currentTimeMillis` → scala-wasm javalib `js.Date.now`; also - reached by every `DateTimeFormatter.ofPattern`, through zone-text printing → `TimeZone`), region zones from - `scala-java-time-tzdb` ([cquiroz/scala-java-time#617](https://github.com/cquiroz/scala-java-time/pull/617) ships - portable data, but the provider's `@EnableReflectiveInstantiation` registration builds `js.Array`s in - `scala.scalajs.reflect.Reflect`), `println` and `UUID.randomUUID` (no `SecureRandom`). `java.time.Duration`, + `DateTimeFormatter.ofPattern` (any pattern reached `Instant.now` through zone-text printing → `TimeZone`) and region + zones from `scala-java-time-tzdb` ([cquiroz/scala-java-time#617](https://github.com/cquiroz/scala-java-time/pull/617) + ships portable data, but Scala 3 <= 3.10.0 emits the provider's `@EnableReflectiveInstantiation` registration with + `js.Array`s) **work since 2026-10-17 with an unreleased scala-java-time**: `scripts/wasm/build-scala-java-time.sh` + builds `main` + [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618) (lazy + `TimeZone` raw offset), and the tzdb module with a Scala 3 nightly that has scala/scala3@3bb676972 (Scala-typed + `registerInstantiatableClassV2`, in 3.10.1). Counter's `java-time` route converts to `Europe/Prague` and formats + and parses with `ofPattern` under daprd. Still excluded: the wall-clock (`Instant.now`/`Clock`/`currentTimeMillis` + → scala-wasm javalib `js.Date.now`), `println` and `UUID.randomUUID` (no `SecureRandom`). `java.time.Duration`, `FiniteDuration`, `UUID.fromString` and `String.format` link. The published POM also brings mainline `scalajs-library`/`javalib` 1.22.0; the `wasm` build must exclude them. 3. **Bindings under the CC flags: they compile with zero warnings under all six flags, but that does not help, @@ -190,9 +196,10 @@ with wasmtime's cache. Warm round trip through the sidecar 2.4 ms median, incl. - Upstream (scala-wasm): params via linear memory on both sides; use the configured WIT world, or keep aliases, in the component type; a Scala 3 WIT backend that tracks the library (or upstreaming to Scala.js). Until then: carry the linker patch and the re-embed step, and keep the stdlib overlay per nightly bump. -- dapr4s shared code must avoid wall-clock/random APIs, `DateTimeFormatter.ofPattern` and tzdb region zones on the - `wasm` path (or provide WASI-backed replacements). java.time parsing, the predefined formatters, localized text and - fixed-offset zones work (scala-java-locales 1.6.0 + scala-java-time 2.8.0). +- dapr4s shared code must avoid wall-clock/random APIs on the `wasm` path (or provide WASI-backed replacements), and + `DateTimeFormatter.ofPattern` and tzdb region zones until a scala-java-time release has #618 and is built with + Scala 3 >= 3.10.1 (an app can use them now with the source build). java.time parsing, the predefined formatters, + localized text and fixed-offset zones work (scala-java-locales 1.6.0 + scala-java-time 2.8.0). - Also found: `compose.sh` exits 1 on success (`tmp` assigned in a `$(...)` subshell), and the wasi-http provider's `store-not-found` payload is daprd's raw body, not the store name. Both are fixed upstream (0.7.1: `compose.sh`; 0.7.2: setup-error payloads are daprd's message). 0.7.2 also diff --git a/scripts/wasm/build-scala-java-time.sh b/scripts/wasm/build-scala-java-time.sh new file mode 100755 index 0000000..2694ebf --- /dev/null +++ b/scripts/wasm/build-scala-java-time.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Build an UNRELEASED scala-java-time from source and publishLocal it (to ~/.ivy2/local) as version +# $SJT_VERSION: scala-java-time_sjs1_3 and scala-java-time-tzdb_sjs1_3. The wasm/ build (build.sbt, +# `scalaJavaTimeWasm`) uses exactly this version. Nothing else of scala-java-time is built. +# +# WHAT: cquiroz/scala-java-time `main` at $BASE, plus a merge of the head of the open upstream PR: +# - https://github.com/cquiroz/scala-java-time/pull/618 (open): `TimeZone.getTimeZone` computes the +# raw offset lazily, so `DateTimeFormatter.ofPattern` no longer links `Instant.now` (the wall clock, +# absent without a JS host). +# and the tzdb module compiled with a Scala 3 NIGHTLY ($TZDB_SCALA) instead of the build's 3.9.0: +# - `scala-java-time-tzdb`'s provider has `@EnableReflectiveInstantiation`. Its registration runs in a +# static initializer, which the linker always keeps. Scala 3 <= 3.10.0 emits it with the JS-backed +# `Reflect.registerInstantiatableClass(..., js.Array(...))`, which does not link without a JS host. +# Scala 3 emits the Scala-typed `registerInstantiatableClassV2` since +# https://github.com/scala/scala3/commit/3bb676972 (on the release-3.10.1 branch, not released yet). +# Core stays on the build's Scala 3 (3.9.0); only the tzdb module needs the newer compiler. +# - The nightly would also become the tzdb POM's scala3-library version, and that would evict the +# scala3-library of every app that depends on it. So the script rewrites that one version in the +# published POM and ivy.xml back to core's. WHY SAFE: the tzdb module's code needs nothing newer +# from the library (it compiles against 3.9.0 core and is unchanged); only the codegen differs. +# WHY: neither fix is in a scala-java-time release yet (newest on Central: 2.8.0). Nothing is pushed +# anywhere: the merge exists only in the local clone. +# RETIRES when a scala-java-time release contains #618 and is built with Scala 3 >= 3.10.1: pin that +# release in wasm/build.sbt (`scalaJavaTimeWasm`), and delete this script and its CI steps. +# +# Idempotent: skips the build when the stamp (SHAs + compiler + version) matches (FORCE=1 rebuilds). +# The CI wasm leg caches ~/.ivy2/local/io.github.cquiroz keyed by this file's hash. +# Needs a JDK 17 for scala-java-time's build (JDK 21+ fails on its fatal deprecation warnings): +# $SJT_JAVA_HOME, else $JAVA_HOME_17_X64 (set by actions/setup-java), else /usr/lib/jvm/java-17-openjdk-amd64. +set -euo pipefail +SJT_VERSION="${SJT_VERSION:-2.8.1-dapr4s-local}" +BASE=b78d9912b28df70d7b11c213c7620e519ca5ae37 # main, "Merge pull request #617" +PR618=09ead550b8bb56424720970b934b6b54058a35f0 +CORE_SCALA=3.9.0 +TZDB_SCALA=3.10.2-RC1-bin-20261017-ae374e9-NIGHTLY +ivy="${IVY_LOCAL:-$HOME/.ivy2/local}" +org="$ivy/io.github.cquiroz" +stampFile="$org/dapr4s-build-stamp-$SJT_VERSION" +stamp="$BASE $PR618 $CORE_SCALA $TZDB_SCALA" +tzdbDir="$org/scala-java-time-tzdb_sjs1_3/$SJT_VERSION" +if [[ -f "$stampFile" && "$(cat "$stampFile")" == "$stamp" && -s "$tzdbDir/jars/scala-java-time-tzdb_sjs1_3.jar" && + "${FORCE:-0}" != 1 ]]; then + echo "build-scala-java-time.sh: $SJT_VERSION up to date in $ivy (FORCE=1 to rebuild)" + exit 0 +fi +jdk="${SJT_JAVA_HOME:-${JAVA_HOME_17_X64:-/usr/lib/jvm/java-17-openjdk-amd64}}" +[[ -x "$jdk/bin/java" ]] || { echo "build-scala-java-time.sh: no JDK 17 at $jdk (set SJT_JAVA_HOME)" >&2; exit 1; } +work="${WORK:-${XDG_CACHE_HOME:-$HOME/.cache}/dapr4s/scala-java-time-src}" +if [[ ! -d "$work/.git" ]]; then + git clone --filter=blob:none --no-checkout https://github.com/cquiroz/scala-java-time.git "$work" +fi +cd "$work" +for ref in "$BASE" pull/618/head; do git fetch -q origin "$ref"; done +git cat-file -e "$PR618^{commit}" || + { echo "build-scala-java-time.sh: the pinned PR head is gone from GitHub; re-pin it" >&2; exit 1; } +git checkout -q -f --detach "$BASE" +git clean -q -fdx -e target +g() { git -c user.name=dapr4s -c user.email=dapr4s@localhost "$@"; } +g merge -q --no-edit "$PR618" +# `set` per project, not `++`: `++` is sticky and would override the tzdb module's compiler again. +JAVA_HOME="$jdk" PATH="$jdk/bin:$PATH" sbt -batch -no-colors --java-home "$jdk" \ + "set ThisBuild / version := \"$SJT_VERSION\"" \ + 'set ThisBuild / resolvers += ("scala3-nightlies" at "https://repo.scala-lang.org/artifactory/maven-nightlies")' \ + "set core.js / scalaVersion := \"$CORE_SCALA\"" \ + "set tzdb.js / scalaVersion := \"$TZDB_SCALA\"" \ + 'set ThisBuild / packageDoc / publishArtifact := false' \ + coreJS/publishLocal tzdbJS/publishLocal +for f in "$tzdbDir"/poms/*.pom "$tzdbDir"/ivys/ivy.xml; do + sed -i "s/$TZDB_SCALA/$CORE_SCALA/g" "$f" + rm -f "$f".md5 "$f".sha1 +done +if grep -rq NIGHTLY "$tzdbDir"/poms "$tzdbDir"/ivys; then + echo "build-scala-java-time.sh: the tzdb POM still names the nightly" >&2 + exit 1 +fi +[[ -s "$tzdbDir/jars/scala-java-time-tzdb_sjs1_3.jar" ]] || + { echo "build-scala-java-time.sh: scala-java-time-tzdb was not published" >&2; exit 1; } +echo "$stamp" >"$stampFile" +echo "build-scala-java-time.sh: published scala-java-time $SJT_VERSION ($stamp) to $ivy" diff --git a/scripts/wasm/consumer-check.sh b/scripts/wasm/consumer-check.sh index e116f03..321db59 100755 --- a/scripts/wasm/consumer-check.sh +++ b/scripts/wasm/consumer-check.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash # Builds the dapr4s test component test/wasm/counter exactly like an external app does: a fresh sbt build # (wasm/target/consumer-check) with ONLY `addSbtPlugin(sbt-dapr4s-wasm)`, `enablePlugins(Dapr4sWasmComponentPlugin)`, -# one libraryDependency (`dapr4s-wasm`), the app's own WIT world + wkg files, and the fixed-name ComponentRoot — no -# in-build workaround. Everything comes from `cd wasm && sbt publishLocal` (run it first) and Maven Central. Then -# runs the counter part of scripts/wasm/e2e.sh on the result. +# one dapr4s libraryDependency (`dapr4s-wasm`), the app's own WIT world + wkg files, and the fixed-name ComponentRoot +# — no in-build workaround. Plus the app's own scala-java-time + scala-java-time-tzdb (`ofPattern`, region zones). +# Everything comes from `cd wasm && sbt publishLocal` (run it first), scripts/wasm/build-scala-java-time.sh and +# Maven Central. Then runs the counter part of scripts/wasm/e2e.sh on the result. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo="$(cd "$here/../.." && pwd)" version="${DAPR4S_WASM_VERSION:-$(cd "$repo/wasm" && sbt -batch -no-colors 'print wallWasm/version' | tail -1 | tr -d '[:space:]')}" nightly="$(sed -n 's/^val scala3Nightly = "\(.*\)"$/\1/p' "$repo/build.sbt")" +sjt="$(sed -n 's/^val scalaJavaTimeWasm = "\(.*\)"$/\1/p' "$repo/wasm/build.sbt")" dir="$repo/wasm/target/consumer-check" rm -rf "$dir" mkdir -p "$dir/project" "$dir/wit" "$dir/src" @@ -31,6 +33,9 @@ lazy val counter = project "-Yexplicit-nulls", "-experimental", "-Wconf:any:error"), dapr4sWasmComponentsDirectory := file("$repo/wasm/target/components"), libraryDependencies += "com.github.sideeffffect" %%% "dapr4s-wasm" % dapr4sWasmVersion, + // The app's own choice: a scala-java-time with ofPattern on Wasm, and the tz database (as on JS). + libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "$sjt", + libraryDependencies += "io.github.cquiroz" %%% "scala-java-time-tzdb" % "$sjt", ) SBT echo "consumer-check: building test/wasm/counter against the published dapr4s wasm artifacts $version" diff --git a/scripts/wasm/e2e.sh b/scripts/wasm/e2e.sh index 803abfb..e05ef7b 100755 --- a/scripts/wasm/e2e.sh +++ b/scripts/wasm/e2e.sh @@ -75,10 +75,13 @@ if [[ "${SKIP_BUILD:-0}" != 1 ]]; then (cd "$repo/wasm" && sbt -batch ${SBT_ARGS:-} counterRaw/dapr4sComponent counter/dapr4sComponent importsProbe/dapr4sComponent) fi -# counter's java-time route: java.time parsing, ISO/RFC formatting, localized text, the default zone, fixed zones. +# counter's java-time route: java.time parsing, ISO/RFC/pattern formatting, localized text, the default zone, +# fixed zones, region zones (scala-java-time-tzdb) in summer and winter time, pattern parsing with a region zone. expected_java_time='"instant=2026-10-17T10:15:30Z;date=2026-10-18;zoned=2026-10-17T12:15:30+02:00;same=true;'\ 'iso=2026-10-17T05:15:30-05:00;rfc=Sat, 17 Oct 2026 12:15:30 +0200;day=Saturday;default=UTC;'\ -'utc=2026-10-17T10:15:30Z[UTC];duration=PT1H45M"' +'utc=2026-10-17T10:15:30Z[UTC];duration=PT1H45M;prague=2026-10-17T12:15:30+02:00[Europe/Prague];'\ +'winter=2026-12-24T18:30+01:00[Europe/Prague];newyork=-04:00;pattern=17.10.2026 12:15 Europe/Prague;'\ +'parsed=2026-12-24T18:30+01:00[Europe/Prague];text=Saturday 17 October 2026"' expected_imports=$'configuration-read: \ninvocation: \nstate: save\nstate-read: get\ntypes: ' diff --git a/test/wasm/counter/CounterComponent.scala b/test/wasm/counter/CounterComponent.scala index 59d3679..a73f425 100644 --- a/test/wasm/counter/CounterComponent.scala +++ b/test/wasm/counter/CounterComponent.scala @@ -54,13 +54,15 @@ object CounterComponent extends DaprComponent: case None => "unexpected success" /** java.time on a Wasm component (scripts/wasm/e2e.sh checks the answer): ISO and pattern formatting with localized - * text, parsing, the default zone (UTC without a JS host) and fixed-offset zones. Not here because they do not link - * yet (AGENTS.md "Wasm component layer rules"): the wall-clock (`Instant.now`, `Clock`) and region zones from - * scala-java-time-tzdb. + * text, parsing, the default zone (UTC without a JS host), fixed-offset zones and region zones (with their summer + * and winter time) from scala-java-time-tzdb. Not here because it does not link yet (AGENTS.md "Wasm component layer + * rules"): the wall-clock (`Instant.now`, `Clock`). */ def javaTime(ignored: Unit): String = val instant = Instant.parse("2026-10-17T10:15:30Z") val local = ZonedDateTime.parse("2026-10-17T12:15:30+02:00") + val prague = ZoneId.of("Europe/Prague") + val pattern = DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm VV", Locale.ENGLISH) List( s"instant=$instant", s"date=${LocalDate.parse("2026-10-17").plusDays(1)}", @@ -72,6 +74,12 @@ object CounterComponent extends DaprComponent: s"default=${ZoneId.systemDefault()}", s"utc=${ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"))}", s"duration=${Duration.parse("PT1H30M").plusMinutes(15)}", + s"prague=${instant.atZone(prague)}", + s"winter=${Instant.parse("2026-12-24T17:30:00Z").atZone(prague)}", + s"newyork=${instant.atZone(ZoneId.of("America/New_York")).getOffset}", + s"pattern=${pattern.format(instant.atZone(prague))}", + s"parsed=${ZonedDateTime.parse("24.12.2026 18:30 Europe/Prague", pattern)}", + s"text=${DateTimeFormatter.ofPattern("EEEE d MMMM uuuu", Locale.ENGLISH).format(local)}", ).mkString(";") override def app(using state: StateCapability): DaprApp = diff --git a/wasm/build.sbt b/wasm/build.sbt index 2dca18d..5d9dd84 100644 --- a/wasm/build.sbt +++ b/wasm/build.sbt @@ -59,6 +59,15 @@ def rootBuildVal(name: String): String = { } val scala3Nightly = rootBuildVal("scala3Nightly") val scalaJavaTime = rootBuildVal("scalaJavaTime") +// An UNRELEASED scala-java-time, built from source and publishLocal'ed by scripts/wasm/build-scala-java-time.sh +// (run it first; CI does). WHAT it adds over the release `scalaJavaTime`: `DateTimeFormatter.ofPattern` links +// without a JS host (https://github.com/cquiroz/scala-java-time/pull/618), and `scala-java-time-tzdb` (region +// zones) links too: it is built with a Scala 3 that emits the Scala-typed reflective registration. +// WHY only the test components use it (an app opts in by depending on a newer scala-java-time), not +// dapr4s-wasm: dapr4s-wasm is published to Central, and its POM must name a version that Central has. +// RETIRES with a scala-java-time release that has both: then dapr4s-wasm pins that release, and this val, the +// script and its CI steps go (see the script's header). +val scalaJavaTimeWasm = "2.8.1-dapr4s-local" val munitVersion = rootBuildVal("munit") // Only the wasm path needs it explicitly (see wallWasm); on JS, scala-java-time's own dependency suffices. val scalaJavaLocales = "1.6.0" @@ -299,6 +308,7 @@ lazy val wallWasm = withCore( // versions a Wasm component needs, in both core modes and for every app (they are in dapr4s-wasm's POM): // scala-java-time >= 2.8.0 (the default zone is UTC without a JS host) and scala-java-locales >= 1.6.0 // (`Locale.getDefault`, reached by every formatter/parser, no longer reads `window` without a JS host). + // `ofPattern` and region zones need a newer scala-java-time, not released yet: see `scalaJavaTimeWasm`. libraryDependencies ++= Seq( "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTime, "io.github.cquiroz" %%% "scala-java-locales" % scalaJavaLocales, @@ -460,6 +470,14 @@ lazy val counter = project .enablePlugins(Dapr4sWasmComponentPlugin) .settings(noPublish, nightly, dapr4sWasmAddGlueExports := false) .settings(sourcesIn(repoDir / "test" / "wasm" / "counter")) + // `DateTimeFormatter.ofPattern` and region zones (`ZoneId.of("Europe/Prague")`, the tz database is the app's + // choice, as on JS): the unreleased scala-java-time (see `scalaJavaTimeWasm`). It evicts dapr4s-wasm's 2.8.0. + .settings( + libraryDependencies ++= Seq( + "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTimeWasm, + "io.github.cquiroz" %%% "scala-java-time-tzdb" % scalaJavaTimeWasm, + ), + ) .dependsOn(wallWasm, witExports) // Calls the four WIT imports with > 16 flat params (jobs.schedule, conversation.converse, diff --git a/wiki/index.md b/wiki/index.md index 491726f..a2c74e2 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -163,4 +163,4 @@ Compiling Scala 3 (including capture-checked dapr4s) to JavaScript/WebAssembly | [js.async / js.await, JSPI, and the WebAssembly Backend](scala-js/scala-js-async-jspi-wasm.md) | js.async/js.await semantics (1.19.0+, ES2017+, Scala 3.8+), orphan await + allowOrphanJSAwait, Wasm backend restrictions, JSPI runtime matrix (Node 25+/Chrome 137+), no-intervening-JS-frame rule, rejected Atomics.wait/deasync alternatives, dapr4s's virtual-thread-parking analogue + port field notes (JSImport.Default for CJS, per-request js.async re-entry, AsyncGenerator-from-coroutine recipe) + sbt Wasm+JSPI setup (Scala.js 1.22 `withUseJSPI`, parallelExecution EADDRINUSE footgun) + munit-on-Wasm harness notes (js.async{}.toFuture, raw-Promise vacuous-pass footgun, wasm cleanup-bug wrapper, plain-JS linker wedge on orphan-await test sources, ESM resolution hook, --test-only ineffective on JS, UUID.randomUUID doesn't link) | 2026-06-12 | | [ScalablyTyped Facades with Scala CLI](scala-js/scalablytyped-with-scala-cli.md) | Converter CLI (1.0.0-beta45) with scala-cli: flag landmines (pin --scala 3.3.6, full --scalajs version, -s es2022, typescript required), @types/* as top-level deps, deterministic npmVersion-digest coordinates from package-lock + converter tuple, ivy2Local zero-config resolution + CI caching, ESM gotchas (deep-module values, CJS default-export shim), MutableBuilder option traits, TS-vs-wire mismatches, the published-POM consumer problem + options; sbt GenSource plugin migration (bundler requirement, locked node_modules via npmUpdate override, separate facade project, embedding via packageBin mappings) | 2026-10-12 | | [Capture Checking on Scala.js](scala-js/capture-checking-on-scala-js.md) | CC erased in picklerPhases before GenSJSIR; empirical probe (dapr4s nightly + full flag set passes on JS, zero warnings); explicit-nulls × js.native facades; sealed caps.Capability gotcha; munit/upickle _sjs1_3 availability | 2026-06-11 | -| [scala-wasm Component Spike (dapr4s Phase 0)](scala-js/scala-wasm-component-spike.md) | Partial go: dapr4s-API app (nightly + CC flags) as a WASI component via scala-wasm 1.22.1-wasm.6 + dapr-wasm-components 0.7.0, 1 → 2 against daprd; 5 blockers (nightly stdlib JS `===` → overlay; no Scala 3 WIT codegen → bindings/exports in Scala 2.13 + plugin, empty-output-with-success footgun, stale 3.8.3-RC1-wasm.4 fork; reactor root via link-time contract; linker `???` for >16 flat params; dealiased component type → re-embed with `--encoding utf16`); wit-bindgen-scala; wkg OCI fetch; what links without JS (java.time formatting/parsing works since scala-java-locales 1.6.0 + scala-java-time 2.8.0; wall-clock, `ofPattern` and tzdb region zones still do not); exports from a library jar; sizes/cold start; now in-repo as the opt-in `wasm/` build with all 2.13 glue generated (same-name facade over `dapr4swit.lowered`, import/export traits, wall unit tests on Node) | 2026-10-17 | +| [scala-wasm Component Spike (dapr4s Phase 0)](scala-js/scala-wasm-component-spike.md) | Partial go: dapr4s-API app (nightly + CC flags) as a WASI component via scala-wasm 1.22.1-wasm.6 + dapr-wasm-components 0.7.0, 1 → 2 against daprd; 5 blockers (nightly stdlib JS `===` → overlay; no Scala 3 WIT codegen → bindings/exports in Scala 2.13 + plugin, empty-output-with-success footgun, stale 3.8.3-RC1-wasm.4 fork; reactor root via link-time contract; linker `???` for >16 flat params; dealiased component type → re-embed with `--encoding utf16`); wit-bindgen-scala; wkg OCI fetch; what links without JS (java.time formatting/parsing works since scala-java-locales 1.6.0 + scala-java-time 2.8.0; `ofPattern` + tzdb region zones with a source-built scala-java-time: cquiroz/scala-java-time#618 + Scala 3 >= 3.10.1 V2 reflective registration; wall-clock still does not); exports from a library jar; sizes/cold start; now in-repo as the opt-in `wasm/` build with all 2.13 glue generated (same-name facade over `dapr4swit.lowered`, import/export traits, wall unit tests on Node) | 2026-10-17 | diff --git a/wiki/log.md b/wiki/log.md index b069896..d8fd8e2 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,8 @@ # Wiki Log +## [2026-10-17] update | ofPattern + region zones on Wasm components (source-built scala-java-time) +- Updated: scala-js/scala-wasm-component-spike.md — `ofPattern` root cause (`TimeZone.getTimeZone` → `Instant.now`; fixed by cquiroz/scala-java-time#618, lazy raw offset) and tzdb root cause (static initializer = linker entry point; Scala 3 <= 3.10.0 emits the JS-backed `Reflect.registerInstantiatableClass`, V2 since scala/scala3@3bb676972, in 3.10.1); dapr4s builds scala-java-time from source; wall-clock still blocked by scala-wasm's javalib + ## [2026-10-17] update | java.time on Wasm components - Updated: scala-js/scala-wasm-component-spike.md — java.time formatting/parsing links and runs with scala-java-locales 1.6.0 + scala-java-time 2.8.0 (evidence: counter `java-time` route under daprd); still blocked: wall-clock (`js.Date.now`; also reached by every `DateTimeFormatter.ofPattern` via `TimeZone`), tzdb region zones (`@EnableReflectiveInstantiation` → `Reflect` `js.Array`) diff --git a/wiki/scala-js/scala-wasm-component-spike.md b/wiki/scala-js/scala-wasm-component-spike.md index 63ae1ee..a2ba529 100644 --- a/wiki/scala-js/scala-wasm-component-spike.md +++ b/wiki/scala-js/scala-wasm-component-spike.md @@ -57,15 +57,35 @@ fixes (a local patch was used for one of them). (counter's `java-time` route): `Instant`/`LocalDate`/`ZonedDateTime` `parse`/`toString`, `ISO_OFFSET_DATE_TIME`, `RFC_1123_DATE_TIME`, `DayOfWeek.getDisplayName(FULL, ENGLISH)` = `Saturday`, `ZoneId.systemDefault()` = `UTC`, `ZoneId.of("UTC")`, `ZoneOffset`s, `Duration.parse`. +- `DateTimeFormatter.ofPattern` and region zones link and run since 2026-10-17, with an UNRELEASED scala-java-time + (`scripts/wasm/build-scala-java-time.sh`, `2.8.1-dapr4s-local`, used by counter + the consumer check only; + `dapr4s-wasm`'s POM stays on 2.8.0 because Central must have it). Counter's answer under wasmtime + daprd adds + `prague=2026-10-17T12:15:30+02:00[Europe/Prague];winter=2026-12-24T18:30+01:00[Europe/Prague];newyork=-04:00;` + `pattern=17.10.2026 12:15 Europe/Prague;parsed=2026-12-24T18:30+01:00[Europe/Prague];text=Saturday 17 October 2026` + (same as the JVM). The two root causes, each proven by a failing link with the released 2.8.0: + - `ofPattern`: any pattern can contain `z`, so the linker keeps `ZoneTextPrinterParser`. Its print/parse call + `TimeZone.getTimeZone`, and the JS `TimeZone.getTimeZone(zoneId)` computed the raw offset as + `rules.getStandardOffset(Instant.now)` → `Clock$SystemClock` → `System.currentTimeMillis` → `global:Date["now"]`. + Once `ofPattern` instantiated the parser, even `Instant.parse` dispatches into it. Fix: + [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618), a private `SimpleTimeZone` + subclass with a `lazy val` raw offset (rules still looked up eagerly). Nothing calls `getRawOffset` on the + formatter path, so `Instant.now` is dropped. + - Region zones: `TzdbZoneRulesProvider` has `@EnableReflectiveInstantiation`, and its registration lives in the + class's static initializer. The linker treats every static initializer as an entry point + (`Analyzer.reachEntryPoints`, "called from core module analyzer"), so merely having the tzdb jar on the classpath + links it. Scala 3 <= 3.10.0 emits the deprecated `Reflect.registerInstantiatableClass(fqcn, cls, js.Array[js.Tuple2[js.Array[Class[_]], js.Function]])` + (JS arrays, JS closures, `toJSVarArgs`). Scala.js 1.20 added the Scala-typed `registerInstantiatableClassV2` + (Scala arrays, `Array[Any] => Any`); the Scala 2 plugin emits it, which is why the 2.13 probe of #617 linked. + Scala 3 emits V2 since [scala/scala3@3bb676972](https://github.com/scala/scala3/commit/3bb676972) (2026-09-07, + a forward port of scala-js/scala-js@e754a55; on `release-3.10.1`, NOT in 3.10.0). Nothing in scala-java-time + can work around it (no source-level way to write a static initializer), so the fix is the compiler: the script + compiles only the tzdb module with nightly `3.10.2-RC1-bin-20261017-ae374e9-NIGHTLY` (core stays on 3.9.0) and + rewrites the nightly scala3-library version in the published POM/ivy.xml back to 3.9.0 (else it evicts every + app's scala3-library). `Reflect.lookupInstantiatableClass` (the core side of discovery) is a Scala `HashMap` + lookup and links fine. - Still does not link (scala-wasm 1.22.1-wasm.6; its `scala-wasm` branch is the same on 2026-10-17): - The wall-clock: `Instant.now`/`Clock.systemUTC`/`currentTimeMillis` → javalib `System.currentTimeMillis` = - `js.Date.now()`. Trap: `DateTimeFormatter.ofPattern` reaches it too. Any pattern can contain `z`, so the linker - keeps `ZoneTextPrinterParser`, whose print/parse call `TimeZone.getTimeZone` → `TimeZone` computes the standard - offset at `Instant.now`. Use the predefined formatters. - - Region zones via `scala-java-time-tzdb` 2.8.0, even with its Wasm-portable data - ([#617](https://github.com/cquiroz/scala-java-time/pull/617)): `TzdbZoneRulesProvider`'s static initializer - (the `@EnableReflectiveInstantiation` registration the Scala 3 Scala.js backend emits) calls - `Reflect.registerInstantiatableClass(name, class, js.Array[...])` → `js.Array`/`toJSVarArgs`. + `js.Date.now()`, and `TimeZone.getRawOffset` (reads it). Out of reach for dapr4s (scala-wasm javalib). - `println` (`console`), `UUID.randomUUID` (no `SecureRandom`). - Whole-program trap: once anything instantiates `js.WrappedDictionary`, virtual stdlib calls such as `List.groupBy` → `newSpecificBuilder` become JS-tainted everywhere (hit in `DaprAppValidation`). From d3bcaa938accedfa77f61920dffc605aac95c15b Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 17 Oct 2026 19:11:59 +0200 Subject: [PATCH 3/4] test(wasm): e2e warms the component up before it counts, and reports failed invokes counter now carries the tz database (~4 MB). On the CI runner the first invocation after "up and running" failed, and set -e ended the script without a message. Warm up until an invocation succeeds, reset the counter, and let every failed invoke reach fail() with dapr's answer and the logs. Co-Authored-By: Claude Opus 5.5 --- scripts/wasm/e2e.sh | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/scripts/wasm/e2e.sh b/scripts/wasm/e2e.sh index e05ef7b..e750229 100755 --- a/scripts/wasm/e2e.sh +++ b/scripts/wasm/e2e.sh @@ -112,25 +112,35 @@ for v in ${E2E_ONLY:-counterRaw counter importsProbe}; do sleep 1 done [[ "$up" == 1 ]] || fail "$v: sidecar + component did not come up" - sleep 1 + # "up and running" does not mean the component answers yet: a big component (counter carries the tz + # database, ~4 MB) can still be compiling on a slow CI runner, and the first invocations then fail. Warm up + # until one succeeds, then reset the counter. Every invoke below is `|| true`, so a failure reaches `fail` + # (with dapr's answer and the logs) instead of a silent `set -e` exit. + warm=0 + for _ in $(seq 1 60); do + invoke counter >/dev/null && { warm=1; break; } + sleep 1 + done + [[ "$warm" == 1 ]] || fail "$v: the component never answered: $(invoke counter || true)" + docker exec "$redis" redis-cli DEL "$APP_ID||counter" >/dev/null - got="$(invoke counter) $(invoke counter)" + got="$(invoke counter || true) $(invoke counter || true)" echo "$v: counter -> $got" [[ "$got" == "1 2" ]] || fail "$v: counter returned '$got', expected '1 2'" if [[ "$v" == counter ]]; then - missing="$(invoke missing-store)" + missing="$(invoke missing-store || true)" echo "$v: missing-store -> $missing" [[ "$missing" == '"ComponentNotFoundException: State no-such-store"' ]] || fail "$v: missing-store returned '$missing'" - stale="$(invoke stale-etag)" + stale="$(invoke stale-etag || true)" echo "$v: stale-etag -> $stale" [[ "$stale" == "\"ETagMismatchException: ETag mismatch for key 'counter' (provided: 999999)\"" ]] || fail "$v: stale-etag returned '$stale'" - third="$(invoke counter)" + third="$(invoke counter || true)" [[ "$third" == 3 ]] || fail "$v: counter after the stale write returned '$third', expected 3" echo "$v: the stale write did not land (counter -> 3)" - jt="$(invoke java-time)" + jt="$(invoke java-time || true)" echo "$v: java-time -> $jt" [[ "$jt" == "$expected_java_time" ]] || fail "$v: java-time returned '$jt', expected '$expected_java_time'" fi From d889bc3466ff0f43df32e73c5f80634563f20deb Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 17 Oct 2026 19:24:18 +0200 Subject: [PATCH 4/4] build(wasm): build scala-java-time main with the merged #618 https://github.com/cquiroz/scala-java-time/pull/618 is merged (48cf7a6). The script now builds that main commit, without a merge step. It waits for a scala-java-time release > 2.8.0; region zones also need that release built with Scala 3 >= 3.10.1. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++-- AGENTS.md | 5 ++-- docs/WASM-COMPONENTS-PROPOSAL.md | 10 +++---- scripts/wasm/build-scala-java-time.sh | 31 +++++++++------------ wasm/build.sbt | 4 +-- wiki/index.md | 2 +- wiki/log.md | 3 ++ wiki/scala-js/scala-wasm-component-spike.md | 4 +-- 8 files changed, 32 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b806878..8e1fc28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,11 +161,11 @@ jobs: wasm/target/scala-wasm-linker ~/.cache/dapr4s/scala-wasm-src key: scala-wasm-linker-${{ hashFiles('scripts/wasm/build-patched-linker.sh', 'scripts/wasm/patches/**') }} - # The UNRELEASED scala-java-time the wasm/ build pins (2.8.1-dapr4s-local: main + the open + # The UNRELEASED scala-java-time the wasm/ build pins (2.8.1-dapr4s-local: main with the merged # https://github.com/cquiroz/scala-java-time/pull/618, tzdb compiled with a Scala 3 nightly; see # scripts/wasm/build-scala-java-time.sh). Built from source with a JDK 17 and publishLocal'ed; cached - # by the script's hash (it pins the SHAs, the compilers and the version). Goes away with a - # scala-java-time release that has both. + # by the script's hash (it pins the SHA, the compilers and the version). Goes away with a + # scala-java-time release > 2.8.0 built with Scala 3 >= 3.10.1. # Both JDKs: the last one (25) stays the default; the script finds 17 through $JAVA_HOME_17_X64. - name: Install JDK 17 next to 25 (scala-java-time's build) if: matrix.platform == 'wasm' diff --git a/AGENTS.md b/AGENTS.md index f181670..3e55288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -472,12 +472,13 @@ agents push branches / open drafts with an EMPTY description; the user writes th must have every version it names. An app gets both by depending on that scala-java-time (+ `-tzdb`). The two fixes: - `ofPattern` (any pattern) kept zone-text printing reachable → `TimeZone.getTimeZone` → `Instant.now`. - https://github.com/cquiroz/scala-java-time/pull/618 computes the raw offset lazily. + https://github.com/cquiroz/scala-java-time/pull/618 (merged 2026-10-17, waits for a release > 2.8.0) + computes the raw offset lazily. The script builds the `main` commit that contains it. - tzdb's provider has `@EnableReflectiveInstantiation`. Its registration is a static initializer (always linked), and Scala 3 <= 3.10.0 emits it with the JS-backed `Reflect.registerInstantiatableClass(js.Array)`. Scala 3 emits the Scala-typed `registerInstantiatableClassV2` since scala/scala3@3bb676972 (in 3.10.1, not released yet), so the script compiles the tzdb module with a Scala 3 nightly. - Retires with a scala-java-time release that has #618 and is built with Scala 3 >= 3.10.1: pin it in + Retires with a scala-java-time release > 2.8.0 (has #618) built with Scala 3 >= 3.10.1 (region zones need both): pin it in `dapr4s-wasm`, drop `scalaJavaTimeWasm`, the script and its CI steps. - **Shared code on the `wasm` path** must still avoid, because they fail to link: - the wall-clock: `Instant.now`, `Clock.system*`, `System.currentTimeMillis` (scala-wasm's javalib calls diff --git a/docs/WASM-COMPONENTS-PROPOSAL.md b/docs/WASM-COMPONENTS-PROPOSAL.md index 2f898a0..3300c7e 100644 --- a/docs/WASM-COMPONENTS-PROPOSAL.md +++ b/docs/WASM-COMPONENTS-PROPOSAL.md @@ -122,8 +122,8 @@ existing JVM example (state + pub/sub + invoke + an actor) so the three platform - **Shared-code deps without JS**: `scala-java-time` (tzdb) and the `JsonCodec` instances must link without `@JSImport`. Phase 0 answered it (§6, item 2); java.time formatting/parsing works since 2026-10-17, and `DateTimeFormatter.ofPattern` + tzdb region zones with an unreleased scala-java-time built from source (pending - a release with [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618), built with - Scala 3 >= 3.10.1). The wall-clock does not (scala-wasm's javalib). + a release > 2.8.0, which has the merged [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618); + region zones also need it built with Scala 3 >= 3.10.1). The wall-clock does not (scala-wasm's javalib). - **Hosts**: GC + EH components need Spin canary or recent wasmtime. The gRPC provider is Spin-only. - **Fork churn**: the scala-wasm API "is subject to change without notice". Pin versions. @@ -162,7 +162,7 @@ Answers to the risk questions (section 5): zones from `scala-java-time-tzdb` ([cquiroz/scala-java-time#617](https://github.com/cquiroz/scala-java-time/pull/617) ships portable data, but Scala 3 <= 3.10.0 emits the provider's `@EnableReflectiveInstantiation` registration with `js.Array`s) **work since 2026-10-17 with an unreleased scala-java-time**: `scripts/wasm/build-scala-java-time.sh` - builds `main` + [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618) (lazy + builds `main` with the merged [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618) (lazy `TimeZone` raw offset), and the tzdb module with a Scala 3 nightly that has scala/scala3@3bb676972 (Scala-typed `registerInstantiatableClassV2`, in 3.10.1). Counter's `java-time` route converts to `Europe/Prague` and formats and parses with `ofPattern` under daprd. Still excluded: the wall-clock (`Instant.now`/`Clock`/`currentTimeMillis` @@ -197,8 +197,8 @@ with wasmtime's cache. Warm round trip through the sidecar 2.4 ms median, incl. in the component type; a Scala 3 WIT backend that tracks the library (or upstreaming to Scala.js). Until then: carry the linker patch and the re-embed step, and keep the stdlib overlay per nightly bump. - dapr4s shared code must avoid wall-clock/random APIs on the `wasm` path (or provide WASI-backed replacements), and - `DateTimeFormatter.ofPattern` and tzdb region zones until a scala-java-time release has #618 and is built with - Scala 3 >= 3.10.1 (an app can use them now with the source build). java.time parsing, the predefined formatters, + `DateTimeFormatter.ofPattern` and tzdb region zones until a scala-java-time release > 2.8.0 (has #618; region zones: built with + Scala 3 >= 3.10.1) exists (an app can use them now with the source build). java.time parsing, the predefined formatters, localized text and fixed-offset zones work (scala-java-locales 1.6.0 + scala-java-time 2.8.0). - Also found: `compose.sh` exits 1 on success (`tmp` assigned in a `$(...)` subshell), and the wasi-http provider's `store-not-found` payload is daprd's raw body, not the store name. diff --git a/scripts/wasm/build-scala-java-time.sh b/scripts/wasm/build-scala-java-time.sh index 2694ebf..f5d0e2d 100755 --- a/scripts/wasm/build-scala-java-time.sh +++ b/scripts/wasm/build-scala-java-time.sh @@ -3,11 +3,11 @@ # $SJT_VERSION: scala-java-time_sjs1_3 and scala-java-time-tzdb_sjs1_3. The wasm/ build (build.sbt, # `scalaJavaTimeWasm`) uses exactly this version. Nothing else of scala-java-time is built. # -# WHAT: cquiroz/scala-java-time `main` at $BASE, plus a merge of the head of the open upstream PR: -# - https://github.com/cquiroz/scala-java-time/pull/618 (open): `TimeZone.getTimeZone` computes the -# raw offset lazily, so `DateTimeFormatter.ofPattern` no longer links `Instant.now` (the wall clock, -# absent without a JS host). -# and the tzdb module compiled with a Scala 3 NIGHTLY ($TZDB_SCALA) instead of the build's 3.9.0: +# WHAT: cquiroz/scala-java-time `main` at $BASE, the merge commit of +# - https://github.com/cquiroz/scala-java-time/pull/618 (merged 2026-10-17): `TimeZone.getTimeZone` +# computes the raw offset lazily, so `DateTimeFormatter.ofPattern` no longer links `Instant.now` (the +# wall clock, absent without a JS host). +# with the tzdb module compiled with a Scala 3 NIGHTLY ($TZDB_SCALA) instead of the build's 3.9.0: # - `scala-java-time-tzdb`'s provider has `@EnableReflectiveInstantiation`. Its registration runs in a # static initializer, which the linker always keeps. Scala 3 <= 3.10.0 emits it with the JS-backed # `Reflect.registerInstantiatableClass(..., js.Array(...))`, which does not link without a JS host. @@ -18,25 +18,24 @@ # scala3-library of every app that depends on it. So the script rewrites that one version in the # published POM and ivy.xml back to core's. WHY SAFE: the tzdb module's code needs nothing newer # from the library (it compiles against 3.9.0 core and is unchanged); only the codegen differs. -# WHY: neither fix is in a scala-java-time release yet (newest on Central: 2.8.0). Nothing is pushed -# anywhere: the merge exists only in the local clone. -# RETIRES when a scala-java-time release contains #618 and is built with Scala 3 >= 3.10.1: pin that -# release in wasm/build.sbt (`scalaJavaTimeWasm`), and delete this script and its CI steps. +# WHY: neither fix is in a scala-java-time release yet (newest on Central: 2.8.0): #618 waits for a release +# > 2.8.0, and region zones also need that release built with Scala 3 >= 3.10.1. +# RETIRES when such a release exists: pin it in wasm/build.sbt (`scalaJavaTimeWasm`), and delete this +# script and its CI steps. # -# Idempotent: skips the build when the stamp (SHAs + compiler + version) matches (FORCE=1 rebuilds). +# Idempotent: skips the build when the stamp (SHA + compilers + version) matches (FORCE=1 rebuilds). # The CI wasm leg caches ~/.ivy2/local/io.github.cquiroz keyed by this file's hash. # Needs a JDK 17 for scala-java-time's build (JDK 21+ fails on its fatal deprecation warnings): # $SJT_JAVA_HOME, else $JAVA_HOME_17_X64 (set by actions/setup-java), else /usr/lib/jvm/java-17-openjdk-amd64. set -euo pipefail SJT_VERSION="${SJT_VERSION:-2.8.1-dapr4s-local}" -BASE=b78d9912b28df70d7b11c213c7620e519ca5ae37 # main, "Merge pull request #617" -PR618=09ead550b8bb56424720970b934b6b54058a35f0 +BASE=48cf7a60dad673b7fca510f77f62d89005049a05 # main, "Merge pull request #618" CORE_SCALA=3.9.0 TZDB_SCALA=3.10.2-RC1-bin-20261017-ae374e9-NIGHTLY ivy="${IVY_LOCAL:-$HOME/.ivy2/local}" org="$ivy/io.github.cquiroz" stampFile="$org/dapr4s-build-stamp-$SJT_VERSION" -stamp="$BASE $PR618 $CORE_SCALA $TZDB_SCALA" +stamp="$BASE $CORE_SCALA $TZDB_SCALA" tzdbDir="$org/scala-java-time-tzdb_sjs1_3/$SJT_VERSION" if [[ -f "$stampFile" && "$(cat "$stampFile")" == "$stamp" && -s "$tzdbDir/jars/scala-java-time-tzdb_sjs1_3.jar" && "${FORCE:-0}" != 1 ]]; then @@ -50,13 +49,9 @@ if [[ ! -d "$work/.git" ]]; then git clone --filter=blob:none --no-checkout https://github.com/cquiroz/scala-java-time.git "$work" fi cd "$work" -for ref in "$BASE" pull/618/head; do git fetch -q origin "$ref"; done -git cat-file -e "$PR618^{commit}" || - { echo "build-scala-java-time.sh: the pinned PR head is gone from GitHub; re-pin it" >&2; exit 1; } +git fetch -q origin "$BASE" git checkout -q -f --detach "$BASE" git clean -q -fdx -e target -g() { git -c user.name=dapr4s -c user.email=dapr4s@localhost "$@"; } -g merge -q --no-edit "$PR618" # `set` per project, not `++`: `++` is sticky and would override the tzdb module's compiler again. JAVA_HOME="$jdk" PATH="$jdk/bin:$PATH" sbt -batch -no-colors --java-home "$jdk" \ "set ThisBuild / version := \"$SJT_VERSION\"" \ diff --git a/wasm/build.sbt b/wasm/build.sbt index 5d9dd84..b1bfd5a 100644 --- a/wasm/build.sbt +++ b/wasm/build.sbt @@ -61,11 +61,11 @@ val scala3Nightly = rootBuildVal("scala3Nightly") val scalaJavaTime = rootBuildVal("scalaJavaTime") // An UNRELEASED scala-java-time, built from source and publishLocal'ed by scripts/wasm/build-scala-java-time.sh // (run it first; CI does). WHAT it adds over the release `scalaJavaTime`: `DateTimeFormatter.ofPattern` links -// without a JS host (https://github.com/cquiroz/scala-java-time/pull/618), and `scala-java-time-tzdb` (region +// without a JS host (https://github.com/cquiroz/scala-java-time/pull/618, merged, waits for a release > 2.8.0), and `scala-java-time-tzdb` (region // zones) links too: it is built with a Scala 3 that emits the Scala-typed reflective registration. // WHY only the test components use it (an app opts in by depending on a newer scala-java-time), not // dapr4s-wasm: dapr4s-wasm is published to Central, and its POM must name a version that Central has. -// RETIRES with a scala-java-time release that has both: then dapr4s-wasm pins that release, and this val, the +// RETIRES with a scala-java-time release > 2.8.0 built with Scala 3 >= 3.10.1: then dapr4s-wasm pins that release, and this val, the // script and its CI steps go (see the script's header). val scalaJavaTimeWasm = "2.8.1-dapr4s-local" val munitVersion = rootBuildVal("munit") diff --git a/wiki/index.md b/wiki/index.md index a2c74e2..a2e9a79 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -163,4 +163,4 @@ Compiling Scala 3 (including capture-checked dapr4s) to JavaScript/WebAssembly | [js.async / js.await, JSPI, and the WebAssembly Backend](scala-js/scala-js-async-jspi-wasm.md) | js.async/js.await semantics (1.19.0+, ES2017+, Scala 3.8+), orphan await + allowOrphanJSAwait, Wasm backend restrictions, JSPI runtime matrix (Node 25+/Chrome 137+), no-intervening-JS-frame rule, rejected Atomics.wait/deasync alternatives, dapr4s's virtual-thread-parking analogue + port field notes (JSImport.Default for CJS, per-request js.async re-entry, AsyncGenerator-from-coroutine recipe) + sbt Wasm+JSPI setup (Scala.js 1.22 `withUseJSPI`, parallelExecution EADDRINUSE footgun) + munit-on-Wasm harness notes (js.async{}.toFuture, raw-Promise vacuous-pass footgun, wasm cleanup-bug wrapper, plain-JS linker wedge on orphan-await test sources, ESM resolution hook, --test-only ineffective on JS, UUID.randomUUID doesn't link) | 2026-06-12 | | [ScalablyTyped Facades with Scala CLI](scala-js/scalablytyped-with-scala-cli.md) | Converter CLI (1.0.0-beta45) with scala-cli: flag landmines (pin --scala 3.3.6, full --scalajs version, -s es2022, typescript required), @types/* as top-level deps, deterministic npmVersion-digest coordinates from package-lock + converter tuple, ivy2Local zero-config resolution + CI caching, ESM gotchas (deep-module values, CJS default-export shim), MutableBuilder option traits, TS-vs-wire mismatches, the published-POM consumer problem + options; sbt GenSource plugin migration (bundler requirement, locked node_modules via npmUpdate override, separate facade project, embedding via packageBin mappings) | 2026-10-12 | | [Capture Checking on Scala.js](scala-js/capture-checking-on-scala-js.md) | CC erased in picklerPhases before GenSJSIR; empirical probe (dapr4s nightly + full flag set passes on JS, zero warnings); explicit-nulls × js.native facades; sealed caps.Capability gotcha; munit/upickle _sjs1_3 availability | 2026-06-11 | -| [scala-wasm Component Spike (dapr4s Phase 0)](scala-js/scala-wasm-component-spike.md) | Partial go: dapr4s-API app (nightly + CC flags) as a WASI component via scala-wasm 1.22.1-wasm.6 + dapr-wasm-components 0.7.0, 1 → 2 against daprd; 5 blockers (nightly stdlib JS `===` → overlay; no Scala 3 WIT codegen → bindings/exports in Scala 2.13 + plugin, empty-output-with-success footgun, stale 3.8.3-RC1-wasm.4 fork; reactor root via link-time contract; linker `???` for >16 flat params; dealiased component type → re-embed with `--encoding utf16`); wit-bindgen-scala; wkg OCI fetch; what links without JS (java.time formatting/parsing works since scala-java-locales 1.6.0 + scala-java-time 2.8.0; `ofPattern` + tzdb region zones with a source-built scala-java-time: cquiroz/scala-java-time#618 + Scala 3 >= 3.10.1 V2 reflective registration; wall-clock still does not); exports from a library jar; sizes/cold start; now in-repo as the opt-in `wasm/` build with all 2.13 glue generated (same-name facade over `dapr4swit.lowered`, import/export traits, wall unit tests on Node) | 2026-10-17 | +| [scala-wasm Component Spike (dapr4s Phase 0)](scala-js/scala-wasm-component-spike.md) | Partial go: dapr4s-API app (nightly + CC flags) as a WASI component via scala-wasm 1.22.1-wasm.6 + dapr-wasm-components 0.7.0, 1 → 2 against daprd; 5 blockers (nightly stdlib JS `===` → overlay; no Scala 3 WIT codegen → bindings/exports in Scala 2.13 + plugin, empty-output-with-success footgun, stale 3.8.3-RC1-wasm.4 fork; reactor root via link-time contract; linker `???` for >16 flat params; dealiased component type → re-embed with `--encoding utf16`); wit-bindgen-scala; wkg OCI fetch; what links without JS (java.time formatting/parsing works since scala-java-locales 1.6.0 + scala-java-time 2.8.0; `ofPattern` + tzdb region zones with a source-built scala-java-time: merged cquiroz/scala-java-time#618 (no release yet) + Scala 3 >= 3.10.1 V2 reflective registration; wall-clock still does not); exports from a library jar; sizes/cold start; now in-repo as the opt-in `wasm/` build with all 2.13 glue generated (same-name facade over `dapr4swit.lowered`, import/export traits, wall unit tests on Node) | 2026-10-17 | diff --git a/wiki/log.md b/wiki/log.md index d8fd8e2..d607219 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,8 @@ # Wiki Log +## [2026-10-17] update | cquiroz/scala-java-time#618 merged +- Updated: scala-js/scala-wasm-component-spike.md — #618 merged (48cf7a6); dapr4s builds that `main` commit (no merge step); waits for a release > 2.8.0, region zones also for one built with Scala 3 >= 3.10.1 + ## [2026-10-17] update | ofPattern + region zones on Wasm components (source-built scala-java-time) - Updated: scala-js/scala-wasm-component-spike.md — `ofPattern` root cause (`TimeZone.getTimeZone` → `Instant.now`; fixed by cquiroz/scala-java-time#618, lazy raw offset) and tzdb root cause (static initializer = linker entry point; Scala 3 <= 3.10.0 emits the JS-backed `Reflect.registerInstantiatableClass`, V2 since scala/scala3@3bb676972, in 3.10.1); dapr4s builds scala-java-time from source; wall-clock still blocked by scala-wasm's javalib diff --git a/wiki/scala-js/scala-wasm-component-spike.md b/wiki/scala-js/scala-wasm-component-spike.md index a2ba529..0e9879a 100644 --- a/wiki/scala-js/scala-wasm-component-spike.md +++ b/wiki/scala-js/scala-wasm-component-spike.md @@ -67,7 +67,7 @@ fixes (a local patch was used for one of them). `TimeZone.getTimeZone`, and the JS `TimeZone.getTimeZone(zoneId)` computed the raw offset as `rules.getStandardOffset(Instant.now)` → `Clock$SystemClock` → `System.currentTimeMillis` → `global:Date["now"]`. Once `ofPattern` instantiated the parser, even `Instant.parse` dispatches into it. Fix: - [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618), a private `SimpleTimeZone` + [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618) (merged 2026-10-17, in no release yet; the script builds `main` at its merge commit 48cf7a6), a private `SimpleTimeZone` subclass with a `lazy val` raw offset (rules still looked up eagerly). Nothing calls `getRawOffset` on the formatter path, so `Instant.now` is dropped. - Region zones: `TzdbZoneRulesProvider` has `@EnableReflectiveInstantiation`, and its registration lives in the @@ -78,7 +78,7 @@ fixes (a local patch was used for one of them). (Scala arrays, `Array[Any] => Any`); the Scala 2 plugin emits it, which is why the 2.13 probe of #617 linked. Scala 3 emits V2 since [scala/scala3@3bb676972](https://github.com/scala/scala3/commit/3bb676972) (2026-09-07, a forward port of scala-js/scala-js@e754a55; on `release-3.10.1`, NOT in 3.10.0). Nothing in scala-java-time - can work around it (no source-level way to write a static initializer), so the fix is the compiler: the script + can work around it (no source-level way to write a static initializer), so the fix is the compiler (region zones need a scala-java-time release > 2.8.0 built with Scala 3 >= 3.10.1): the script compiles only the tzdb module with nightly `3.10.2-RC1-bin-20261017-ae374e9-NIGHTLY` (core stays on 3.9.0) and rewrites the nightly scala3-library version in the published POM/ivy.xml back to 3.9.0 (else it evicts every app's scala3-library). `Reflect.lookupInstantiatableClass` (the core side of discovery) is a Scala `HashMap`