diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d219f65..8e1fc28 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 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 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' + 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 ad0b033..3e55288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -458,10 +458,37 @@ 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`. +- **`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 (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 > 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 + `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`). + - `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 c81b1cf..1671475 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1130,6 +1130,11 @@ 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`). + `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 d1faf5a..3300c7e 100644 --- a/docs/WASM-COMPONENTS-PROPOSAL.md +++ b/docs/WASM-COMPONENTS-PROPOSAL.md @@ -120,7 +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`. Unknown until Phase 0. + `@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 > 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. @@ -149,11 +152,22 @@ 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. + `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` 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` + → 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, because the nightly ignores WIT.** They must be compiled by Scala 2.13 + the scala-wasm plugin. This is the same @@ -182,8 +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 `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 on the `wasm` path (or provide WASI-backed replacements), and + `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. 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..f5d0e2d --- /dev/null +++ b/scripts/wasm/build-scala-java-time.sh @@ -0,0 +1,74 @@ +#!/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, 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. +# 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): #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 (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=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 $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" +git fetch -q origin "$BASE" +git checkout -q -f --detach "$BASE" +git clean -q -fdx -e target +# `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 88af2c5..e750229 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,14 @@ 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/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;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: ' # First line of the reply. Not `| head -1`: once head exits, a dapr that still writes gets SIGPIPE, and @@ -103,24 +112,37 @@ 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 || true)" + 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..a73f425 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,35 @@ 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), 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)}", + 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)}", + 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 = given StateCapability.Instance = state.instance(Store) DaprApp(invokeRoutes = @@ -56,5 +89,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 23defee..b1bfd5a 100644 --- a/wasm/build.sbt +++ b/wasm/build.sbt @@ -59,7 +59,18 @@ 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, 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 > 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") +// 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 +196,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 +304,15 @@ 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). + // `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, + ), projectDependencies := projectDependencies.value.filterNot(m => embeddedInWall(m.name)), Compile / packageBin / mappings ++= (wasmApi / Compile / packageBin / mappings).value ++ (wallInert / Compile / packageBin / mappings).value, @@ -450,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 69fca2c..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 → 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; `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 5ce0e22..d607219 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,14 @@ # 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 + +## [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-17] update | dapr-wasm-components 0.7.5 - Updated: scala-js/scala-wasm-component-spike.md — 0.7.3–0.7.5 leave the WIT and `wkg-config.toml` unchanged; 0.7.4 moves the OCI names, 0.7.5's compose.sh follows them (own provider pull dropped); dapr4s targets 0.7.5 diff --git a/wiki/scala-js/scala-wasm-component-spike.md b/wiki/scala-js/scala-wasm-component-spike.md index beab14c..0e9879a 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,44 @@ 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`. +- `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) (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 + 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 (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` + 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()`, 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`). - POM hygiene: `dapr4s_sjs1_3` brings mainline `org.scala-js:scalajs-library_2.13:1.22.0` + javalib next to the