From 3972fdd0f2dc863328c38048583f3066cba939c4 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Tue, 20 Oct 2026 23:54:23 -0400 Subject: [PATCH 01/19] loko 0.2: Clock, Random, Env, Console, and Store services, platforms, and the derive Services passed to code as traits, with test, system, and JavaScript implementations, one crate per service. #[service] adds a HasX trait to a service trait X and makes every HasX an X; #[derive(Services)] makes a struct each service its fields provide. TestPlatform, StdPlatform, TokioPlatform, and JsPlatform bundle Clock, Random, Env, and Console. Store and the object_store adapter move here from lakekit. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 18 + .github/workflows/ci.yml | 73 ++ .gitignore | 3 + .pre-commit-config.yaml | 45 ++ AGENTS.md | 31 + CHANGELOG.md | 27 + CLAUDE.md | 1 + CONTRIBUTING.md | 84 ++ Cargo.lock | 1096 +++++++++++++++++++++++++++ Cargo.toml | 61 ++ NOTICE | 6 + PRIOR_ART.md | 26 + README.md | 108 +++ clippy.toml | 2 + crates/loko-clock/Cargo.toml | 38 + crates/loko-clock/README.md | 24 + crates/loko-clock/src/lib.rs | 209 +++++ crates/loko-console/Cargo.toml | 29 + crates/loko-console/README.md | 20 + crates/loko-console/src/lib.rs | 137 ++++ crates/loko-env/Cargo.toml | 20 + crates/loko-env/README.md | 17 + crates/loko-env/src/lib.rs | 117 +++ crates/loko-macros/Cargo.toml | 22 + crates/loko-macros/README.md | 29 + crates/loko-macros/src/lib.rs | 209 +++++ crates/loko-object-store/Cargo.toml | 30 + crates/loko-object-store/README.md | 24 + crates/loko-object-store/src/lib.rs | 243 ++++++ crates/loko-random/Cargo.toml | 37 + crates/loko-random/README.md | 19 + crates/loko-random/src/lib.rs | 176 +++++ crates/loko-store/Cargo.toml | 21 + crates/loko-store/README.md | 15 + crates/loko-store/src/lib.rs | 51 ++ crates/loko-store/src/memory.rs | 151 ++++ crates/loko-store/src/store.rs | 50 ++ crates/loko/Cargo.toml | 44 ++ crates/loko/README.md | 33 + crates/loko/src/lib.rs | 89 +++ crates/loko/src/platform.rs | 146 ++++ crates/loko/tests/services.rs | 103 +++ rust-toolchain.toml | 4 + rustfmt.toml | 3 + spec/0001-services.md | 64 ++ spec/0002-clock.md | 47 ++ spec/0003-random.md | 44 ++ spec/0004-env.md | 39 + spec/0005-console.md | 40 + spec/0006-store.md | 47 ++ spec/0007-platforms.md | 49 ++ spec/README.md | 48 ++ spec/TEMPLATE.md | 36 + spec/milestones/README.md | 7 + spec/milestones/v0-2.md | 20 + 55 files changed, 4132 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 NOTICE create mode 100644 PRIOR_ART.md create mode 100644 README.md create mode 100644 clippy.toml create mode 100644 crates/loko-clock/Cargo.toml create mode 100644 crates/loko-clock/README.md create mode 100644 crates/loko-clock/src/lib.rs create mode 100644 crates/loko-console/Cargo.toml create mode 100644 crates/loko-console/README.md create mode 100644 crates/loko-console/src/lib.rs create mode 100644 crates/loko-env/Cargo.toml create mode 100644 crates/loko-env/README.md create mode 100644 crates/loko-env/src/lib.rs create mode 100644 crates/loko-macros/Cargo.toml create mode 100644 crates/loko-macros/README.md create mode 100644 crates/loko-macros/src/lib.rs create mode 100644 crates/loko-object-store/Cargo.toml create mode 100644 crates/loko-object-store/README.md create mode 100644 crates/loko-object-store/src/lib.rs create mode 100644 crates/loko-random/Cargo.toml create mode 100644 crates/loko-random/README.md create mode 100644 crates/loko-random/src/lib.rs create mode 100644 crates/loko-store/Cargo.toml create mode 100644 crates/loko-store/README.md create mode 100644 crates/loko-store/src/lib.rs create mode 100644 crates/loko-store/src/memory.rs create mode 100644 crates/loko-store/src/store.rs create mode 100644 crates/loko/Cargo.toml create mode 100644 crates/loko/README.md create mode 100644 crates/loko/src/lib.rs create mode 100644 crates/loko/src/platform.rs create mode 100644 crates/loko/tests/services.rs create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 spec/0001-services.md create mode 100644 spec/0002-clock.md create mode 100644 spec/0003-random.md create mode 100644 spec/0004-env.md create mode 100644 spec/0005-console.md create mode 100644 spec/0006-store.md create mode 100644 spec/0007-platforms.md create mode 100644 spec/README.md create mode 100644 spec/TEMPLATE.md create mode 100644 spec/milestones/README.md create mode 100644 spec/milestones/v0-2.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..06bdc49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# Dependabot keeps dependencies current: one pull request a week per ecosystem with minor and patch updates, and one +# per major update. A new version is proposed once it's 7 days old (security updates at once). +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: { interval: weekly } + cooldown: { default-days: 7 } + groups: + actions: { update-types: [minor, patch] } + + - package-ecosystem: cargo + directory: / + schedule: { interval: weekly } + cooldown: { default-days: 7 } + groups: + cargo: { update-types: [minor, patch] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f177ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +# Checks every push to main and every pull request. Needs no secrets. +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + native: + name: Native (format, lint, tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install # from rust-toolchain.toml + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-features --all-targets -- -D warnings + - run: cargo test --workspace --all-features + # Each feature builds on its own, and with none, without warnings. + - name: Features one at a time + env: + RUSTFLAGS: -D warnings + run: | + cargo check -p loko --no-default-features + cargo check -p loko + cargo check -p loko --features tokio + cargo check -p loko --features rand + cargo check -p loko --features store + cargo check -p loko --features object-store + cargo check -p loko-clock --no-default-features + cargo check -p loko-clock --features tokio + cargo check -p loko-random --no-default-features + cargo check -p loko-random --features getrandom + cargo check -p loko-random --features rand + cargo check -p loko-env + cargo check -p loko-console + cargo check -p loko-store + cargo check -p loko-object-store + + wasm: + name: WebAssembly (browser and WASI targets) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install # from rust-toolchain.toml + - run: rustup target add wasm32-unknown-unknown wasm32-wasip2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - run: cargo clippy --workspace --all-features --target wasm32-unknown-unknown -- -D warnings + - run: cargo clippy --workspace --all-features --target wasm32-wasip2 -- -D warnings + # The JavaScript implementations on their own, and the browser build without them. + - name: Features one at a time + env: + RUSTFLAGS: -D warnings + run: | + cargo check -p loko --no-default-features --target wasm32-unknown-unknown + cargo check -p loko --no-default-features --features js --target wasm32-unknown-unknown + cargo check -p loko --target wasm32-wasip2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c393a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +.env* +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7535203 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +# Git hooks, run by prek (https://prek.j178.dev): `uv tool install prek && prek install --install-hooks`. +# On commit: formatters and fast checks on the staged files. On push: clippy, natively and for both wasm32 targets. +# Everything: `prek run --all-files --hook-stage pre-push`. CI runs the same checks plus the tests. +default_install_hook_types: [pre-commit, pre-push] + +repos: + - repo: builtin + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + - id: check-toml + - id: check-added-large-files + args: [--maxkb=1024] + - id: detect-private-key + + - repo: local + hooks: + - id: rustfmt + name: rustfmt + entry: rustfmt --edition 2024 + language: system + types: [rust] + - id: clippy + name: clippy + entry: cargo clippy --workspace --all-features --all-targets --quiet -- -D warnings + language: system + types: [rust] + pass_filenames: false + stages: [pre-push] + - id: clippy-wasm + name: clippy (wasm32) + entry: cargo clippy --workspace --all-features --target wasm32-unknown-unknown --quiet -- -D warnings + language: system + types: [rust] + pass_filenames: false + stages: [pre-push] + - id: clippy-wasi + name: clippy (wasm32-wasip2) + entry: cargo clippy --workspace --all-features --target wasm32-wasip2 --quiet -- -D warnings + language: system + types: [rust] + pass_filenames: false + stages: [pre-push] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eb9900a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Agent instructions + +loko: services for Rust programs (`Clock`, `Random`, `Env`, `Console`, `Store`) passed to code as traits, with test, +system, and JavaScript implementations. One crate per service (`crates/loko-`), the derive macros +(`crates/loko-macros`), and the `loko` crate, which re-exports them and adds the platforms. The polythings projects +(Timelake, lakekit, lakeglass) use it. + +## Before changing code + +- Follow [CONTRIBUTING.md](CONTRIBUTING.md). It is the code guide, and it applies to you. +- For anything beyond a small fix, check the specs ([spec/README.md](spec/README.md)). Agree on the design with the user + before building something new, and record the decision in the spec's Decisions table. + +## Rules + +- **Done means checked.** Before you report work as finished, run `prek run --all-files --hook-stage pre-push` and + `cargo test --workspace --all-features`. Report failures as failures. +- **Fix the code, not the checks.** Don't loosen lint or format configs. Don't add `#[allow]`. Use + `#[expect(..., reason = "...")]` only when the tool is genuinely wrong. +- **Keep things in sync:** a change in behaviour updates its spec (status, design, history), the crate's README, the + tests, and `CHANGELOG.md` (under `Unreleased`) in the same commit. +- **Everything builds for WASM.** Every crate builds for `wasm32-unknown-unknown` and `wasm32-wasip2`. JavaScript + implementations are gated to `wasm32-unknown-unknown` and feature `js`; system ones to targets that have a system. + CI checks all three targets. +- **Every service is a `#[loko_macros::service]` trait** whose methods take `&self` and return `impl Future` without a + `Send` bound when they are async. +- **Test implementations are deterministic** and their clones share state. +- **Ideas, not code.** Never copy or translate another project's code. Credit ideas in `PRIOR_ART.md` + (CONTRIBUTING.md, "Credit"). +- **Tests run offline.** No network, cloud accounts, or real buckets in tests. +- **Never commit secrets** or `.env*` files. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad88369 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to loko. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and +versions follow [Semantic Versioning](https://semver.org/). + +## Unreleased + +The first release of loko as a services library. It is version 0.2.0 because the `loko` name on crates.io carries an +unrelated 0.1.0 from 2022 (task scripting), by the same owner. + +### Added + +- `loko-clock`: the `Clock` service (`now`, `now_ms`, `sleep`), `TestClock`, `StdClock`, `TokioClock` (feature + `tokio`), and `JsClock` (feature `js`). +- `loko-random`: the `Random` service (`next_u64`, `fill`), `SeededRandom` (SplitMix64), `OsRandom` (feature + `getrandom`), `JsRandom` (feature `js`), and `Random` for a `Mutex` around a `rand_core` generator (feature `rand`). +- `loko-env`: the `Env` service (`var`, `vars`), `SystemEnv`, and `MapEnv`. +- `loko-console`: the `Console` service (`print_line`, `print_error_line`), `StdConsole`, `TestConsole`, and + `JsConsole` (feature `js`). +- `loko-store`: the `Store` service (objects by URI, with put-if-absent, multipart uploads, and prefix listing), + `MemoryStore`, and an error type. It started as lakekit's `Store`. +- `loko-object-store`: a `Store` over an `object_store` client, from a client or a URL. It started as + `lakekit-object-store`. +- `loko-macros`: `#[service]`, which adds a `HasX` trait to a service trait `X` and makes every `HasX` an `X`, and + `#[derive(Services)]`, which makes a struct each service its fields provide. +- `loko`: the services under one crate (`loko::clock`, …, `loko::store` with feature `store`), the platforms + `TestPlatform`, `StdPlatform`, `TokioPlatform`, and `JsPlatform`, and a prelude. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1e6ad17 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to loko + +This is the code guide for everyone who changes this repository, people and AI agents alike. Formatters and linters +enforce the mechanical rules (see [Checks](#checks)). This guide covers what they can't: how code should read. +[spec/](spec/README.md) holds the design. + +## Principles + +1. **Readable first.** Someone new should understand a function from its name, its signature, and one pass through + the body. +2. **One crate per service.** A service's crate holds its trait and its implementations, and depends on no other + service's crate, so a library can take just the service it needs. The `loko` crate re-exports them all and holds + what combines them (the platforms). +3. **Small traits.** A service has the few methods its callers need, and provided methods for what follows from them + (`now_ms` from `now`). Implementations are where the variety goes. +4. **Every target.** Each service has a test implementation that runs anywhere, a system one where there is a system + (native and WASI), and a JavaScript one for `wasm32-unknown-unknown` where it makes sense. +5. **One definition of each thing.** Before writing a helper, constant, or type, look for an existing one. +6. **Behaviour, spec, and tests change together.** A change users can see updates its spec, adds or updates tests, and + adds a line to [CHANGELOG.md](CHANGELOG.md). + +## Comments and docs + +- **Public items get a short doc comment**: what it does, and anything a caller must know (errors, units, which + targets, whether it blocks). One to three lines is usually enough. +- **Comments explain why, not what.** Explain a non-obvious choice or a workaround. +- **No long comments.** If an explanation needs a page, it belongs in a spec. Link to it from the code. + +## Credit + +loko learns from other projects' ideas, never from copying their code. [PRIOR_ART.md](PRIOR_ART.md) lists the ideas +we took. + +- **Never copy code**, whatever its license: not by pasting, and not by translating it into another language. +- **Credit ideas from open-source projects** with a row in PRIOR_ART.md and a short comment where the idea is + implemented. +- **Don't name open-core, dual-licensed, or source-available projects, or commercial products, as a source.** + +## Rust + +- **Services:** a trait marked `#[loko_macros::service]`, methods on `&self`, async methods returning `impl Future` + without `Send`. Implementations that hold state share it between clones (`Arc>`). +- **Errors:** what the platform gives (`io::Result` for the console), or the crate's own `non_exhaustive` error enum + (`loko_store::Error`). No `unwrap()` outside tests; a poisoned lock is recovered, not a panic. +- **Targets:** JavaScript code under `#[cfg(all(feature = "js", target_arch = "wasm32", target_os = "unknown"))]`; + system code under `#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]`. +- **Suppressing a lint:** `#[expect(lint, reason = "...")]` with a real reason. Never `#[allow]`. + +## Tests + +- **A bug fix starts with a test that fails**, then the fix. +- **Tests run offline** and use the test implementations; a test of a system implementation checks only what holds on + every machine. + +## Checks + +Install once: `uv tool install prek && prek install --install-hooks`. Hooks then run on commit and on push. Before you +push, or before calling work done: + +```bash +prek run --all-files --hook-stage pre-push # rustfmt, clippy (native, wasm32-unknown-unknown, wasm32-wasip2) +cargo test --workspace --all-features # unit and doc tests +``` + +## Dependencies + +Keep dependencies on their newest versions; check the latest release when adding one. Actions are pinned to a commit +with the version in a comment (`uses: actions/checkout@ # v7.0.1`). + +## Commits + +- **Subject:** a short imperative sentence with the area first, for example `Clock: …`, `Macros: …`, or `Spec: …`. + Add a body when the why isn't obvious. +- **Scope:** one logical change per commit. + +## Changelog + +[CHANGELOG.md](CHANGELOG.md) is for users, not a list of commits. Anything a user can notice gets one line under +`## Unreleased`, in `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, or `Security`, in the same commit as the +change. + +## License + +Contributions are licensed under the Apache License 2.0, like the rest of the project (see [LICENSE](LICENSE)). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..61fedfa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1096 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loko" +version = "0.2.0" +dependencies = [ + "loko-clock", + "loko-console", + "loko-env", + "loko-macros", + "loko-object-store", + "loko-random", + "loko-store", + "tokio", +] + +[[package]] +name = "loko-clock" +version = "0.2.0" +dependencies = [ + "js-sys", + "loko-macros", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "loko-console" +version = "0.2.0" +dependencies = [ + "js-sys", + "loko-macros", + "wasm-bindgen", +] + +[[package]] +name = "loko-env" +version = "0.2.0" +dependencies = [ + "loko-macros", +] + +[[package]] +name = "loko-macros" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "loko-object-store" +version = "0.2.0" +dependencies = [ + "futures-util", + "loko-store", + "object_store", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "loko-random" +version = "0.2.0" +dependencies = [ + "getrandom", + "js-sys", + "loko-macros", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "loko-store" +version = "0.2.0" +dependencies = [ + "loko-macros", + "thiserror", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object_store" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1796bc93603f78c5760a69f2d58badc9618d22adade0a95385bb2adbae4eb94" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-core", + "futures-util", + "http", + "humantime", + "itertools", + "nix", + "parking_lot", + "percent-encoding", + "thiserror", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e614690 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,61 @@ +[workspace] +resolver = "2" +members = [ + "crates/loko-clock", + "crates/loko-random", + "crates/loko-env", + "crates/loko-console", + "crates/loko-store", + "crates/loko-object-store", + "crates/loko-macros", + "crates/loko", +] + +[workspace.package] +version = "0.2.0" +edition = "2024" +rust-version = "1.99" +license = "Apache-2.0" +repository = "https://github.com/polythings/loko" +keywords = ["services", "clock", "random", "testing", "wasm"] +categories = ["development-tools::testing", "wasm", "asynchronous"] + +[workspace.dependencies] +loko-clock = { path = "crates/loko-clock", version = "0.2.0" } +loko-random = { path = "crates/loko-random", version = "0.2.0" } +loko-env = { path = "crates/loko-env", version = "0.2.0" } +loko-console = { path = "crates/loko-console", version = "0.2.0" } +loko-store = { path = "crates/loko-store", version = "0.2.0" } +loko-object-store = { path = "crates/loko-object-store", version = "0.2.0" } +loko-macros = { path = "crates/loko-macros", version = "0.2.0" } +loko = { path = "crates/loko", version = "0.2.0" } +thiserror = "2" +futures-util = { version = "0.3", default-features = false, features = ["alloc"] } +object_store = { version = "0.14", default-features = false } +url = "2" +rand_core = { version = "0.10", default-features = false } +tokio = { version = "1", features = ["time"] } +getrandom = "0.4" +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + +[workspace.lints.rust] +unsafe_code = "forbid" +unreachable_pub = "warn" +missing_debug_implementations = "warn" +missing_docs = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +missing_errors_doc = "allow" +missing_panics_doc = "allow" +must_use_candidate = "allow" +allow_attributes = "warn" +dbg_macro = "warn" +todo = "warn" +unwrap_used = "warn" diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ac52329 --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +loko +Copyright 2026 Oleksandr Litus and the loko contributors + +This product is licensed under the Apache License, Version 2.0 (see LICENSE). + +Ideas loko borrowed from other projects are credited in PRIOR_ART.md. diff --git a/PRIOR_ART.md b/PRIOR_ART.md new file mode 100644 index 0000000..a3a6a1d --- /dev/null +++ b/PRIOR_ART.md @@ -0,0 +1,26 @@ +# Prior art + +loko stands on other open-source projects and learns from more. This file credits the ideas we took. We take ideas, +never code: every line of loko is our own. How we credit is in [CONTRIBUTING.md](CONTRIBUTING.md#credit). + +## Foundations + +| Project | License | What loko uses | +|---|---|---| +| [tokio](https://github.com/tokio-rs/tokio) | MIT | The timer behind `TokioClock` | +| [getrandom](https://github.com/rust-random/getrandom) | Apache-2.0 / MIT | The operating system's generator behind `OsRandom` | +| [rand_core](https://github.com/rust-random/rand) | Apache-2.0 / MIT | The generator trait `Random` accepts (feature `rand`) | +| [wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen) (`js-sys`, `wasm-bindgen-futures`) | Apache-2.0 / MIT | The JavaScript implementations | +| [object_store](https://github.com/apache/arrow-rs-object-store) | Apache-2.0 / MIT | The stores `loko-object-store` adapts | +| [syn](https://github.com/dtolnay/syn), [quote](https://github.com/dtolnay/quote) | Apache-2.0 / MIT | The macros | + +## Ideas + +| Idea | From | License of the source | Where in loko | +|---|---|---|---| +| The time, randomness, the console, and the system as services passed to code, each with a test implementation (a clock that moves when told) | [ZIO](https://github.com/zio/zio) (`Clock`, `Random`, `Console`, `System`, `TestClock`) | Apache-2.0 | The service crates and `TestPlatform` | +| An environment of services described by "has a" traits (`HasX`), so code asks for the services it needs and any environment holding them qualifies | [RIO](https://github.com/commercialhaskell/rio) (`HasLogFunc` and the "Has" type class pattern) | MIT | `#[service]`'s `HasX` traits | +| Implementing a trait for a struct by forwarding to a field, chosen with a derive and a field attribute | [ambassador](https://github.com/hobofan/ambassador) | Apache-2.0 / MIT | `#[derive(Services)]` | +| SplitMix64: a 64-bit state advanced by a constant and mixed with two multiply-xorshift rounds | Steele, Lea, and Flood, "Fast Splittable Pseudorandom Number Generators" (OOPSLA 2014) | A published algorithm | `SeededRandom` | +| A storage interface with no I/O library of its own, supplied by the application | [delta-kernel-rs](https://github.com/delta-io/delta-kernel-rs) (its `Engine` trait), by way of lakekit | Apache-2.0 | `Store` | +| One crate per backend under an umbrella crate with a feature each | [arrow-rs](https://github.com/apache/arrow-rs), [Apache OpenDAL](https://github.com/apache/opendal) | Apache-2.0 | The workspace layout | diff --git a/README.md b/README.md new file mode 100644 index 0000000..63962b7 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# loko + +Services for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed +to code as traits instead of reached for as globals. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the +program passes the system's services, and tests pass services they control. The same code runs natively, under WASI, +and in browsers and Workers. + +loko is what the [polythings](https://github.com/polythings) projects share (Timelake, lakekit, and lakeglass among +them), and anyone can use it. + +```rust +use std::time::Duration; + +use loko::{Clock, Random, TestPlatform}; + +/// An ID from the time and a random suffix. +fn id(services: &(impl Clock + Random)) -> String { + format!("{}-{:04x}", services.now_ms(), services.next_u64() & 0xffff) +} + +let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program +platform.clock.advance(Duration::from_secs(1)); +assert_eq!(id(&platform), "1000-cdaf"); // the same on every run +``` + +## Services + +| Service | Methods | For tests | System | JavaScript (feature `js`) | +|---|---|---|---|---| +| [`Clock`](crates/loko-clock) | `now`, `now_ms`, `sleep` | `TestClock` | `StdClock`, `TokioClock` (feature `tokio`) | `JsClock` | +| [`Random`](crates/loko-random) | `next_u64`, `fill` | `SeededRandom` | `OsRandom` (feature `getrandom`) | `JsRandom` | +| [`Env`](crates/loko-env) | `var`, `vars` | `MapEnv` | `SystemEnv` | `MapEnv` | +| [`Console`](crates/loko-console) | `print_line`, `print_error_line` | `TestConsole` | `StdConsole` | `JsConsole` | +| [`Store`](crates/loko-store) (feature `store`) | get, put, put-if-absent, upload, list, delete | `MemoryStore` | [`ObjectStoreAdapter`](crates/loko-object-store) (feature `object-store`) | the application's binding | + +Futures aren't required to be `Send`, so a service can wrap JavaScript promises. With feature `rand`, any `rand_core` +generator in a `Mutex` is a `Random`. + +## Platforms + +A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, and is each of them: + +| Platform | Clock | Random | Env | Console | Where | +|---|---|---|---|---|---| +| `TestPlatform` | `TestClock` | `SeededRandom` | `MapEnv` | `TestConsole` | Tests, anywhere | +| `StdPlatform` | `StdClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native and WASI, without an async runtime | +| `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native, under tokio (feature `tokio`) | +| `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | Browsers, Workers, and Node (feature `js`) | + +## Your own services and bundles + +`#[loko::service]` makes a trait a service, and `#[derive(Services)]` makes a struct each service its fields provide. +A program bundles a platform with a store and its own services; a service's implementation can need other services. + +```rust +use loko::prelude::*; +use loko::store::MemoryStore; + +#[loko::service] +trait Greeter { + fn greet(&self, name: &str) -> String; +} + +#[derive(Services)] +struct App { + #[service(Clock, Random, Env, Console)] + platform: TestPlatform, + #[service(Store)] + store: MemoryStore, + #[service(Greeter)] + greeter: G, +} + +fn welcome(app: &(impl Greeter + Env + Console)) -> std::io::Result<()> { + let name = app.var("USER").unwrap_or_else(|| "stranger".into()); + app.print_line(&app.greet(&name)) +} +``` + +`#[service]` on `trait Greeter` adds `trait HasGreeter` (a type that holds a `Greeter`) and makes every `HasGreeter` +a `Greeter`; the derive implements `HasGreeter` for `App`. It names `HasGreeter` next to the path you give, so bring +both in (`loko::prelude::*` does for loko's services) or write the path (`#[service(loko::Clock)]`). + +## Crates + +| Crate | What it holds | +|---|---| +| [`loko`](crates/loko) | Everything below, the platforms, and a prelude, with a feature per optional part | +| [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One service each, with its implementations | +| [`loko-object-store`](crates/loko-object-store) | A `Store` over an `object_store` client: S3, GCS, Azure, local files, memory | +| [`loko-macros`](crates/loko-macros) | `#[service]` and `#[derive(Services)]` | + +A library that needs one service can depend on its crate alone. + +## Development + +```bash +cargo test --workspace --all-features # tests, offline +cargo build --workspace --all-features --target wasm32-unknown-unknown # browsers and Workers +cargo build --workspace --all-features --target wasm32-wasip2 # WASI +prek run --all-files --hook-stage pre-push # formatters and linters +``` + +[CONTRIBUTING.md](CONTRIBUTING.md) is the code guide, and [spec/](spec/README.md) holds the design. + +## License + +Apache License 2.0 ([LICENSE](LICENSE)). diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..731d87f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +allow-unwrap-in-tests = true +doc-valid-idents = ["WebAssembly", ".."] diff --git a/crates/loko-clock/Cargo.toml b/crates/loko-clock/Cargo.toml new file mode 100644 index 0000000..6ab64ad --- /dev/null +++ b/crates/loko-clock/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "loko-clock" +description = "loko's Clock service: the time and a timer, with test, tokio, and JavaScript clocks" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +loko-macros.workspace = true + +[features] +default = [] +# `TokioClock`: the system clock and tokio's timer (native targets). +tokio = ["dep:tokio"] +# `JsClock`: `Date.now` and `setTimeout` (`wasm32-unknown-unknown` with a JavaScript host). +js = ["dep:js-sys", "dep:wasm-bindgen", "dep:wasm-bindgen-futures"] + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, optional = true } + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +js-sys = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/crates/loko-clock/README.md b/crates/loko-clock/README.md new file mode 100644 index 0000000..0fe40ba --- /dev/null +++ b/crates/loko-clock/README.md @@ -0,0 +1,24 @@ +# loko-clock + +[loko](https://github.com/polythings/loko)'s `Clock` service: the time and a timer, passed to code instead of read +from the system, so tests control time and the same code runs natively, under WASI, and in browsers. + +- `Clock`: `now` (a `SystemTime`), `now_ms` (milliseconds since the Unix epoch), and `sleep(Duration)`. The future + isn't required to be `Send`. +- `TestClock`: moves only when told (`advance`, `set`) or when slept on, so a test of a retry loop runs at once. + Clones share the time. +- `StdClock`: the system clock, with a `sleep` that blocks the thread (native and WASI, no async runtime needed). +- `TokioClock` (feature `tokio`): the system clock and tokio's timer. +- `JsClock` (feature `js`, `wasm32-unknown-unknown`): `Date.now` and `setTimeout`. + +```rust +use std::time::Duration; + +use loko_clock::{Clock, TestClock}; + +let clock = TestClock::at_ms(1_000); +clock.advance(Duration::from_secs(1)); +assert_eq!(clock.now_ms(), 2_000); +``` + +Apache License 2.0. diff --git a/crates/loko-clock/src/lib.rs b/crates/loko-clock/src/lib.rs new file mode 100644 index 0000000..9a6e97c --- /dev/null +++ b/crates/loko-clock/src/lib.rs @@ -0,0 +1,209 @@ +//! loko's `Clock` service: the time and a timer, passed to code instead of read from the system, so +//! tests control time and the same code runs natively and in WebAssembly. +//! +//! - [`Clock`]: `now`, `now_ms`, and `sleep`. +//! - [`TestClock`]: a clock that moves only when told (or when slept on), for tests. +//! - `StdClock`: the system clock with a blocking sleep, for programs without an async runtime. +//! - `TokioClock` (feature `tokio`): the system clock and tokio's timer, on native targets. +//! - `JsClock` (feature `js`): `Date.now` and `setTimeout`, in browsers, Workers, and Node. +//! +//! ``` +//! use std::time::Duration; +//! +//! use loko_clock::{Clock, TestClock}; +//! +//! let clock = TestClock::at_ms(1_000); +//! clock.advance(Duration::from_secs(1)); +//! assert_eq!(clock.now_ms(), 2_000); +//! ``` + +use std::future::{Future, ready}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// The time and a timer. +/// +/// The future isn't required to be `Send`, so a clock can wrap JavaScript timers. +#[loko_macros::service] +pub trait Clock { + /// The current time. + fn now(&self) -> SystemTime; + + /// Milliseconds since the Unix epoch (negative before it). + fn now_ms(&self) -> i64 { + match self.now().duration_since(UNIX_EPOCH) { + Ok(since) => i64::try_from(since.as_millis()).unwrap_or(i64::MAX), + Err(before) => i64::try_from(before.duration().as_millis()).map_or(i64::MIN, |ms| -ms), + } + } + + /// Waits `duration`. + fn sleep(&self, duration: Duration) -> impl Future; +} + +/// The time `ms` milliseconds after the Unix epoch (before it, if negative). +pub fn from_ms(ms: i64) -> SystemTime { + let magnitude = Duration::from_millis(ms.unsigned_abs()); + if ms >= 0 { UNIX_EPOCH + magnitude } else { UNIX_EPOCH - magnitude } +} + +/// A clock that moves only when told, or when slept on (sleeping returns at once, the clock moved +/// forward by the sleep). Clones share the same time. The idea is ZIO's `TestClock`. +#[derive(Clone, Debug)] +pub struct TestClock { + now: Arc>, +} + +impl TestClock { + /// A clock at `now`. + pub fn new(now: SystemTime) -> Self { + Self { now: Arc::new(Mutex::new(now)) } + } + + /// A clock `ms` milliseconds after the Unix epoch. + pub fn at_ms(ms: i64) -> Self { + Self::new(from_ms(ms)) + } + + /// Moves the clock forward by `duration`. + pub fn advance(&self, duration: Duration) { + *self.now.lock().unwrap_or_else(PoisonError::into_inner) += duration; + } + + /// Sets the clock to `now`, forward or back. + pub fn set(&self, now: SystemTime) { + *self.now.lock().unwrap_or_else(PoisonError::into_inner) = now; + } +} + +impl Default for TestClock { + /// A clock at the Unix epoch. + fn default() -> Self { + Self::new(UNIX_EPOCH) + } +} + +impl Clock for TestClock { + fn now(&self) -> SystemTime { + *self.now.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn sleep(&self, duration: Duration) -> impl Future { + self.advance(duration); + ready(()) + } +} + +/// The system clock for programs without an async runtime (native targets and WASI). Its `sleep` blocks the +/// calling thread before the future is returned, so in async code use `TokioClock` (or another +/// runtime's clock) instead. +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +#[derive(Clone, Copy, Debug, Default)] +pub struct StdClock; + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +impl Clock for StdClock { + fn now(&self) -> SystemTime { + SystemTime::now() + } + + fn sleep(&self, duration: Duration) -> impl Future { + std::thread::sleep(duration); + ready(()) + } +} + +/// The system clock and tokio's timer (feature `tokio`, native targets). +#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] +#[derive(Clone, Copy, Debug, Default)] +pub struct TokioClock; + +#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] +impl Clock for TokioClock { + fn now(&self) -> SystemTime { + SystemTime::now() + } + + fn sleep(&self, duration: Duration) -> impl Future { + tokio::time::sleep(duration) + } +} + +/// `Date.now` and `setTimeout` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): browsers, Workers, +/// and Node. +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +#[derive(Clone, Copy, Debug, Default)] +pub struct JsClock; + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +impl Clock for JsClock { + fn now(&self) -> SystemTime { + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "milliseconds since the epoch")] + let ms = js_sys::Date::now() as u64; + UNIX_EPOCH + Duration::from_millis(ms) + } + + async fn sleep(&self, duration: Duration) { + use js_sys::{Function, Promise, Reflect}; + use wasm_bindgen::{JsCast, JsValue}; + + #[expect(clippy::cast_precision_loss, reason = "timeouts are far below 2^53 ms")] + let ms = JsValue::from_f64(duration.as_millis() as f64); + let promise = Promise::new(&mut |resolve, _| { + let global = js_sys::global(); + match Reflect::get(&global, &"setTimeout".into()).and_then(JsCast::dyn_into::) { + Ok(set_timeout) => { + let _ = set_timeout.call2(&global, &resolve, &ms); + } + Err(_) => { + let _ = resolve.call0(&JsValue::NULL); + } + } + }); + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clocks_move_when_told() { + let clock = TestClock::at_ms(5_000); + let other = clock.clone(); + other.advance(Duration::from_millis(250)); + assert_eq!(clock.now_ms(), 5_250, "clones share the time"); + clock.set(from_ms(-1_500)); + assert_eq!(clock.now_ms(), -1_500); + assert_eq!(TestClock::default().now(), UNIX_EPOCH); + } + + #[test] + fn sleeping_moves_a_test_clock() { + let clock = TestClock::at_ms(0); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut sleep = std::pin::pin!(clock.sleep(Duration::from_secs(3))); + assert!(sleep.as_mut().poll(&mut cx).is_ready()); + assert_eq!(clock.now_ms(), 3_000); + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + #[test] + fn the_std_clock_tells_time_and_blocks() { + let before = StdClock.now_ms(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut sleep = std::pin::pin!(StdClock.sleep(Duration::from_millis(5))); + assert!(sleep.as_mut().poll(&mut cx).is_ready()); + assert!(StdClock.now_ms() >= before + 5); + } + + #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] + #[tokio::test] + async fn the_tokio_clock_tells_time_and_waits() { + let before = TokioClock.now_ms(); + assert!(before > 1_700_000_000_000, "after November 2023"); + TokioClock.sleep(Duration::from_millis(5)).await; + assert!(TokioClock.now_ms() >= before + 5); + } +} diff --git a/crates/loko-console/Cargo.toml b/crates/loko-console/Cargo.toml new file mode 100644 index 0000000..a68131e --- /dev/null +++ b/crates/loko-console/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "loko-console" +description = "loko's Console service: lines to standard output and error, captured in tests, or to the JavaScript console" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +loko-macros.workspace = true + +[features] +default = [] +# `JsConsole`: `console.log` and `console.error` (`wasm32-unknown-unknown` with a JavaScript host). +js = ["dep:js-sys", "dep:wasm-bindgen"] + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +js-sys = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/crates/loko-console/README.md b/crates/loko-console/README.md new file mode 100644 index 0000000..995962e --- /dev/null +++ b/crates/loko-console/README.md @@ -0,0 +1,20 @@ +# loko-console + +[loko](https://github.com/polythings/loko)'s `Console` service: lines to standard output and standard error, passed to +code instead of printed directly, so tests read what was printed and code in a browser logs to its console. + +- `Console`: `print_line` and `print_error_line`. +- `StdConsole`: the process's standard output and error. +- `TestConsole`: keeps the lines (`lines`, `error_lines`). Clones share them. +- `JsConsole` (feature `js`, `wasm32-unknown-unknown`): `console.log` and `console.error`. + +```rust +use loko_console::{Console, TestConsole}; + +let console = TestConsole::new(); +console.print_line("done")?; +assert_eq!(console.lines(), ["done"]); +# Ok::<(), std::io::Error>(()) +``` + +Apache License 2.0. diff --git a/crates/loko-console/src/lib.rs b/crates/loko-console/src/lib.rs new file mode 100644 index 0000000..e47cf24 --- /dev/null +++ b/crates/loko-console/src/lib.rs @@ -0,0 +1,137 @@ +//! loko's `Console` service: lines to standard output and standard error, passed to code instead of +//! printed directly, so tests read what was printed and code in a browser logs to its console. +//! +//! - [`Console`]: `print_line` and `print_error_line`. +//! - [`StdConsole`]: the process's standard output and error. +//! - [`TestConsole`]: keeps the lines, for tests. +//! - `JsConsole` (feature `js`): `console.log` and `console.error`, in browsers, Workers, and Node. +//! +//! ``` +//! use loko_console::{Console, TestConsole}; +//! +//! let console = TestConsole::new(); +//! console.print_line("done")?; +//! assert_eq!(console.lines(), ["done"]); +//! # Ok::<(), std::io::Error>(()) +//! ``` + +use std::io::{self, Write}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +/// Lines of text to standard output and standard error. +#[loko_macros::service] +pub trait Console { + /// Writes `line` and a newline to standard output. + fn print_line(&self, line: &str) -> io::Result<()>; + + /// Writes `line` and a newline to standard error. + fn print_error_line(&self, line: &str) -> io::Result<()>; +} + +/// The process's standard output and standard error. +#[derive(Clone, Copy, Debug, Default)] +pub struct StdConsole; + +impl Console for StdConsole { + fn print_line(&self, line: &str) -> io::Result<()> { + writeln!(io::stdout().lock(), "{line}") + } + + fn print_error_line(&self, line: &str) -> io::Result<()> { + writeln!(io::stderr().lock(), "{line}") + } +} + +/// Keeps the lines written, for tests. Clones share the lines. +#[derive(Clone, Debug, Default)] +pub struct TestConsole { + out: Arc>>, + err: Arc>>, +} + +fn lock(lines: &Mutex>) -> MutexGuard<'_, Vec> { + lines.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl TestConsole { + /// No lines yet. + pub fn new() -> Self { + Self::default() + } + + /// The lines written to standard output, in order. + pub fn lines(&self) -> Vec { + lock(&self.out).clone() + } + + /// The lines written to standard error, in order. + pub fn error_lines(&self) -> Vec { + lock(&self.err).clone() + } +} + +impl Console for TestConsole { + fn print_line(&self, line: &str) -> io::Result<()> { + lock(&self.out).push(line.to_string()); + Ok(()) + } + + fn print_error_line(&self, line: &str) -> io::Result<()> { + lock(&self.err).push(line.to_string()); + Ok(()) + } +} + +/// `console.log` and `console.error` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): browsers, +/// Workers, and Node. +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +#[derive(Clone, Copy, Debug, Default)] +pub struct JsConsole; + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +impl JsConsole { + fn call(method: &str, line: &str) -> io::Result<()> { + use js_sys::{Function, Reflect}; + use wasm_bindgen::JsCast; + + let console = Reflect::get(&js_sys::global(), &"console".into()) + .map_err(|_| io::Error::other("the host has no console"))?; + let f: Function = Reflect::get(&console, &method.into()) + .ok() + .and_then(|f| f.dyn_into().ok()) + .ok_or_else(|| io::Error::other(format!("the console has no {method}")))?; + f.call1(&console, &line.into()).map(drop).map_err(|e| io::Error::other(format!("console.{method}: {e:?}"))) + } +} + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +impl Console for JsConsole { + fn print_line(&self, line: &str) -> io::Result<()> { + Self::call("log", line) + } + + fn print_error_line(&self, line: &str) -> io::Result<()> { + Self::call("error", line) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_consoles_keep_lines() { + let console = TestConsole::new(); + let shared = console.clone(); + console.print_line("a").unwrap(); + shared.print_error_line("oops").unwrap(); + console.print_line("b").unwrap(); + assert_eq!(console.lines(), ["a", "b"]); + assert_eq!(console.error_lines(), ["oops"]); + } + + #[test] + fn the_std_console_writes() { + StdConsole.print_line("loko-console test line").unwrap(); + } +} diff --git a/crates/loko-env/Cargo.toml b/crates/loko-env/Cargo.toml new file mode 100644 index 0000000..bd9fbb3 --- /dev/null +++ b/crates/loko-env/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "loko-env" +description = "loko's Env service: environment variables, from the process or from a map" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +loko-macros.workspace = true + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/crates/loko-env/README.md b/crates/loko-env/README.md new file mode 100644 index 0000000..7a5a372 --- /dev/null +++ b/crates/loko-env/README.md @@ -0,0 +1,17 @@ +# loko-env + +[loko](https://github.com/polythings/loko)'s `Env` service: environment variables, passed to code instead of read from +the process, so tests set their own and code in a browser (which has none) reads them from a map. + +- `Env`: `var` and `vars`. +- `SystemEnv`: the process's variables. +- `MapEnv`: variables in a map, for tests and for hosts without a process environment. Clones share the map. + +```rust +use loko_env::{Env, MapEnv}; + +let env = MapEnv::new().with("REGION", "us-east-2"); +assert_eq!(env.var("REGION").as_deref(), Some("us-east-2")); +``` + +Apache License 2.0. diff --git a/crates/loko-env/src/lib.rs b/crates/loko-env/src/lib.rs new file mode 100644 index 0000000..0b5a73a --- /dev/null +++ b/crates/loko-env/src/lib.rs @@ -0,0 +1,117 @@ +//! loko's `Env` service: environment variables, passed to code instead of read from the process, so +//! tests set their own and code in a browser (which has none) gets them from a map. +//! +//! - [`Env`]: `var` and `vars`. +//! - [`SystemEnv`]: the process's variables. +//! - [`MapEnv`]: variables in a map, for tests and for hosts without a process environment. +//! +//! ``` +//! use loko_env::{Env, MapEnv}; +//! +//! let env = MapEnv::new().with("REGION", "us-east-2"); +//! assert_eq!(env.var("REGION").as_deref(), Some("us-east-2")); +//! assert_eq!(env.var("MISSING"), None); +//! ``` + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +/// Environment variables. +#[loko_macros::service] +pub trait Env { + /// The variable `name`, or `None` if it isn't set (or isn't valid Unicode). + fn var(&self, name: &str) -> Option; + + /// Every variable, as `(name, value)` pairs. + fn vars(&self) -> Vec<(String, String)>; +} + +/// The process's environment variables (`std::env`). On targets without one +/// (`wasm32-unknown-unknown`) nothing is set. +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemEnv; + +impl Env for SystemEnv { + fn var(&self, name: &str) -> Option { + std::env::var(name).ok() + } + + fn vars(&self) -> Vec<(String, String)> { + std::env::vars().collect() + } +} + +/// Environment variables in a map. Clones share the map. +#[derive(Clone, Debug, Default)] +pub struct MapEnv { + vars: Arc>>, +} + +impl MapEnv { + /// No variables. + pub fn new() -> Self { + Self::default() + } + + /// These variables and `name` set to `value`. + #[must_use] + pub fn with(self, name: impl Into, value: impl Into) -> Self { + self.set(name, value); + self + } + + /// Sets `name` to `value`. + pub fn set(&self, name: impl Into, value: impl Into) { + self.map().insert(name.into(), value.into()); + } + + /// Unsets `name`. + pub fn remove(&self, name: &str) { + self.map().remove(name); + } + + fn map(&self) -> MutexGuard<'_, BTreeMap> { + self.vars.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl, V: Into> FromIterator<(K, V)> for MapEnv { + fn from_iter>(iter: I) -> Self { + let env = Self::new(); + for (k, v) in iter { + env.set(k, v); + } + env + } +} + +impl Env for MapEnv { + fn var(&self, name: &str) -> Option { + self.map().get(name).cloned() + } + + fn vars(&self) -> Vec<(String, String)> { + self.map().iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_envs_set_and_unset() { + let env: MapEnv = [("A", "1"), ("B", "2")].into_iter().collect(); + let shared = env.clone(); + shared.set("C", "3"); + shared.remove("A"); + assert_eq!(env.vars(), [("B".to_string(), "2".to_string()), ("C".to_string(), "3".to_string())]); + } + + #[test] + fn the_system_env_reads_the_process() { + // PATH is set wherever these tests run. + assert!(SystemEnv.var("PATH").is_some()); + assert!(SystemEnv.vars().iter().any(|(k, _)| k == "PATH")); + } +} diff --git a/crates/loko-macros/Cargo.toml b/crates/loko-macros/Cargo.toml new file mode 100644 index 0000000..0579536 --- /dev/null +++ b/crates/loko-macros/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "loko-macros" +description = "loko's macros: #[service] makes a trait a service, #[derive(Services)] wires services into a struct" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true + +[lints] +workspace = true diff --git a/crates/loko-macros/README.md b/crates/loko-macros/README.md new file mode 100644 index 0000000..f734bda --- /dev/null +++ b/crates/loko-macros/README.md @@ -0,0 +1,29 @@ +# loko-macros + +[loko](https://github.com/polythings/loko)'s macros. Use them through the `loko` crate. + +- `#[service]` on a trait `X` adds a trait `HasX` (a type that holds an `X`, with an accessor) and implements `X` for + every `HasX`, forwarding each method, associated type, and constant. Methods take `&self`; the trait can't be + generic. +- `#[derive(Services)]` implements `HasX` for each `#[service(X, …)]` on a struct's fields, so the struct is each of + those services. It names `HasX` next to the path given: `#[service(loko::Clock)]` uses `loko::HasClock`, and a bare + `#[service(Clock)]` needs `HasClock` in scope (`loko::prelude::*` brings in both). + +```rust +use loko::prelude::*; + +#[loko::service] +trait Greeter { + fn greet(&self, name: &str) -> String; +} + +#[derive(Services)] +struct App { + #[service(Clock, Random)] + platform: TestPlatform, + #[service(Greeter)] + greeter: G, +} +``` + +Apache License 2.0. diff --git a/crates/loko-macros/src/lib.rs b/crates/loko-macros/src/lib.rs new file mode 100644 index 0000000..ce52cdf --- /dev/null +++ b/crates/loko-macros/src/lib.rs @@ -0,0 +1,209 @@ +//! loko's macros, used by its service crates and re-exported by `loko`. +//! +//! - `#[service]` on a trait `X` adds a trait `HasX` (a type that holds an `X`) and implements `X` +//! for every `HasX` by forwarding each method to the held service. +//! - `#[derive(Services)]` implements `HasX` for a struct's fields marked `#[service(X, …)]`, so the +//! struct is every service its fields provide. +//! +//! The `HasX` traits follow RIO's "Has" type classes, and choosing a field to forward to with a derive +//! follows ambassador (`PRIOR_ART.md`). + +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::{ + Data, DeriveInput, Error, FnArg, Ident, ItemTrait, Pat, PatIdent, Path, ReturnType, Signature, Token, TraitItem, + TraitItemFn, parse_macro_input, parse_quote, +}; + +/// Makes a trait a loko service: adds `Has` and implements the trait for every type that has +/// one, forwarding each method. Methods must take `&self`; the trait can't be generic. +#[proc_macro_attribute] +pub fn service(args: TokenStream, input: TokenStream) -> TokenStream { + if !args.is_empty() { + return Error::new(Span::call_site(), "#[service] takes no arguments").to_compile_error().into(); + } + let item = parse_macro_input!(input as ItemTrait); + match expand_service(&item) { + Ok(extra) => quote! { #item #extra }.into(), + Err(e) => { + let e = e.to_compile_error(); + quote! { #item #e }.into() + } + } +} + +/// The accessor of service `name`: `Clock` → `clock`, `ObjectStore` → `object_store`. +fn accessor(name: &Ident) -> Ident { + let mut snake = String::new(); + for (i, c) in name.to_string().chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + snake.push('_'); + } + snake.extend(c.to_lowercase()); + } else { + snake.push(c); + } + } + Ident::new(&snake, name.span()) +} + +fn expand_service(item: &ItemTrait) -> syn::Result { + if !item.generics.params.is_empty() { + return Err(Error::new(item.generics.span(), "a loko service can't be generic")); + } + if item.unsafety.is_some() { + return Err(Error::new(item.span(), "a loko service can't be an unsafe trait")); + } + let vis = &item.vis; + let name = &item.ident; + let has = format_ident!("Has{}", name); + let get = accessor(name); + let supertraits = &item.supertraits; + let held = quote! { <::#name as #name> }; + + let mut items = vec![]; + for trait_item in &item.items { + match trait_item { + TraitItem::Type(t) => { + let ident = &t.ident; + let (_, ty_generics, where_clause) = t.generics.split_for_impl(); + items.push(quote! { type #ident #ty_generics = #held::#ident #ty_generics #where_clause; }); + } + TraitItem::Const(c) => { + let (ident, ty) = (&c.ident, &c.ty); + items.push(quote! { const #ident: #ty = #held::#ident; }); + } + TraitItem::Fn(f) => items.push(forward_fn(f, &held, &get)?), + other => return Err(Error::new(other.span(), "loko services can hold types, constants, and methods")), + } + } + + let doc = format!("A type that holds a [`{name}`], and so is one (see `#[loko::service]`)."); + let get_doc = format!("The [`{name}`] this holds."); + let bound = if supertraits.is_empty() { + quote! {} + } else { + quote! { + #supertraits } + }; + Ok(quote! { + #[doc = #doc] + #vis trait #has { + /// The service held. + type #name: #name; + #[doc = #get_doc] + fn #get(&self) -> &Self::#name; + } + + #[automatically_derived] + impl #name for T { + #(#items)* + } + }) +} + +/// A method of the blanket impl: the trait's signature, calling the held service's method. +fn forward_fn(f: &TraitItemFn, held: &TokenStream2, get: &Ident) -> syn::Result { + let mut sig: Signature = f.sig.clone(); + match sig.inputs.first() { + Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none() => {} + _ => return Err(Error::new(sig.span(), "loko service methods take `&self`")), + } + let mut args = vec![]; + for (i, input) in sig.inputs.iter_mut().skip(1).enumerate() { + let FnArg::Typed(typed) = input else { continue }; + let ident = format_ident!("__loko_arg{}", i); + *typed.pat = + Pat::Ident(PatIdent { attrs: vec![], by_ref: None, mutability: None, ident: ident.clone(), subpat: None }); + args.push(ident); + } + let method = &sig.ident; + let call = quote! { #held::#method(self.#get(), #(#args),*) }; + let body = if sig.asyncness.is_some() { + quote! { #call.await } + } else { + call + }; + Ok(match sig.output { + ReturnType::Default => quote! { #[inline] #sig { #body; } }, + ReturnType::Type(..) => quote! { #[inline] #sig { #body } }, + }) +} + +/// Implements `Has` for each `#[service(Service, …)]` on a field, so the struct is each of +/// those services, forwarding to the field. +#[proc_macro_derive(Services, attributes(service))] +pub fn derive_services(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match expand_services(&input) { + Ok(tokens) => tokens.into(), + Err(e) => e.to_compile_error().into(), + } +} + +fn expand_services(input: &DeriveInput) -> syn::Result { + let Data::Struct(data) = &input.data else { + return Err(Error::new(input.span(), "#[derive(Services)] is for structs")); + }; + let name = &input.ident; + let (impl_generics, ty_generics, _) = input.generics.split_for_impl(); + let mut impls = vec![]; + for (i, field) in data.fields.iter().enumerate() { + let index = syn::Index::from(i); + let access = field.ident.as_ref().map_or_else(|| quote! { #index }, |ident| quote! { #ident }); + let ty = &field.ty; + for attr in field.attrs.iter().filter(|a| a.path().is_ident("service")) { + let services = attr.parse_args_with(Punctuated::::parse_terminated)?; + for service in services { + let last = service.segments.last().ok_or_else(|| Error::new(service.span(), "a service path"))?; + let service_name = last.ident.clone(); + let mut has = service.clone(); + if let Some(last) = has.segments.last_mut() { + last.ident = format_ident!("Has{}", service_name); + } + let get = accessor(&service_name); + // A generic field type must be the service it provides. + let mut generics = input.generics.clone(); + generics.make_where_clause().predicates.push(parse_quote! { #ty: #service }); + let where_clause = &generics.where_clause; + impls.push(quote! { + #[automatically_derived] + impl #impl_generics #has for #name #ty_generics #where_clause { + type #service_name = #ty; + #[inline] + fn #get(&self) -> &#ty { + &self.#access + } + } + }); + } + } + } + if impls.is_empty() { + return Err(Error::new(input.span(), "no field has #[service(…)]")); + } + Ok(quote! { #(#impls)* }) +} + +#[cfg(test)] +mod tests { + use syn::parse::Parser; + + use super::*; + + #[test] + fn accessors_are_snake_case() { + assert_eq!(accessor(&format_ident!("Clock")), "clock"); + assert_eq!(accessor(&format_ident!("ObjectStore")), "object_store"); + } + + #[test] + fn services_parse() { + let parser = Punctuated::::parse_terminated; + let paths = parser.parse_str("Clock, loko::Random").unwrap(); + assert_eq!(paths.len(), 2); + } +} diff --git a/crates/loko-object-store/Cargo.toml b/crates/loko-object-store/Cargo.toml new file mode 100644 index 0000000..dcb8bbd --- /dev/null +++ b/crates/loko-object-store/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "loko-object-store" +description = "A loko store over an object_store client: S3, GCS, Azure, local files, memory, and more" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +loko-store.workspace = true +# Without features: applications enable the backends they need (`aws`, `gcp`, `azure`, `http`, `fs`, or the `*-base` +# variants for WebAssembly) on their own `object_store` dependency. +object_store.workspace = true +futures-util.workspace = true +url.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +object_store = { workspace = true, features = ["fs"] } +tempfile = "3" +tokio = { workspace = true, features = ["macros", "rt"] } + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/crates/loko-object-store/README.md b/crates/loko-object-store/README.md new file mode 100644 index 0000000..89935f2 --- /dev/null +++ b/crates/loko-object-store/README.md @@ -0,0 +1,24 @@ +# loko-object-store + +A [loko](https://github.com/polythings/loko) `Store` over an [`object_store`](https://crates.io/crates/object_store) +client: S3, GCS, Azure, local files, memory, and anything else that implements `ObjectStore`. + +```rust +use std::sync::Arc; + +use loko_object_store::ObjectStoreAdapter; +use object_store::memory::InMemory; + +let store = ObjectStoreAdapter::new(Arc::new(InMemory::new()), "memory://"); +// Or from a URL, with the backend enabled on `object_store` (here `aws`): +// let store = ObjectStoreAdapter::from_url("s3://bucket/warehouse", [("aws_region", "us-east-2")])?; +``` + +- loko names objects by absolute URI; the adapter maps them to paths under its root (`s3://bucket/`, or + `file:///` for local files). +- Put-if-absent, the commit primitive, is `PutMode::Create`; another writer's object reads as `false`. +- Listing is by string prefix (`…/metadata/v`), done by listing the prefix's directory. +- The crate enables no `object_store` backends: turn on the ones you need (`aws`, `gcp`, `azure`, `http`, `fs`, or the + `*-base` variants for WebAssembly) on your own `object_store` dependency. + +Apache License 2.0. diff --git a/crates/loko-object-store/src/lib.rs b/crates/loko-object-store/src/lib.rs new file mode 100644 index 0000000..c130bbd --- /dev/null +++ b/crates/loko-object-store/src/lib.rs @@ -0,0 +1,243 @@ +//! A loko [`Store`] over an [`object_store`] client: S3, GCS, Azure, local files, memory, and +//! anything else that implements `ObjectStore`. Applications enable the backends they need on their +//! own `object_store` dependency and hand the client to [`ObjectStoreAdapter::new`], or build one +//! from a URL with [`ObjectStoreAdapter::from_url`]. +//! +//! loko names objects by absolute URI (`s3://bucket/warehouse/t/metadata/v1.metadata.json`); +//! the adapter maps them to paths under its root (`s3://bucket`). +//! +//! ``` +//! use std::sync::Arc; +//! +//! use loko_object_store::ObjectStoreAdapter; +//! use object_store::memory::InMemory; +//! +//! let store = ObjectStoreAdapter::new(Arc::new(InMemory::new()), "memory://"); +//! ``` + +use std::future::Future; +use std::sync::Arc; + +use futures_util::TryStreamExt; +use loko_store::{Error, Listed, Result, Store, Upload}; +use object_store::path::Path; +use object_store::{MultipartUpload, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload}; +use url::Url; + +/// A [`Store`] over an `object_store` client whose objects have URIs under `root`. +#[derive(Clone, Debug)] +pub struct ObjectStoreAdapter { + store: Arc, + root: String, +} + +fn store_error(e: object_store::Error) -> Error { + match e { + object_store::Error::NotFound { path, .. } => Error::NotFound(path), + object_store::Error::NotImplemented { operation, implementer } => { + Error::Unsupported(format!("{implementer} has no {operation}")) + } + other => Error::Store(other.to_string()), + } +} + +impl ObjectStoreAdapter { + /// `store`, whose objects have URIs under `root`: the object at path `a/b` is `/a/b`. The + /// root is the store's scheme and bucket (`s3://bucket`, `gs://bucket`, `memory://`), or + /// `file:///` for a local file system rooted at `/`. + pub fn new(store: Arc, root: impl Into) -> Self { + let mut root = root.into(); + if !root.ends_with('/') { + root.push('/'); + } + Self { store, root } + } + + /// The store for `url`'s scheme and bucket (`s3://bucket/…`), configured with `options` + /// (`object_store`'s keys, such as `aws_region`). Its backend must be enabled on + /// `object_store`. + pub fn from_url(url: &str, options: I) -> Result + where + I: IntoIterator, + K: AsRef, + V: Into, + { + let parsed = Url::parse(url).map_err(|e| Error::Invalid(format!("{url}: {e}")))?; + let root = match parsed.host_str().filter(|h| !h.is_empty()) { + Some(host) => format!("{}://{host}/", parsed.scheme()), + // `file:///data/t`: a file system rooted at `/`. + None => format!("{}:///", parsed.scheme()), + }; + let root_url = Url::parse(&root).map_err(|e| Error::Invalid(format!("{root}: {e}")))?; + let (store, _) = object_store::parse_url_opts(&root_url, options).map_err(store_error)?; + Ok(Self::new(Arc::from(store), root)) + } + + /// The path of the object at `uri`. + fn path(&self, uri: &str) -> Result { + let rest = uri + .strip_prefix(&self.root) + .ok_or_else(|| Error::Invalid(format!("{uri} is outside this store ({})", self.root)))?; + Path::parse(rest).map_err(|e| Error::Invalid(format!("{uri}: {e}"))) + } + + /// The URI of the object at `path`. + fn uri(&self, path: &Path) -> String { + format!("{}{path}", self.root) + } +} + +/// A multipart upload through `object_store`. +#[derive(Debug)] +pub struct ObjectStoreUpload(Box); + +impl Upload for ObjectStoreUpload { + async fn put_part(&mut self, bytes: Vec) -> Result<()> { + self.0.put_part(PutPayload::from(bytes)).await.map_err(store_error) + } + + async fn complete(mut self) -> Result<()> { + self.0.complete().await.map(drop).map_err(store_error) + } + + async fn abort(mut self) { + let _ = self.0.abort().await; + } +} + +impl Store for ObjectStoreAdapter { + type Upload = ObjectStoreUpload; + + async fn get(&self, uri: &str) -> Result>> { + match self.store.get(&self.path(uri)?).await { + Ok(found) => Ok(Some(found.bytes().await.map_err(store_error)?.to_vec())), + Err(object_store::Error::NotFound { .. }) => Ok(None), + Err(e) => Err(store_error(e)), + } + } + + async fn get_range(&self, uri: &str, start: u64, len: u64) -> Result> { + let range = start..start.saturating_add(len); + Ok(self.store.get_range(&self.path(uri)?, range).await.map_err(store_error)?.to_vec()) + } + + async fn exists(&self, uri: &str) -> Result { + match self.store.head(&self.path(uri)?).await { + Ok(_) => Ok(true), + Err(object_store::Error::NotFound { .. }) => Ok(false), + Err(e) => Err(store_error(e)), + } + } + + async fn put(&self, uri: &str, bytes: Vec) -> Result<()> { + self.store.put(&self.path(uri)?, PutPayload::from(bytes)).await.map(drop).map_err(store_error) + } + + async fn put_if_absent(&self, uri: &str, bytes: Vec) -> Result { + let options = PutOptions { mode: PutMode::Create, ..PutOptions::default() }; + match self.store.put_opts(&self.path(uri)?, PutPayload::from(bytes), options).await { + Ok(_) => Ok(true), + Err(object_store::Error::AlreadyExists { .. } | object_store::Error::Precondition { .. }) => Ok(false), + Err(e) => Err(store_error(e)), + } + } + + async fn upload(&self, uri: &str) -> Result { + Ok(ObjectStoreUpload(self.store.put_multipart(&self.path(uri)?).await.map_err(store_error)?)) + } + + fn list(&self, prefix: &str) -> impl Future>> { + // `object_store` lists by directory; loko by string prefix (`…/metadata/v`). List the + // directory the prefix is in and keep what starts with it. + let relative = prefix.strip_prefix(&self.root).map(|rest| rest.rsplit_once('/').map_or("", |(dir, _)| dir)); + async move { + let relative = + relative.ok_or_else(|| Error::Invalid(format!("{prefix} is outside this store ({})", self.root)))?; + let dir = Path::parse(relative).map_err(|e| Error::Invalid(format!("{prefix}: {e}")))?; + let dir = (!relative.is_empty()).then_some(&dir); + let objects: Vec<_> = self.store.list(dir).try_collect().await.map_err(store_error)?; + Ok(objects + .into_iter() + .map(|o| Listed { uri: self.uri(&o.location), size: o.size }) + .filter(|o| o.uri.starts_with(prefix)) + .collect()) + } + } + + async fn delete(&self, uris: &[String]) -> Result<()> { + let deletes = uris.iter().map(|uri| async move { + match self.store.delete(&self.path(uri)?).await { + Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()), + Err(e) => Err(store_error(e)), + } + }); + futures_util::future::try_join_all(deletes).await.map(drop) + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use object_store::memory::InMemory; + + use super::*; + + fn adapter() -> ObjectStoreAdapter { + ObjectStoreAdapter::new(Arc::new(InMemory::new()), "memory://") + } + + #[tokio::test] + async fn objects_round_trip() { + let store = adapter(); + assert_eq!(store.get("memory://t/a").await.unwrap(), None); + assert!(!store.exists("memory://t/a").await.unwrap()); + store.put("memory://t/a", b"hello".to_vec()).await.unwrap(); + assert_eq!(store.get("memory://t/a").await.unwrap().as_deref(), Some(&b"hello"[..])); + assert_eq!(store.get_range("memory://t/a", 1, 3).await.unwrap(), b"ell"); + assert!(store.put_if_absent("memory://t/b", b"1".to_vec()).await.unwrap()); + assert!(!store.put_if_absent("memory://t/b", b"2".to_vec()).await.unwrap(), "the first write wins"); + assert_eq!(store.get("memory://t/b").await.unwrap().as_deref(), Some(&b"1"[..])); + + let mut upload = store.upload("memory://t/c").await.unwrap(); + upload.put_part(b"ab".to_vec()).await.unwrap(); + upload.put_part(b"cd".to_vec()).await.unwrap(); + upload.complete().await.unwrap(); + assert_eq!(store.get("memory://t/c").await.unwrap().as_deref(), Some(&b"abcd"[..])); + + store.delete(&["memory://t/a".into(), "memory://t/missing".into()]).await.unwrap(); + assert!(!store.exists("memory://t/a").await.unwrap()); + assert!(matches!(store.get("s3://elsewhere/x").await, Err(Error::Invalid(_)))); + } + + #[tokio::test] + async fn lists_by_string_prefix() { + let store = adapter(); + for name in ["v1.metadata.json", "v2.metadata.json", "version-hint.text", "snap-1.avro"] { + store.put(&format!("memory://t/metadata/{name}"), vec![0; 3]).await.unwrap(); + } + store.put("memory://t/data/v9.parquet", vec![]).await.unwrap(); + let mut listed: Vec = + store.list("memory://t/metadata/v").await.unwrap().into_iter().map(|o| o.uri).collect(); + listed.sort(); + assert_eq!( + listed, + [ + "memory://t/metadata/v1.metadata.json", + "memory://t/metadata/v2.metadata.json", + "memory://t/metadata/version-hint.text" + ] + ); + assert_eq!(store.list("memory://t/metadata/s").await.unwrap()[0].size, 3); + assert_eq!(store.list("memory://t").await.unwrap().len(), 5, "a prefix right under the root"); + } + + #[tokio::test] + async fn local_files_have_file_uris() { + let dir = tempfile::tempdir().unwrap(); + let store = ObjectStoreAdapter::from_url("file:///", std::iter::empty::<(&str, String)>()).unwrap(); + let uri = format!("file://{}/t/a.json", dir.path().display()); + store.put(&uri, b"{}".to_vec()).await.unwrap(); + assert!(dir.path().join("t/a.json").exists()); + let listed = store.list(&format!("file://{}/t/", dir.path().display())).await.unwrap(); + assert_eq!(listed[0].uri, uri); + } +} diff --git a/crates/loko-random/Cargo.toml b/crates/loko-random/Cargo.toml new file mode 100644 index 0000000..dbbf1d8 --- /dev/null +++ b/crates/loko-random/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "loko-random" +description = "loko's Random service: random numbers, with seeded, operating-system, and JavaScript sources" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[features] +default = [] +# `OsRandom`: the operating system's random number generator (native targets and WASI). +getrandom = ["dep:getrandom"] +# `JsRandom`: `crypto.getRandomValues` (`wasm32-unknown-unknown` with a JavaScript host). +js = ["dep:js-sys", "dep:wasm-bindgen"] +# `Random` for a `Mutex` around any `rand_core` generator. +rand = ["dep:rand_core"] + +[dependencies] +loko-macros.workspace = true +rand_core = { workspace = true, optional = true } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +getrandom = { workspace = true, optional = true } + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +js-sys = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/crates/loko-random/README.md b/crates/loko-random/README.md new file mode 100644 index 0000000..5975c64 --- /dev/null +++ b/crates/loko-random/README.md @@ -0,0 +1,19 @@ +# loko-random + +[loko](https://github.com/polythings/loko)'s `Random` service: random numbers, passed to code instead of drawn from a +global source, so tests are repeatable and the same code runs natively, under WASI, and in browsers. + +- `Random`: `next_u64` and `fill`. +- `SeededRandom`: a repeatable sequence from a seed (SplitMix64). Not for secrets. Clones share the sequence. +- `OsRandom` (feature `getrandom`): the operating system's generator (native and WASI). +- `JsRandom` (feature `js`, `wasm32-unknown-unknown`): `crypto.getRandomValues`. +- Feature `rand`: any `rand_core` generator in a `Mutex` is a `Random`. + +```rust +use loko_random::{Random, SeededRandom}; + +let (a, b) = (SeededRandom::new(7), SeededRandom::new(7)); +assert_eq!(a.next_u64(), b.next_u64()); +``` + +Apache License 2.0. diff --git a/crates/loko-random/src/lib.rs b/crates/loko-random/src/lib.rs new file mode 100644 index 0000000..d192222 --- /dev/null +++ b/crates/loko-random/src/lib.rs @@ -0,0 +1,176 @@ +//! loko's `Random` service: random numbers, passed to code instead of drawn from a global source, so +//! tests are repeatable and the same code runs natively and in WebAssembly. +//! +//! - [`Random`]: `next_u64` and `fill`. +//! - [`SeededRandom`]: a repeatable sequence from a seed, for tests. +//! - `OsRandom` (feature `getrandom`): the operating system's generator, on native targets and WASI. +//! - `JsRandom` (feature `js`): `crypto.getRandomValues`, in browsers, Workers, and Node. +//! - Feature `rand`: any `rand_core` generator in a `Mutex` is a `Random`. +//! +//! ``` +//! use loko_random::{Random, SeededRandom}; +//! +//! let (a, b) = (SeededRandom::new(7), SeededRandom::new(7)); +//! assert_eq!(a.next_u64(), b.next_u64()); +//! ``` + +use std::sync::{Arc, Mutex, PoisonError}; + +/// Random numbers. +#[loko_macros::service] +pub trait Random { + /// A random number. + fn next_u64(&self) -> u64; + + /// Fills `dest` with random bytes. + fn fill(&self, dest: &mut [u8]) { + for chunk in dest.chunks_mut(8) { + let bytes = self.next_u64().to_le_bytes(); + chunk.copy_from_slice(&bytes[..chunk.len()]); + } + } +} + +/// A repeatable sequence of numbers from a seed (SplitMix64): the same seed gives the same numbers. +/// Not for secrets. Clones share the sequence. +#[derive(Clone, Debug)] +pub struct SeededRandom { + state: Arc>, +} + +impl SeededRandom { + /// The sequence of `seed`. + pub fn new(seed: u64) -> Self { + Self { state: Arc::new(Mutex::new(seed)) } + } +} + +impl Default for SeededRandom { + /// The sequence of seed 0. + fn default() -> Self { + Self::new(0) + } +} + +impl Random for SeededRandom { + fn next_u64(&self) -> u64 { + // SplitMix64 (Steele, Lea, and Flood, 2014): a Weyl sequence with a mixing function. + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } +} + +/// The operating system's random number generator (feature `getrandom`, native targets and WASI). +#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] +#[derive(Clone, Copy, Debug, Default)] +pub struct OsRandom; + +#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] +impl Random for OsRandom { + fn next_u64(&self) -> u64 { + // The operating system's generator only fails when it isn't available at all. + getrandom::u64().expect("the operating system's random number generator") + } + + fn fill(&self, dest: &mut [u8]) { + getrandom::fill(dest).expect("the operating system's random number generator"); + } +} + +/// `crypto.getRandomValues` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): browsers, Workers, and +/// Node. Hosts without it fall back to `Math.random`. +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +#[derive(Clone, Copy, Debug, Default)] +pub struct JsRandom; + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +impl Random for JsRandom { + fn next_u64(&self) -> u64 { + let mut bytes = [0u8; 8]; + self.fill(&mut bytes); + u64::from_le_bytes(bytes) + } + + fn fill(&self, dest: &mut [u8]) { + use js_sys::{Function, Reflect, Uint8Array}; + use wasm_bindgen::JsCast; + + let crypto = Reflect::get(&js_sys::global(), &"crypto".into()).ok().filter(|c| !c.is_undefined()); + let get_random_values = crypto + .as_ref() + .and_then(|c| Reflect::get(c, &"getRandomValues".into()).ok().and_then(|f| f.dyn_into::().ok())); + // getRandomValues fills at most 65,536 bytes per call. + for chunk in dest.chunks_mut(65_536) { + let filled = match (&crypto, &get_random_values) { + (Some(crypto), Some(f)) => { + let array = Uint8Array::new_with_length(u32::try_from(chunk.len()).unwrap_or(u32::MAX)); + f.call1(crypto, &array).is_ok() && { + array.copy_to(chunk); + true + } + } + _ => false, + }; + if !filled { + for b in chunk.iter_mut() { + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a byte")] + let byte = (js_sys::Math::random() * 256.0) as u8; + *b = byte; + } + } + } + } +} + +/// Any `rand_core` generator, behind a mutex so it can be shared (feature `rand`). +#[cfg(feature = "rand")] +impl Random for Mutex { + fn next_u64(&self) -> u64 { + self.lock().unwrap_or_else(PoisonError::into_inner).next_u64() + } + + fn fill(&self, dest: &mut [u8]) { + self.lock().unwrap_or_else(PoisonError::into_inner).fill_bytes(dest); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seeded_sequences_repeat() { + let a = SeededRandom::new(42); + let b = SeededRandom::new(42); + let first: Vec = (0..5).map(|_| a.next_u64()).collect(); + let second: Vec = (0..5).map(|_| b.next_u64()).collect(); + assert_eq!(first, second); + assert_ne!(first[0], first[1]); + assert_ne!(SeededRandom::new(1).next_u64(), SeededRandom::new(2).next_u64()); + // SplitMix64's first output for seed 0, as published. + assert_eq!(SeededRandom::new(0).next_u64(), 0xE220_A839_7B1D_CDAF); + } + + #[test] + fn fill_covers_odd_lengths() { + let random = SeededRandom::new(3); + let mut bytes = [0u8; 13]; + random.fill(&mut bytes); + assert!(bytes.iter().any(|&b| b != 0)); + let clone = random.clone(); + assert_ne!(random.next_u64(), clone.next_u64(), "clones share one sequence"); + } + + #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] + #[test] + fn os_random_numbers_differ() { + assert_ne!(OsRandom.next_u64(), OsRandom.next_u64()); + let mut bytes = [0u8; 32]; + OsRandom.fill(&mut bytes); + assert!(bytes.iter().any(|&b| b != 0)); + } +} diff --git a/crates/loko-store/Cargo.toml b/crates/loko-store/Cargo.toml new file mode 100644 index 0000000..f210a2b --- /dev/null +++ b/crates/loko-store/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "loko-store" +description = "loko's Store service: objects by URI, with conditional writes, multipart uploads, and prefix listing; a store in memory" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +loko-macros.workspace = true +thiserror.workspace = true + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/crates/loko-store/README.md b/crates/loko-store/README.md new file mode 100644 index 0000000..e928a6f --- /dev/null +++ b/crates/loko-store/README.md @@ -0,0 +1,15 @@ +# loko-store + +[loko](https://github.com/polythings/loko)'s `Store` service: objects by absolute URI, passed to code instead of +reached through a particular client, so the same code works over S3, a local directory, memory, or a JavaScript +binding. + +- `Store`: `get`, `get_range`, `exists`, `put`, `put_if_absent` (a conditional write: `false` when an object is + there), `upload` (multipart: parts in order, then complete or abort), `list` (by string prefix), and `delete` + (missing objects are not an error). The futures aren't required to be `Send`, so a store can wrap JavaScript + promises. +- `MemoryStore`: objects in memory, for tests. Clones share the objects. +- `Error`: `NotFound`, `Invalid`, `Unsupported`, and `Store` (the backend's error, as text). +- [`loko-object-store`](https://crates.io/crates/loko-object-store) implements `Store` over an `object_store` client. + +Apache License 2.0. diff --git a/crates/loko-store/src/lib.rs b/crates/loko-store/src/lib.rs new file mode 100644 index 0000000..d602a7e --- /dev/null +++ b/crates/loko-store/src/lib.rs @@ -0,0 +1,51 @@ +//! loko's `Store` service: objects by URI, passed to code instead of reached through a particular +//! client, so the same code works over S3, a local directory, memory, or a JavaScript binding. +//! +//! - [`Store`]: get, ranged get, exists, put, put-if-absent (a conditional write), multipart upload, +//! list by prefix, delete. +//! - [`MemoryStore`]: objects in memory, for tests. +//! - `loko-object-store`: a `Store` over an `object_store` client (S3, GCS, Azure, local files). +//! +//! ``` +//! use loko_store::{MemoryStore, Store}; +//! +//! # block_on(async { +//! let store = MemoryStore::new(); +//! assert!(store.put_if_absent("memory://t/a", b"1".to_vec()).await?); +//! assert!(!store.put_if_absent("memory://t/a", b"2".to_vec()).await?, "the first write wins"); +//! # Ok::<(), loko_store::Error>(()) +//! # }).unwrap(); +//! # // MemoryStore's futures are ready at once, so one poll runs them. +//! # fn block_on(f: F) -> F::Output { +//! # let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); +//! # let mut f = std::pin::pin!(f); +//! # loop { if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { return v; } } +//! # } +//! ``` + +mod memory; +mod store; + +pub use memory::{MemoryStore, MemoryUpload}; +pub use store::{HasStore, Listed, Store, Upload}; + +/// An error of a store. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The object isn't there. + #[error("not found: {0}")] + NotFound(String), + /// The request doesn't fit the store (a URI outside it, a bad path). + #[error("invalid: {0}")] + Invalid(String), + /// The store can't do this (a backend without conditional writes). + #[error("unsupported: {0}")] + Unsupported(String), + /// The store failed. + #[error("store: {0}")] + Store(String), +} + +/// `Result` with [`Error`]. +pub type Result = std::result::Result; diff --git a/crates/loko-store/src/memory.rs b/crates/loko-store/src/memory.rs new file mode 100644 index 0000000..0913a74 --- /dev/null +++ b/crates/loko-store/src/memory.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeMap; +use std::future::{Future, ready}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use crate::{Error, Listed, Result, Store, Upload}; + +/// A [`Store`] in memory, for tests and examples. Clones share the same objects. +#[derive(Clone, Debug, Default)] +pub struct MemoryStore { + objects: Arc>>>, +} + +impl MemoryStore { + /// An empty store. + pub fn new() -> Self { + Self::default() + } + + /// The URIs stored, sorted. + pub fn uris(&self) -> Vec { + self.objects().keys().cloned().collect() + } + + fn objects(&self) -> MutexGuard<'_, BTreeMap>> { + lock(&self.objects) + } +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// An upload to a [`MemoryStore`]. +#[derive(Debug)] +pub struct MemoryUpload { + store: MemoryStore, + uri: String, + bytes: Vec, +} + +impl Upload for MemoryUpload { + fn put_part(&mut self, bytes: Vec) -> impl Future> { + self.bytes.extend(bytes); + ready(Ok(())) + } + + fn complete(self) -> impl Future> { + self.store.objects().insert(self.uri, self.bytes); + ready(Ok(())) + } + + fn abort(self) -> impl Future { + ready(()) + } +} + +impl Store for MemoryStore { + type Upload = MemoryUpload; + + fn get(&self, uri: &str) -> impl Future>>> { + ready(Ok(self.objects().get(uri).cloned())) + } + + fn get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>> { + let objects = self.objects(); + ready(objects.get(uri).ok_or_else(|| Error::NotFound(uri.to_string())).map(|bytes| { + let start = usize::try_from(start).unwrap_or(usize::MAX).min(bytes.len()); + let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(bytes.len()); + bytes[start..end].to_vec() + })) + } + + fn exists(&self, uri: &str) -> impl Future> { + ready(Ok(self.objects().contains_key(uri))) + } + + fn put(&self, uri: &str, bytes: Vec) -> impl Future> { + self.objects().insert(uri.to_string(), bytes); + ready(Ok(())) + } + + fn put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future> { + let mut objects = self.objects(); + let absent = !objects.contains_key(uri); + if absent { + objects.insert(uri.to_string(), bytes); + } + ready(Ok(absent)) + } + + fn upload(&self, uri: &str) -> impl Future> { + ready(Ok(MemoryUpload { store: self.clone(), uri: uri.to_string(), bytes: vec![] })) + } + + fn list(&self, prefix: &str) -> impl Future>> { + let listed = self + .objects() + .range(prefix.to_string()..) + .take_while(|(k, _)| k.starts_with(prefix)) + .map(|(k, v)| Listed { uri: k.clone(), size: v.len() as u64 }) + .collect(); + ready(Ok(listed)) + } + + fn delete(&self, uris: &[String]) -> impl Future> { + let mut objects = self.objects(); + for uri in uris { + objects.remove(uri); + } + ready(Ok(())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Runs a future that is ready at once, as the memory store's are. + fn now(f: F) -> F::Output { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut f = std::pin::pin!(f); + loop { + if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + } + } + + #[test] + fn objects_round_trip() { + let store = MemoryStore::new(); + assert_eq!(now(store.get("memory://t/a")).unwrap(), None); + now(store.put("memory://t/a", b"hello".to_vec())).unwrap(); + assert_eq!(now(store.get_range("memory://t/a", 1, 3)).unwrap(), b"ell"); + assert!(matches!(now(store.get_range("memory://t/b", 0, 1)), Err(Error::NotFound(_)))); + assert!(now(store.put_if_absent("memory://t/b", b"1".to_vec())).unwrap()); + assert!(!now(store.put_if_absent("memory://t/b", b"2".to_vec())).unwrap()); + + let mut upload = now(store.upload("memory://t/c")).unwrap(); + now(upload.put_part(b"ab".to_vec())).unwrap(); + now(upload.put_part(b"cd".to_vec())).unwrap(); + now(upload.complete()).unwrap(); + assert_eq!(now(store.get("memory://t/c")).unwrap().as_deref(), Some(&b"abcd"[..])); + + let listed: Vec = now(store.list("memory://t/")).unwrap().into_iter().map(|o| o.uri).collect(); + assert_eq!(listed, ["memory://t/a", "memory://t/b", "memory://t/c"]); + now(store.delete(&["memory://t/a".into(), "memory://t/missing".into()])).unwrap(); + assert_eq!(store.uris(), ["memory://t/b", "memory://t/c"]); + assert!(store.clone().uris().len() == 2, "clones share the objects"); + } +} diff --git a/crates/loko-store/src/store.rs b/crates/loko-store/src/store.rs new file mode 100644 index 0000000..ee10047 --- /dev/null +++ b/crates/loko-store/src/store.rs @@ -0,0 +1,50 @@ +use std::future::Future; + +use crate::Result; + +/// An object found by [`Store::list`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Listed { + /// Absolute URI. + pub uri: String, + /// Size in bytes. + pub size: u64, +} + +/// Where a table's objects live: an object store, a directory, memory, or a JavaScript binding. +/// Paths are absolute URIs, as table metadata records them. +/// +/// The futures aren't required to be `Send`, so a store can wrap JavaScript promises. +#[loko_macros::service] +pub trait Store { + /// A multipart upload in progress. + type Upload: Upload; + + /// The object at `uri`, or `None` if there is none. + fn get(&self, uri: &str) -> impl Future>>>; + /// `len` bytes of the object at `uri` from `start`. + fn get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>>; + /// Whether an object exists at `uri`. + fn exists(&self, uri: &str) -> impl Future>; + /// Writes (or replaces) the object at `uri`. + fn put(&self, uri: &str, bytes: Vec) -> impl Future>; + /// Writes the object at `uri` unless one exists; `false` if one did. The commit primitive. + fn put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future>; + /// Starts a multipart upload to `uri`. + fn upload(&self, uri: &str) -> impl Future>; + /// Objects whose URI starts with `prefix` (a string prefix, not only a directory). + fn list(&self, prefix: &str) -> impl Future>>; + /// Deletes objects; missing ones are not an error. + fn delete(&self, uris: &[String]) -> impl Future>; +} + +/// A multipart upload: parts in order, then [`Upload::complete`]. +pub trait Upload { + /// Sends the next part. Every part but the last must be at least the store's minimum (5 MiB on + /// S3 and R2). + fn put_part(&mut self, bytes: Vec) -> impl Future>; + /// Completes the upload: the object appears. + fn complete(self) -> impl Future>; + /// Drops the parts sent. + fn abort(self) -> impl Future; +} diff --git a/crates/loko/Cargo.toml b/crates/loko/Cargo.toml new file mode 100644 index 0000000..43de14c --- /dev/null +++ b/crates/loko/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "loko" +description = "Services passed to code instead of reached for: Clock, Random, Env, Console, and Store, with test, native, and JavaScript implementations" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[features] +default = ["getrandom"] +# `OsRandom` and `StdPlatform` (native targets and WASI). +getrandom = ["loko-random/getrandom"] +# `TokioClock` and `TokioPlatform` (native targets). +tokio = ["getrandom", "loko-clock/tokio"] +# `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown` with a JavaScript host). +js = ["loko-clock/js", "loko-random/js", "loko-console/js"] +# `Random` for a `Mutex` around any `rand_core` generator. +rand = ["loko-random/rand"] +# `loko::store`: the `Store` service and `MemoryStore`. +store = ["dep:loko-store"] +# `loko::object_store`: a `Store` over an `object_store` client. +object-store = ["store", "dep:loko-object-store"] + +[dependencies] +loko-clock.workspace = true +loko-random.workspace = true +loko-env.workspace = true +loko-console.workspace = true +loko-macros.workspace = true +loko-store = { workspace = true, optional = true } +loko-object-store = { workspace = true, optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/crates/loko/README.md b/crates/loko/README.md new file mode 100644 index 0000000..431314e --- /dev/null +++ b/crates/loko/README.md @@ -0,0 +1,33 @@ +# loko + +Services for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed +to code as traits instead of reached for as globals, with test, system, and JavaScript implementations, and platforms +that bundle one of each. + +```rust +use std::time::Duration; + +use loko::{Clock, Random, TestPlatform}; + +fn id(services: &(impl Clock + Random)) -> String { + format!("{}-{:04x}", services.now_ms(), services.next_u64() & 0xffff) +} + +let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program +platform.clock.advance(Duration::from_secs(1)); +assert_eq!(id(&platform), "1000-cdaf"); +``` + +| Feature | Adds | +|---|---| +| `getrandom` (default) | `OsRandom` and `StdPlatform` (native and WASI) | +| `tokio` | `TokioClock` and `TokioPlatform` (native) | +| `js` | `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown`) | +| `rand` | `Random` for a `Mutex` around a `rand_core` generator | +| `store` | `loko::store`: the `Store` service and `MemoryStore` | +| `object-store` | `loko::object_store`: a `Store` over an `object_store` client | + +`#[loko::service]` makes your own traits services and `#[derive(loko::Services)]` bundles them with loko's. The +[repository](https://github.com/polythings/loko) has the details. + +Apache License 2.0. diff --git a/crates/loko/src/lib.rs b/crates/loko/src/lib.rs new file mode 100644 index 0000000..eb52732 --- /dev/null +++ b/crates/loko/src/lib.rs @@ -0,0 +1,89 @@ +//! Services passed to code instead of reached for: the time, randomness, environment variables, the +//! console, and object storage. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the +//! program passes a platform, and tests pass a [`TestPlatform`] they control. The same code runs +//! natively and in WebAssembly. +//! +//! - Services: [`clock`], [`random`], [`env`], [`console`], and `store` (feature `store`), each a +//! trait with test, native, and JavaScript implementations. The traits are also at the root. +//! - Platforms: [`TestPlatform`], `StdPlatform`, `TokioPlatform`, and `JsPlatform` bundle one of each. +//! - [`service`] makes a trait a service, and [`Services`] makes a struct each service its fields +//! provide, so new services and new bundles work like loko's own. [`prelude`] brings in the +//! services with the `HasClock`-style traits the derive names. +//! +//! ``` +//! use std::time::Duration; +//! +//! use loko::{Clock, Random, TestPlatform}; +//! +//! /// An ID from the time and a random suffix. +//! fn id(services: &(impl Clock + Random)) -> String { +//! format!("{}-{:04x}", services.now_ms(), services.next_u64() & 0xffff) +//! } +//! +//! let platform = TestPlatform::default(); +//! platform.clock.advance(Duration::from_secs(1)); +//! assert_eq!(id(&platform), "1000-cdaf"); +//! ``` +//! +//! A program bundles the services it needs, its own among them: +//! +//! ``` +//! # #[cfg(feature = "store")] { +//! use loko::prelude::*; +//! use loko::store::MemoryStore; +//! +//! #[derive(Services)] +//! struct App { +//! #[service(Store)] +//! store: MemoryStore, +//! #[service(Clock, Random)] +//! platform: TestPlatform, +//! } +//! +//! async fn save(app: &(impl Store + Clock)) -> loko::store::Result<()> { +//! app.put(&format!("memory:///logs/{}", app.now_ms()), b"started".to_vec()).await +//! } +//! # } +//! ``` + +pub use loko_clock as clock; +pub use loko_clock::{Clock, HasClock}; +pub use loko_console as console; +pub use loko_console::{Console, HasConsole}; +pub use loko_env as env; +pub use loko_env::{Env, HasEnv}; +pub use loko_macros::{Services, service}; +#[cfg(feature = "object-store")] +pub use loko_object_store as object_store; +pub use loko_random as random; +pub use loko_random::{HasRandom, Random}; +#[cfg(feature = "store")] +pub use loko_store as store; +#[cfg(feature = "store")] +pub use loko_store::{HasStore, Store}; + +mod platform; + +/// The services, the `Has` traits that `#[derive(Services)]` names next to them, the macros, and +/// the platforms. +pub mod prelude { + #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] + pub use crate::JsPlatform; + #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] + pub use crate::StdPlatform; + #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] + pub use crate::TokioPlatform; + pub use crate::{ + Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Random, Services, TestPlatform, service, + }; + #[cfg(feature = "store")] + pub use crate::{HasStore, Store}; +} + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +pub use platform::JsPlatform; +#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] +pub use platform::StdPlatform; +pub use platform::TestPlatform; +#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] +pub use platform::TokioPlatform; diff --git a/crates/loko/src/platform.rs b/crates/loko/src/platform.rs new file mode 100644 index 0000000..68e6aa6 --- /dev/null +++ b/crates/loko/src/platform.rs @@ -0,0 +1,146 @@ +//! Platforms: one of each service, ready to pass. A platform is each service it holds; a program +//! that needs more (a `Store`, its own services) puts a platform in a `#[derive(Services)]` struct. + +use crate::clock::{Clock, TestClock}; +use crate::console::{Console, TestConsole}; +use crate::env::{Env, MapEnv}; +use crate::random::{Random, SeededRandom}; +use crate::{HasClock, HasConsole, HasEnv, HasRandom, Services}; + +/// Services for tests: a [`TestClock`] at the Unix epoch, a [`SeededRandom`] with seed 0, an empty +/// [`MapEnv`], and a [`TestConsole`] that records lines. Clones share the clock, the generator, and +/// the console; the fields are public so a test can set them up and look at them. +#[derive(Clone, Debug, Default, Services)] +#[non_exhaustive] +pub struct TestPlatform { + /// The time, moved by the test. + #[service(Clock)] + pub clock: TestClock, + /// Random numbers, the same on every run. + #[service(Random)] + pub random: SeededRandom, + /// Environment variables set by the test. + #[service(Env)] + pub env: MapEnv, + /// The lines printed. + #[service(Console)] + pub console: TestConsole, +} + +impl TestPlatform { + /// The default test platform. + pub fn new() -> Self { + Self::default() + } + + /// The clock set to `ms` milliseconds after the Unix epoch. + #[must_use] + pub fn at_ms(mut self, ms: i64) -> Self { + self.clock = TestClock::at_ms(ms); + self + } + + /// The generator seeded with `seed`. + #[must_use] + pub fn seeded(mut self, seed: u64) -> Self { + self.random = SeededRandom::new(seed); + self + } + + /// Environment variable `name` set to `value`. + #[must_use] + pub fn with_var(mut self, name: impl Into, value: impl Into) -> Self { + self.env = self.env.with(name, value); + self + } +} + +/// The system's services for programs without an async runtime (feature `getrandom`, native +/// targets and WASI): [`StdClock`](crate::clock::StdClock), whose `sleep` blocks the thread, +/// [`OsRandom`](crate::random::OsRandom), [`SystemEnv`](crate::env::SystemEnv), and +/// [`StdConsole`](crate::console::StdConsole). +#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] +#[derive(Clone, Copy, Debug, Default, Services)] +#[non_exhaustive] +pub struct StdPlatform { + /// The system clock. + #[service(Clock)] + pub clock: crate::clock::StdClock, + /// The operating system's generator. + #[service(Random)] + pub random: crate::random::OsRandom, + /// The process's environment variables. + #[service(Env)] + pub env: crate::env::SystemEnv, + /// Standard output and standard error. + #[service(Console)] + pub console: crate::console::StdConsole, +} + +/// The system's services under tokio (feature `tokio`, native targets): [`StdPlatform`] with +/// [`TokioClock`](crate::clock::TokioClock), whose `sleep` is tokio's timer. +#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] +#[derive(Clone, Copy, Debug, Default, Services)] +#[non_exhaustive] +pub struct TokioPlatform { + /// The system clock and tokio's timer. + #[service(Clock)] + pub clock: crate::clock::TokioClock, + /// The operating system's generator. + #[service(Random)] + pub random: crate::random::OsRandom, + /// The process's environment variables. + #[service(Env)] + pub env: crate::env::SystemEnv, + /// Standard output and standard error. + #[service(Console)] + pub console: crate::console::StdConsole, +} + +/// The JavaScript host's services (feature `js`, `wasm32-unknown-unknown`): [`JsClock`](crate::clock::JsClock), +/// [`JsRandom`](crate::random::JsRandom), a [`MapEnv`] (JavaScript hosts have no common environment; +/// set the variables the program reads), and [`JsConsole`](crate::console::JsConsole). +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +#[derive(Clone, Debug, Default, Services)] +#[non_exhaustive] +pub struct JsPlatform { + /// `Date.now` and `setTimeout`. + #[service(Clock)] + pub clock: crate::clock::JsClock, + /// `crypto.getRandomValues`. + #[service(Random)] + pub random: crate::random::JsRandom, + /// Variables set by the program. + #[service(Env)] + pub env: MapEnv, + /// `console.log` and `console.error`. + #[service(Console)] + pub console: crate::console::JsConsole, +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn a_test_platform_is_each_service() { + let platform = TestPlatform::new().at_ms(1_000).seeded(0).with_var("REGION", "us-east-2"); + platform.clock.advance(Duration::from_millis(500)); + assert_eq!(platform.now_ms(), 1_500); + assert_eq!(platform.next_u64(), 0xE220_A839_7B1D_CDAF); + assert_eq!(platform.var("REGION").as_deref(), Some("us-east-2")); + platform.print_line("hello").unwrap(); + assert_eq!(platform.console.lines(), ["hello"]); + } + + #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] + #[test] + fn the_std_platform_reads_the_system() { + let platform = StdPlatform::default(); + assert!(platform.now_ms() > 1_700_000_000_000); + assert_ne!(platform.next_u64(), platform.next_u64()); + assert_eq!(platform.var("LOKO_SURELY_UNSET"), None); + } +} diff --git a/crates/loko/tests/services.rs b/crates/loko/tests/services.rs new file mode 100644 index 0000000..7aac391 --- /dev/null +++ b/crates/loko/tests/services.rs @@ -0,0 +1,103 @@ +//! Services and bundles defined outside loko work like loko's own. + +use std::time::Duration; + +use loko::clock::TestClock; +use loko::prelude::*; + +/// A service of this crate's own. +#[loko::service] +trait Greeter { + /// A greeting for `name`. + fn greet(&self, name: &str) -> String; +} + +/// A greeter that needs other services: the clock for the time of day. +struct ClockGreeter { + clock: C, +} + +impl Greeter for ClockGreeter { + fn greet(&self, name: &str) -> String { + let hour = self.clock.now_ms() / 3_600_000 % 24; + if hour < 12 { format!("Good morning, {name}") } else { format!("Good afternoon, {name}") } + } +} + +/// A bundle of a platform and the greeter, generic over the greeter. +#[derive(Services)] +struct App { + #[service(Clock, Random, Env, Console)] + platform: TestPlatform, + #[service(Greeter)] + greeter: G, +} + +/// Code that needs a mix of loko's services and this crate's. +fn welcome(app: &(impl Greeter + Console + Env)) { + let name = app.var("USER").unwrap_or_else(|| "stranger".into()); + app.print_line(&app.greet(&name)).expect("a test console doesn't fail"); +} + +#[test] +fn own_services_bundle_with_loko_services() { + let platform = TestPlatform::new().with_var("USER", "Ada"); + let greeter = ClockGreeter { clock: platform.clock.clone() }; + let app = App { platform: platform.clone(), greeter }; + welcome(&app); + platform.clock.advance(Duration::from_hours(13)); + welcome(&app); + assert_eq!(platform.console.lines(), ["Good morning, Ada", "Good afternoon, Ada"]); + assert_eq!(app.next_u64(), 0xE220_A839_7B1D_CDAF, "Random forwards to the platform"); +} + +/// Tuple structs bundle too. +#[derive(Services)] +struct Pair(#[service(Clock)] TestPlatform, #[service(Greeter)] ClockGreeter); + +#[test] +fn tuple_bundles_forward() { + let platform = TestPlatform::new().at_ms(15 * 3_600_000); + let pair = Pair(platform.clone(), ClockGreeter { clock: platform.clock.clone() }); + assert_eq!(pair.now_ms(), 15 * 3_600_000); + assert_eq!(pair.greet("Lin"), "Good afternoon, Lin"); +} + +#[cfg(feature = "store")] +mod store { + use loko::store::{MemoryStore, Upload}; + + use super::*; + + #[derive(Clone, Services)] + struct Stored { + #[service(Store)] + store: MemoryStore, + #[service(Clock)] + platform: TestPlatform, + } + + /// Async methods, an associated type, and default methods forward. + #[test] + fn async_services_forward() { + let app = Stored { store: MemoryStore::new(), platform: TestPlatform::new().at_ms(42) }; + let run = async { + let uri = format!("memory:///logs/{}", app.now_ms()); + assert!(app.put_if_absent(&uri, b"a".to_vec()).await.unwrap()); + assert!(!app.put_if_absent(&uri, b"b".to_vec()).await.unwrap()); + let mut upload = app.upload("memory:///big").await.unwrap(); + upload.put_part(b"x".to_vec()).await.unwrap(); + upload.complete().await.unwrap(); + app.sleep(Duration::from_secs(1)).await; + let listed: Vec<_> = app.list("memory:///").await.unwrap().into_iter().map(|l| l.uri).collect(); + (listed, app.get(&uri).await.unwrap()) + }; + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let std::task::Poll::Ready((listed, got)) = std::pin::pin!(run).poll(&mut cx) else { + panic!("memory and test services are ready at once"); + }; + assert_eq!(listed, ["memory:///big", "memory:///logs/42"]); + assert_eq!(got.as_deref(), Some(&b"a"[..])); + assert_eq!(app.platform.now_ms(), 1_042); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7533fe2 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f0ce655 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +style_edition = "2024" +max_width = 120 +use_small_heuristics = "Max" diff --git a/spec/0001-services.md b/spec/0001-services.md new file mode 100644 index 0000000..66649a3 --- /dev/null +++ b/spec/0001-services.md @@ -0,0 +1,64 @@ +--- +number: 1 +title: Services and the derive +status: done +updated: 2026-10-20 +versions: [unreleased] +related: [2, 3, 4, 5, 6, 7] +issues: [1, 2, 4] +--- + +# 1. Services and the derive + +**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +A service is a trait for something code would otherwise reach for as a global: the time, random numbers, +environment variables, the console, object storage. Code takes the services it needs as `&impl Trait`, the program +passes the system's, and tests pass ones they control. `#[service]` and `#[derive(Services)]` let anyone define new +services and bundle them with loko's. + +## Design + +- **A service** is a trait marked `#[loko_macros::service]` (`#[loko::service]` through the `loko` crate). Its + methods take `&self`; async ones return `impl Future` with no `Send` bound, so an implementation can wrap JavaScript + promises. The trait can't be generic; it can have associated types, constants, and provided methods. +- **`#[service]` on `trait X`** adds: + - `trait HasX { type X: X; fn x(&self) -> &Self::X; }` (the accessor is the trait's name in snake case), and + - `impl X for T`, forwarding every method (provided ones too, so an implementation's overrides are kept), + associated type, and constant to the held service. +- **`#[derive(Services)]`** on a struct implements `HasX` for each `#[service(X, …)]` on a field, with the field's + type as the held service (and `FieldType: X` as a bound, so generic fields work). Named and tuple fields both work. + A field can provide several services, such as a platform providing `Clock, Random`. +- **Paths:** the derive names `HasX` next to the path given: `#[service(loko::Clock)]` uses `loko::HasClock`, and a + bare `#[service(Clock)]` needs `HasClock` in scope. `loko::prelude` brings in each service with its `HasX`. +- **Composition:** an implementation of one service can hold others (a greeter that holds a clock), and a program's + bundle can mix loko's services with its own. Code asks only for what it uses: `&(impl Store + Clock)`. +- **Crates:** one per service (`loko-clock`, `loko-random`, `loko-env`, `loko-console`, `loko-store`), each with its + trait and implementations; `loko-object-store`; `loko-macros`; and `loko`, which re-exports them + (`loko::clock`, …; the traits also at the root), adds the platforms ([spec 7](0007-platforms.md)), and has a feature + per optional part. A library that needs one service depends on its crate alone. +- **Targets:** every crate builds for native targets, `wasm32-unknown-unknown`, and `wasm32-wasip2`. JavaScript + implementations need feature `js` and `wasm32-unknown-unknown`; system ones are on every target with a system + (native and WASI). + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D1 | A crate per service, re-exported by `loko`, so a library takes only the service it needs. | Accepted | +| D2 | Futures aren't `Send`: services can wrap JavaScript promises; multi-threaded executors are an open question (#2). | Accepted | +| D3 | `#[service]` adds `HasX` and a blanket `impl X for T`; the derive implements `HasX` per field attribute. A service implemented by hand and one held in a bundle look the same to callers. | Accepted | +| D4 | The derive names `HasX` next to the path given, and `loko::prelude` brings each service in with its `HasX`, rather than generating macros named after the traits. | Accepted | +| D5 | The crate is `loko`, at 0.2.0: the name's owner already published an unrelated 0.1.0 in 2022. | Accepted | + +## Open questions and issues + +- [#1](https://github.com/polythings/loko/issues/1) Publish 0.2.0 to crates.io +- [#2](https://github.com/polythings/loko/issues/2) Send futures for multi-threaded executors +- [#4](https://github.com/polythings/loko/issues/4) Which services come next? + +## History + +- 2026-10-20: built for 0.2. diff --git a/spec/0002-clock.md b/spec/0002-clock.md new file mode 100644 index 0000000..eb6dfb2 --- /dev/null +++ b/spec/0002-clock.md @@ -0,0 +1,47 @@ +--- +number: 2 +title: Clock +status: done +updated: 2026-10-20 +versions: [unreleased] +related: [1, 7] +issues: [] +--- + +# 2. Clock + +**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +The time and a timer. Code that stamps records, measures, retries, or waits takes a `Clock`, so tests set the time +and skip the waits. + +## Design + +- **`Clock`:** `now() -> SystemTime`, `now_ms() -> i64` (provided: milliseconds since the Unix epoch, negative before + it, saturating), and `sleep(Duration) -> impl Future`. +- **`from_ms(i64) -> SystemTime`:** the inverse of `now_ms`. +- **`TestClock`:** starts where told (`new`, `at_ms`; the Unix epoch by default), moves on `advance` and `set`, and + on `sleep`, which moves it forward by the sleep and returns at once. Clones share the time. +- **`StdClock`:** `SystemTime::now`, and a `sleep` that blocks the thread before returning a ready future: for + programs without an async runtime (native and WASI). +- **`TokioClock`** (feature `tokio`, native): `SystemTime::now` and `tokio::time::sleep`. +- **`JsClock`** (feature `js`, `wasm32-unknown-unknown`): `Date.now` and a promise around `setTimeout` (resolved at + once on a host without `setTimeout`). + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D6 | `now` returns `SystemTime` and `sleep` takes `Duration`, the standard library's types; `now_ms` is provided for the common case. | Accepted | +| D7 | `TestClock`'s `sleep` moves the clock and returns at once, so code that waits runs in tests without waiting. | Accepted | +| D8 | `StdClock` blocks in `sleep`, documented, so programs without a runtime have a system clock; async programs use their runtime's clock. | Accepted | + +## Open questions and issues + +None. + +## History + +- 2026-10-20: built for 0.2. diff --git a/spec/0003-random.md b/spec/0003-random.md new file mode 100644 index 0000000..e91894b --- /dev/null +++ b/spec/0003-random.md @@ -0,0 +1,44 @@ +--- +number: 3 +title: Random +status: done +updated: 2026-10-20 +versions: [unreleased] +related: [1, 7] +issues: [] +--- + +# 3. Random + +**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +Random numbers. Code that makes identifiers, jitter, or samples takes a `Random`, so tests get the same numbers on +every run. + +## Design + +- **`Random`:** `next_u64() -> u64` and `fill(&mut [u8])` (provided, from `next_u64`; implementations backed by a + byte source override it). +- **`SeededRandom`:** SplitMix64 from a seed (0 by default). Not for secrets. Clones share the sequence. +- **`OsRandom`** (feature `getrandom`, native and WASI): the operating system's generator. +- **`JsRandom`** (feature `js`, `wasm32-unknown-unknown`): `crypto.getRandomValues` in chunks of at most 65,536 + bytes, falling back to `Math.random` on a host without Web Crypto. +- **Feature `rand`:** `Mutex` is a `Random` for any `rand_core::Rng`, so a program's existing generator works as a + service. + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D9 | `Random` is two methods on `&self`; generators with `&mut self` state go behind a `Mutex`, which the `rand` feature implements. | Accepted | +| D10 | `SeededRandom` is SplitMix64: small, fast, statistically good enough for tests, and the same on every platform. | Accepted | + +## Open questions and issues + +None. + +## History + +- 2026-10-20: built for 0.2. diff --git a/spec/0004-env.md b/spec/0004-env.md new file mode 100644 index 0000000..51c68fc --- /dev/null +++ b/spec/0004-env.md @@ -0,0 +1,39 @@ +--- +number: 4 +title: Env +status: done +updated: 2026-10-20 +versions: [unreleased] +related: [1, 7] +issues: [3] +--- + +# 4. Env + +**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +Environment variables. Code that reads configuration takes an `Env`, so tests set their own and hosts without a +process environment supply a map. + +## Design + +- **`Env`:** `var(name) -> Option` (`None` when unset or not Unicode) and `vars() -> Vec<(String, String)>`. +- **`SystemEnv`:** `std::env`. On `wasm32-unknown-unknown` nothing is set. +- **`MapEnv`:** variables in a sorted map: `new`, `with` (builder), `set`, `remove`, and `FromIterator`. Clones share + the map. `JsPlatform` uses one ([spec 7](0007-platforms.md)). + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D11 | JavaScript hosts get a `MapEnv` the application fills, since browsers, Node, Deno, and Workers keep variables in different places (#3). | Accepted | + +## Open questions and issues + +- [#3](https://github.com/polythings/loko/issues/3) Environment variables in JavaScript hosts + +## History + +- 2026-10-20: built for 0.2. diff --git a/spec/0005-console.md b/spec/0005-console.md new file mode 100644 index 0000000..46a088f --- /dev/null +++ b/spec/0005-console.md @@ -0,0 +1,40 @@ +--- +number: 5 +title: Console +status: done +updated: 2026-10-20 +versions: [unreleased] +related: [1, 7] +issues: [] +--- + +# 5. Console + +**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +Lines to standard output and standard error. Code that reports progress or results takes a `Console`, so tests +read what it printed and code in a browser logs to the developer console. + +## Design + +- **`Console`:** `print_line(&str) -> io::Result<()>` and `print_error_line(&str) -> io::Result<()>`; each writes the + line and a newline. +- **`StdConsole`:** the process's standard output and error, locked for the line. +- **`TestConsole`:** keeps the lines (`lines`, `error_lines`). Clones share them. +- **`JsConsole`** (feature `js`, `wasm32-unknown-unknown`): `console.log` and `console.error`. + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D12 | Lines rather than a byte stream: what programs print to a console, and what JavaScript consoles take. | Accepted | + +## Open questions and issues + +None. + +## History + +- 2026-10-20: built for 0.2. diff --git a/spec/0006-store.md b/spec/0006-store.md new file mode 100644 index 0000000..77fc58e --- /dev/null +++ b/spec/0006-store.md @@ -0,0 +1,47 @@ +--- +number: 6 +title: Store +status: done +updated: 2026-10-20 +versions: [unreleased] +related: [1] +issues: [] +--- + +# 6. Store + +**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +Objects by absolute URI. Code that reads and writes files in object storage takes a `Store`, so the same code works +over S3, a local directory, memory, or a JavaScript binding. It started as lakekit's `Store`, where table formats +read and write through it. + +## Design + +- **`Store`:** `get` (the object or `None`), `get_range`, `exists`, `put`, `put_if_absent` (a conditional write, + `false` when an object is there: the commit primitive of table formats), `upload` (a multipart `Upload`: parts in + order, then `complete` or `abort`), `list` (by string prefix, not only by directory), and `delete` (missing objects + are not an error). Paths are absolute URIs. +- **`Error`:** `NotFound`, `Invalid`, `Unsupported`, and `Store` (the backend's error, as text); `non_exhaustive`. + Libraries with their own error type convert from it. +- **`MemoryStore`:** objects in memory, for tests. Clones share the objects. +- **`ObjectStoreAdapter`** (`loko-object-store`): a `Store` over any `object_store` client, from a client and a root + URI or from a URL with options. Put-if-absent is `PutMode::Create`. The crate enables no backends; the application + turns on the ones it needs on its own `object_store` dependency. +- **Not in the platforms:** a program bundles a store with a platform ([spec 7](0007-platforms.md)). + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D13 | `Store` and the `object_store` adapter moved from lakekit to loko, so any project can use them; lakekit keeps its own error type, converting from `loko_store::Error`. | Accepted | + +## Open questions and issues + +None. + +## History + +- 2026-10-20: built for 0.2. Moved from lakekit (`lakekit-core`'s `Store` and `MemoryStore`, and `lakekit-object-store`). diff --git a/spec/0007-platforms.md b/spec/0007-platforms.md new file mode 100644 index 0000000..b9a7fe5 --- /dev/null +++ b/spec/0007-platforms.md @@ -0,0 +1,49 @@ +--- +number: 7 +title: Platforms +status: done +updated: 2026-10-20 +versions: [unreleased] +related: [1, 2, 3, 4, 5] +issues: [3] +--- + +# 7. Platforms + +**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, ready to pass: the test services, the +system's, the system's under tokio, or a JavaScript host's. A program that needs more puts a platform in its own +`#[derive(Services)]` bundle. + +## Design + +| Platform | Clock | Random | Env | Console | Targets | +|---|---|---|---|---|---| +| `TestPlatform` | `TestClock` | `SeededRandom` | `MapEnv` | `TestConsole` | All | +| `StdPlatform` | `StdClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native and WASI (feature `getrandom`, on by default) | +| `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native (feature `tokio`) | +| `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | `wasm32-unknown-unknown` (feature `js`) | + +- Each platform is a struct with public fields, one per service, built with `#[derive(Services)]`, so it is each + service and a test can set up and inspect the parts (`platform.clock.advance(…)`, `platform.console.lines()`). +- The structs are `non_exhaustive`, so a service added later isn't a breaking change; they are built with `default()` + (and `TestPlatform`'s `at_ms`, `seeded`, and `with_var`). +- `Store` is not in a platform: which store a program uses is its own choice. + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D14 | Platforms bundle `Clock`, `Random`, `Env`, and `Console`, without `Store`, and are named for where they run (`TestPlatform`, `StdPlatform`, `TokioPlatform`, `JsPlatform`). | Accepted | +| D15 | JavaScript implementations are for `wasm32-unknown-unknown` only; system implementations, and `StdPlatform`, also build for WASI. | Accepted | + +## Open questions and issues + +- [#3](https://github.com/polythings/loko/issues/3) Environment variables in JavaScript hosts + +## History + +- 2026-10-20: built for 0.2. diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..6e119be --- /dev/null +++ b/spec/README.md @@ -0,0 +1,48 @@ +# Specs + +loko's design, laid out like lakekit's, Timelake's, and lakeglass's. Start here, people and agents alike. + +- **Specs** (`NNNN-name.md`): one feature each, with the same template ([TEMPLATE.md](TEMPLATE.md)). A spec says what + the feature does and why, what is still open (as GitHub issues), how far it is built, and the releases that changed + it. +- **Milestones** ([`milestones/`](milestones/README.md)): goals made of specs. The roadmap is the list of milestones. + +## Statuses + +| Status | Meaning | +|---|---| +| Done | Built and in effect; the spec describes how it works now. | +| In progress | Partly built: the spec says which parts are, and the open parts have issues. | +| Planned | Agreed or proposed, not built yet. | +| Dropped | Discussed and not done; kept so the reasons stay on record. | + +## Writing and changing a spec + +- **When:** anything beyond a small fix gets a spec, or a change to one, agreed before it is built. A new service is + a new spec. +- **New spec:** copy [TEMPLATE.md](TEMPLATE.md) to `NNNN-short-name.md` with the next free number (numbers are never + reused), and add it to a milestone and to the index below. +- **Front matter:** `number`, `title`, `status` (`done`, `in-progress`, `planned`, `dropped`), `updated`, `versions` + (the releases that changed it; `unreleased` until a release names it), and optionally `related` and `issues`. +- **Open questions** are GitHub issues (label `question`), listed under `issues`, not paragraphs in the spec. +- **Same commit:** when work lands, its spec's status, versions, and history change with the code and the changelog. + +## Index + +7 specs: 7 done. Decisions D1–D15. + +| # | Spec | Status | Updated | +|---:|---|---|---| +| 1 | [Services and the derive](0001-services.md) | Done | 2026-10-20 | +| 2 | [Clock](0002-clock.md) | Done | 2026-10-20 | +| 3 | [Random](0003-random.md) | Done | 2026-10-20 | +| 4 | [Env](0004-env.md) | Done | 2026-10-20 | +| 5 | [Console](0005-console.md) | Done | 2026-10-20 | +| 6 | [Store](0006-store.md) | Done | 2026-10-20 | +| 7 | [Platforms](0007-platforms.md) | Done | 2026-10-20 | + +## Milestones + +| Milestone | Status | +|---|---| +| [0.2: Services for the polythings projects](milestones/v0-2.md) | In progress | diff --git a/spec/TEMPLATE.md b/spec/TEMPLATE.md new file mode 100644 index 0000000..1b58a04 --- /dev/null +++ b/spec/TEMPLATE.md @@ -0,0 +1,36 @@ +--- +number: 0 +title: Short title +status: planned +updated: YYYY-MM-DD +versions: [] +related: [] +issues: [] +--- + +# 0. Short title + +**Planned** · updated YYYY-MM-DD · milestone: [Name](milestones/slug.md) + +## Summary + +Two to four sentences: what this feature does, for whom, and why. + +## Design + +How it works, as precisely as needed to build or review it: types, options, interfaces, edge cases. Cite other specs +as "spec N", linked to their files. + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D0 | One sentence | Proposed | + +## Open questions and issues + +- [#N](https://github.com/polythings/loko/issues/N) Each open question or task is an issue. + +## History + +- YYYY-MM-DD: proposed. diff --git a/spec/milestones/README.md b/spec/milestones/README.md new file mode 100644 index 0000000..36c8b5f --- /dev/null +++ b/spec/milestones/README.md @@ -0,0 +1,7 @@ +# Milestones + +The roadmap: goals made of specs. A milestone is done, in progress, planned, or exploring (an idea being looked into). + +| Milestone | Status | Specs | +|---|---|---| +| [0.2: Services for the polythings projects](v0-2.md) | In progress | 1, 2, 3, 4, 5, 6, 7 | diff --git a/spec/milestones/v0-2.md b/spec/milestones/v0-2.md new file mode 100644 index 0000000..8886fba --- /dev/null +++ b/spec/milestones/v0-2.md @@ -0,0 +1,20 @@ +# 0.2: Services for the polythings projects + +**In progress** + +The first release as a services library: `Clock`, `Random`, `Env`, `Console`, and `Store` with test, system, and +JavaScript implementations, the platforms, and the macros for new services and bundles. Enough for lakekit to take +its store, clock, and randomness from loko instead of defining them, and for Timelake and lakeglass to follow. + +| # | Spec | Status | +|---:|---|---| +| 1 | [Services and the derive](../0001-services.md) | Done | +| 2 | [Clock](../0002-clock.md) | Done | +| 3 | [Random](../0003-random.md) | Done | +| 4 | [Env](../0004-env.md) | Done | +| 5 | [Console](../0005-console.md) | Done | +| 6 | [Store](../0006-store.md) | Done | +| 7 | [Platforms](../0007-platforms.md) | Done | + +**Done when:** the crates are on crates.io ([#1](https://github.com/polythings/loko/issues/1)) and lakekit depends on +them by version. From 206d49ac36c6403179ccbb47d170a9d57f1b4fd6 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Tue, 20 Oct 2026 23:59:49 -0400 Subject: [PATCH 02/19] Spec: the Store spec links the OpenDAL adapter issue (#6) Co-Authored-By: Claude Opus 5.5 --- spec/0006-store.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/0006-store.md b/spec/0006-store.md index 77fc58e..57b059f 100644 --- a/spec/0006-store.md +++ b/spec/0006-store.md @@ -5,7 +5,7 @@ status: done updated: 2026-10-20 versions: [unreleased] related: [1] -issues: [] +issues: [6] --- # 6. Store @@ -40,7 +40,7 @@ read and write through it. ## Open questions and issues -None. +- [#6](https://github.com/polythings/loko/issues/6) Store adapter for OpenDAL (loko-opendal) ## History From f05508f1a837cda8592758827acf4a548482dba4 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 00:06:13 -0400 Subject: [PATCH 03/19] Clock, Random, Console: one module per implementation The trait stays in lib.rs; TestClock, StdClock, TokioClock, and JsClock (and their counterparts in loko-random and loko-console) each get a module named for where they run, gated once on its mod line instead of on every item, with their own tests. The public API is unchanged. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 7 +- crates/loko-clock/src/js.rs | 37 ++++++ crates/loko-clock/src/lib.rs | 180 +++------------------------ crates/loko-clock/src/system.rs | 35 ++++++ crates/loko-clock/src/testing.rs | 77 ++++++++++++ crates/loko-clock/src/tokio_clock.rs | 31 +++++ crates/loko-console/src/js.rs | 33 +++++ crates/loko-console/src/lib.rs | 121 ++---------------- crates/loko-console/src/system.rs | 27 ++++ crates/loko-console/src/testing.rs | 60 +++++++++ crates/loko-random/src/js.rs | 44 +++++++ crates/loko-random/src/lib.rs | 158 ++--------------------- crates/loko-random/src/rand.rs | 14 +++ crates/loko-random/src/system.rs | 29 +++++ crates/loko-random/src/testing.rs | 64 ++++++++++ 15 files changed, 497 insertions(+), 420 deletions(-) create mode 100644 crates/loko-clock/src/js.rs create mode 100644 crates/loko-clock/src/system.rs create mode 100644 crates/loko-clock/src/testing.rs create mode 100644 crates/loko-clock/src/tokio_clock.rs create mode 100644 crates/loko-console/src/js.rs create mode 100644 crates/loko-console/src/system.rs create mode 100644 crates/loko-console/src/testing.rs create mode 100644 crates/loko-random/src/js.rs create mode 100644 crates/loko-random/src/rand.rs create mode 100644 crates/loko-random/src/system.rs create mode 100644 crates/loko-random/src/testing.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e6ad17..c389fa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,11 @@ we took. without `Send`. Implementations that hold state share it between clones (`Arc>`). - **Errors:** what the platform gives (`io::Result` for the console), or the crate's own `non_exhaustive` error enum (`loko_store::Error`). No `unwrap()` outside tests; a poisoned lock is recovered, not a panic. -- **Targets:** JavaScript code under `#[cfg(all(feature = "js", target_arch = "wasm32", target_os = "unknown"))]`; - system code under `#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]`. +- **Layout:** the trait in `lib.rs`, and each implementation in a module named for where it runs: `testing.rs`, + `system.rs` (native and WASI), `js.rs`, or a runtime's (`tokio_clock.rs`). Each module has its own tests. +- **Targets:** a module that only builds on some targets is gated once, on its `mod` line and its `pub use`: + JavaScript code under `#[cfg(all(feature = "js", target_arch = "wasm32", target_os = "unknown"))]`, system code under + `#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]`. - **Suppressing a lint:** `#[expect(lint, reason = "...")]` with a real reason. Never `#[allow]`. ## Tests diff --git a/crates/loko-clock/src/js.rs b/crates/loko-clock/src/js.rs new file mode 100644 index 0000000..3824968 --- /dev/null +++ b/crates/loko-clock/src/js.rs @@ -0,0 +1,37 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use js_sys::{Function, Promise, Reflect}; +use wasm_bindgen::{JsCast, JsValue}; + +use crate::Clock; + +/// `Date.now` and `setTimeout` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): +/// browsers, Workers, and Node. +#[derive(Clone, Copy, Debug, Default)] +pub struct JsClock; + +impl Clock for JsClock { + fn now(&self) -> SystemTime { + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "milliseconds since the epoch")] + let ms = js_sys::Date::now() as u64; + UNIX_EPOCH + Duration::from_millis(ms) + } + + async fn sleep(&self, duration: Duration) { + #[expect(clippy::cast_precision_loss, reason = "timeouts are far below 2^53 ms")] + let ms = JsValue::from_f64(duration.as_millis() as f64); + let promise = Promise::new(&mut |resolve, _| { + let global = js_sys::global(); + match Reflect::get(&global, &"setTimeout".into()).and_then(JsCast::dyn_into::) { + Ok(set_timeout) => { + let _ = set_timeout.call2(&global, &resolve, &ms); + } + // A host without timers: resolve at once. + Err(_) => { + let _ = resolve.call0(&JsValue::NULL); + } + } + }); + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } +} diff --git a/crates/loko-clock/src/lib.rs b/crates/loko-clock/src/lib.rs index 9a6e97c..7fc9e74 100644 --- a/crates/loko-clock/src/lib.rs +++ b/crates/loko-clock/src/lib.rs @@ -17,10 +17,25 @@ //! assert_eq!(clock.now_ms(), 2_000); //! ``` -use std::future::{Future, ready}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::future::Future; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +mod js; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +mod system; +mod testing; +#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] +mod tokio_clock; + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +pub use js::JsClock; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub use system::StdClock; +pub use testing::TestClock; +#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] +pub use tokio_clock::TokioClock; + /// The time and a timer. /// /// The future isn't required to be `Send`, so a clock can wrap JavaScript timers. @@ -46,164 +61,3 @@ pub fn from_ms(ms: i64) -> SystemTime { let magnitude = Duration::from_millis(ms.unsigned_abs()); if ms >= 0 { UNIX_EPOCH + magnitude } else { UNIX_EPOCH - magnitude } } - -/// A clock that moves only when told, or when slept on (sleeping returns at once, the clock moved -/// forward by the sleep). Clones share the same time. The idea is ZIO's `TestClock`. -#[derive(Clone, Debug)] -pub struct TestClock { - now: Arc>, -} - -impl TestClock { - /// A clock at `now`. - pub fn new(now: SystemTime) -> Self { - Self { now: Arc::new(Mutex::new(now)) } - } - - /// A clock `ms` milliseconds after the Unix epoch. - pub fn at_ms(ms: i64) -> Self { - Self::new(from_ms(ms)) - } - - /// Moves the clock forward by `duration`. - pub fn advance(&self, duration: Duration) { - *self.now.lock().unwrap_or_else(PoisonError::into_inner) += duration; - } - - /// Sets the clock to `now`, forward or back. - pub fn set(&self, now: SystemTime) { - *self.now.lock().unwrap_or_else(PoisonError::into_inner) = now; - } -} - -impl Default for TestClock { - /// A clock at the Unix epoch. - fn default() -> Self { - Self::new(UNIX_EPOCH) - } -} - -impl Clock for TestClock { - fn now(&self) -> SystemTime { - *self.now.lock().unwrap_or_else(PoisonError::into_inner) - } - - fn sleep(&self, duration: Duration) -> impl Future { - self.advance(duration); - ready(()) - } -} - -/// The system clock for programs without an async runtime (native targets and WASI). Its `sleep` blocks the -/// calling thread before the future is returned, so in async code use `TokioClock` (or another -/// runtime's clock) instead. -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -#[derive(Clone, Copy, Debug, Default)] -pub struct StdClock; - -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -impl Clock for StdClock { - fn now(&self) -> SystemTime { - SystemTime::now() - } - - fn sleep(&self, duration: Duration) -> impl Future { - std::thread::sleep(duration); - ready(()) - } -} - -/// The system clock and tokio's timer (feature `tokio`, native targets). -#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] -#[derive(Clone, Copy, Debug, Default)] -pub struct TokioClock; - -#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] -impl Clock for TokioClock { - fn now(&self) -> SystemTime { - SystemTime::now() - } - - fn sleep(&self, duration: Duration) -> impl Future { - tokio::time::sleep(duration) - } -} - -/// `Date.now` and `setTimeout` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): browsers, Workers, -/// and Node. -#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -#[derive(Clone, Copy, Debug, Default)] -pub struct JsClock; - -#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -impl Clock for JsClock { - fn now(&self) -> SystemTime { - #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "milliseconds since the epoch")] - let ms = js_sys::Date::now() as u64; - UNIX_EPOCH + Duration::from_millis(ms) - } - - async fn sleep(&self, duration: Duration) { - use js_sys::{Function, Promise, Reflect}; - use wasm_bindgen::{JsCast, JsValue}; - - #[expect(clippy::cast_precision_loss, reason = "timeouts are far below 2^53 ms")] - let ms = JsValue::from_f64(duration.as_millis() as f64); - let promise = Promise::new(&mut |resolve, _| { - let global = js_sys::global(); - match Reflect::get(&global, &"setTimeout".into()).and_then(JsCast::dyn_into::) { - Ok(set_timeout) => { - let _ = set_timeout.call2(&global, &resolve, &ms); - } - Err(_) => { - let _ = resolve.call0(&JsValue::NULL); - } - } - }); - let _ = wasm_bindgen_futures::JsFuture::from(promise).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_clocks_move_when_told() { - let clock = TestClock::at_ms(5_000); - let other = clock.clone(); - other.advance(Duration::from_millis(250)); - assert_eq!(clock.now_ms(), 5_250, "clones share the time"); - clock.set(from_ms(-1_500)); - assert_eq!(clock.now_ms(), -1_500); - assert_eq!(TestClock::default().now(), UNIX_EPOCH); - } - - #[test] - fn sleeping_moves_a_test_clock() { - let clock = TestClock::at_ms(0); - let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - let mut sleep = std::pin::pin!(clock.sleep(Duration::from_secs(3))); - assert!(sleep.as_mut().poll(&mut cx).is_ready()); - assert_eq!(clock.now_ms(), 3_000); - } - - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - #[test] - fn the_std_clock_tells_time_and_blocks() { - let before = StdClock.now_ms(); - let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - let mut sleep = std::pin::pin!(StdClock.sleep(Duration::from_millis(5))); - assert!(sleep.as_mut().poll(&mut cx).is_ready()); - assert!(StdClock.now_ms() >= before + 5); - } - - #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] - #[tokio::test] - async fn the_tokio_clock_tells_time_and_waits() { - let before = TokioClock.now_ms(); - assert!(before > 1_700_000_000_000, "after November 2023"); - TokioClock.sleep(Duration::from_millis(5)).await; - assert!(TokioClock.now_ms() >= before + 5); - } -} diff --git a/crates/loko-clock/src/system.rs b/crates/loko-clock/src/system.rs new file mode 100644 index 0000000..a714614 --- /dev/null +++ b/crates/loko-clock/src/system.rs @@ -0,0 +1,35 @@ +use std::future::{Future, ready}; +use std::time::{Duration, SystemTime}; + +use crate::Clock; + +/// The system clock for programs without an async runtime (native targets and WASI). Its `sleep` +/// blocks the calling thread before the future is returned, so in async code use `TokioClock` (or +/// another runtime's clock) instead. +#[derive(Clone, Copy, Debug, Default)] +pub struct StdClock; + +impl Clock for StdClock { + fn now(&self) -> SystemTime { + SystemTime::now() + } + + fn sleep(&self, duration: Duration) -> impl Future { + std::thread::sleep(duration); + ready(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_std_clock_tells_time_and_blocks() { + let before = StdClock.now_ms(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut sleep = std::pin::pin!(StdClock.sleep(Duration::from_millis(5))); + assert!(sleep.as_mut().poll(&mut cx).is_ready()); + assert!(StdClock.now_ms() >= before + 5); + } +} diff --git a/crates/loko-clock/src/testing.rs b/crates/loko-clock/src/testing.rs new file mode 100644 index 0000000..c12d7eb --- /dev/null +++ b/crates/loko-clock/src/testing.rs @@ -0,0 +1,77 @@ +use std::future::{Future, ready}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::{Clock, from_ms}; + +/// A clock that moves only when told, or when slept on (sleeping returns at once, the clock moved +/// forward by the sleep). Clones share the same time. The idea is ZIO's `TestClock`. +#[derive(Clone, Debug)] +pub struct TestClock { + now: Arc>, +} + +impl TestClock { + /// A clock at `now`. + pub fn new(now: SystemTime) -> Self { + Self { now: Arc::new(Mutex::new(now)) } + } + + /// A clock `ms` milliseconds after the Unix epoch. + pub fn at_ms(ms: i64) -> Self { + Self::new(from_ms(ms)) + } + + /// Moves the clock forward by `duration`. + pub fn advance(&self, duration: Duration) { + *self.now.lock().unwrap_or_else(PoisonError::into_inner) += duration; + } + + /// Sets the clock to `now`, forward or back. + pub fn set(&self, now: SystemTime) { + *self.now.lock().unwrap_or_else(PoisonError::into_inner) = now; + } +} + +impl Default for TestClock { + /// A clock at the Unix epoch. + fn default() -> Self { + Self::new(UNIX_EPOCH) + } +} + +impl Clock for TestClock { + fn now(&self) -> SystemTime { + *self.now.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn sleep(&self, duration: Duration) -> impl Future { + self.advance(duration); + ready(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clocks_move_when_told() { + let clock = TestClock::at_ms(5_000); + let other = clock.clone(); + other.advance(Duration::from_millis(250)); + assert_eq!(clock.now_ms(), 5_250, "clones share the time"); + clock.set(from_ms(-1_500)); + assert_eq!(clock.now_ms(), -1_500); + assert_eq!(TestClock::default().now(), UNIX_EPOCH); + } + + #[test] + fn sleeping_moves_a_test_clock() { + let clock = TestClock::at_ms(0); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut sleep = std::pin::pin!(clock.sleep(Duration::from_secs(3))); + assert!(sleep.as_mut().poll(&mut cx).is_ready()); + assert_eq!(clock.now_ms(), 3_000); + } +} diff --git a/crates/loko-clock/src/tokio_clock.rs b/crates/loko-clock/src/tokio_clock.rs new file mode 100644 index 0000000..986a985 --- /dev/null +++ b/crates/loko-clock/src/tokio_clock.rs @@ -0,0 +1,31 @@ +use std::future::Future; +use std::time::{Duration, SystemTime}; + +use crate::Clock; + +/// The system clock and tokio's timer (feature `tokio`, native targets). +#[derive(Clone, Copy, Debug, Default)] +pub struct TokioClock; + +impl Clock for TokioClock { + fn now(&self) -> SystemTime { + SystemTime::now() + } + + fn sleep(&self, duration: Duration) -> impl Future { + tokio::time::sleep(duration) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn the_tokio_clock_tells_time_and_waits() { + let before = TokioClock.now_ms(); + assert!(before > 1_700_000_000_000, "after November 2023"); + TokioClock.sleep(Duration::from_millis(5)).await; + assert!(TokioClock.now_ms() >= before + 5); + } +} diff --git a/crates/loko-console/src/js.rs b/crates/loko-console/src/js.rs new file mode 100644 index 0000000..505a7b9 --- /dev/null +++ b/crates/loko-console/src/js.rs @@ -0,0 +1,33 @@ +use std::io; + +use js_sys::{Function, Reflect}; +use wasm_bindgen::JsCast; + +use crate::Console; + +/// `console.log` and `console.error` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): +/// browsers, Workers, and Node. +#[derive(Clone, Copy, Debug, Default)] +pub struct JsConsole; + +impl JsConsole { + fn call(method: &str, line: &str) -> io::Result<()> { + let console = Reflect::get(&js_sys::global(), &"console".into()) + .map_err(|_| io::Error::other("the host has no console"))?; + let f: Function = Reflect::get(&console, &method.into()) + .ok() + .and_then(|f| f.dyn_into().ok()) + .ok_or_else(|| io::Error::other(format!("the console has no {method}")))?; + f.call1(&console, &line.into()).map(drop).map_err(|e| io::Error::other(format!("console.{method}: {e:?}"))) + } +} + +impl Console for JsConsole { + fn print_line(&self, line: &str) -> io::Result<()> { + Self::call("log", line) + } + + fn print_error_line(&self, line: &str) -> io::Result<()> { + Self::call("error", line) + } +} diff --git a/crates/loko-console/src/lib.rs b/crates/loko-console/src/lib.rs index e47cf24..4117195 100644 --- a/crates/loko-console/src/lib.rs +++ b/crates/loko-console/src/lib.rs @@ -15,8 +15,17 @@ //! # Ok::<(), std::io::Error>(()) //! ``` -use std::io::{self, Write}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::io; + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +mod js; +mod system; +mod testing; + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +pub use js::JsConsole; +pub use system::StdConsole; +pub use testing::TestConsole; /// Lines of text to standard output and standard error. #[loko_macros::service] @@ -27,111 +36,3 @@ pub trait Console { /// Writes `line` and a newline to standard error. fn print_error_line(&self, line: &str) -> io::Result<()>; } - -/// The process's standard output and standard error. -#[derive(Clone, Copy, Debug, Default)] -pub struct StdConsole; - -impl Console for StdConsole { - fn print_line(&self, line: &str) -> io::Result<()> { - writeln!(io::stdout().lock(), "{line}") - } - - fn print_error_line(&self, line: &str) -> io::Result<()> { - writeln!(io::stderr().lock(), "{line}") - } -} - -/// Keeps the lines written, for tests. Clones share the lines. -#[derive(Clone, Debug, Default)] -pub struct TestConsole { - out: Arc>>, - err: Arc>>, -} - -fn lock(lines: &Mutex>) -> MutexGuard<'_, Vec> { - lines.lock().unwrap_or_else(PoisonError::into_inner) -} - -impl TestConsole { - /// No lines yet. - pub fn new() -> Self { - Self::default() - } - - /// The lines written to standard output, in order. - pub fn lines(&self) -> Vec { - lock(&self.out).clone() - } - - /// The lines written to standard error, in order. - pub fn error_lines(&self) -> Vec { - lock(&self.err).clone() - } -} - -impl Console for TestConsole { - fn print_line(&self, line: &str) -> io::Result<()> { - lock(&self.out).push(line.to_string()); - Ok(()) - } - - fn print_error_line(&self, line: &str) -> io::Result<()> { - lock(&self.err).push(line.to_string()); - Ok(()) - } -} - -/// `console.log` and `console.error` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): browsers, -/// Workers, and Node. -#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -#[derive(Clone, Copy, Debug, Default)] -pub struct JsConsole; - -#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -impl JsConsole { - fn call(method: &str, line: &str) -> io::Result<()> { - use js_sys::{Function, Reflect}; - use wasm_bindgen::JsCast; - - let console = Reflect::get(&js_sys::global(), &"console".into()) - .map_err(|_| io::Error::other("the host has no console"))?; - let f: Function = Reflect::get(&console, &method.into()) - .ok() - .and_then(|f| f.dyn_into().ok()) - .ok_or_else(|| io::Error::other(format!("the console has no {method}")))?; - f.call1(&console, &line.into()).map(drop).map_err(|e| io::Error::other(format!("console.{method}: {e:?}"))) - } -} - -#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -impl Console for JsConsole { - fn print_line(&self, line: &str) -> io::Result<()> { - Self::call("log", line) - } - - fn print_error_line(&self, line: &str) -> io::Result<()> { - Self::call("error", line) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_consoles_keep_lines() { - let console = TestConsole::new(); - let shared = console.clone(); - console.print_line("a").unwrap(); - shared.print_error_line("oops").unwrap(); - console.print_line("b").unwrap(); - assert_eq!(console.lines(), ["a", "b"]); - assert_eq!(console.error_lines(), ["oops"]); - } - - #[test] - fn the_std_console_writes() { - StdConsole.print_line("loko-console test line").unwrap(); - } -} diff --git a/crates/loko-console/src/system.rs b/crates/loko-console/src/system.rs new file mode 100644 index 0000000..bf3d288 --- /dev/null +++ b/crates/loko-console/src/system.rs @@ -0,0 +1,27 @@ +use std::io::{self, Write}; + +use crate::Console; + +/// The process's standard output and standard error. +#[derive(Clone, Copy, Debug, Default)] +pub struct StdConsole; + +impl Console for StdConsole { + fn print_line(&self, line: &str) -> io::Result<()> { + writeln!(io::stdout().lock(), "{line}") + } + + fn print_error_line(&self, line: &str) -> io::Result<()> { + writeln!(io::stderr().lock(), "{line}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_std_console_writes() { + StdConsole.print_line("loko-console test line").unwrap(); + } +} diff --git a/crates/loko-console/src/testing.rs b/crates/loko-console/src/testing.rs new file mode 100644 index 0000000..df33dbb --- /dev/null +++ b/crates/loko-console/src/testing.rs @@ -0,0 +1,60 @@ +use std::io; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use crate::Console; + +/// Keeps the lines written, for tests. Clones share the lines. +#[derive(Clone, Debug, Default)] +pub struct TestConsole { + out: Arc>>, + err: Arc>>, +} + +fn lock(lines: &Mutex>) -> MutexGuard<'_, Vec> { + lines.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl TestConsole { + /// No lines yet. + pub fn new() -> Self { + Self::default() + } + + /// The lines written to standard output, in order. + pub fn lines(&self) -> Vec { + lock(&self.out).clone() + } + + /// The lines written to standard error, in order. + pub fn error_lines(&self) -> Vec { + lock(&self.err).clone() + } +} + +impl Console for TestConsole { + fn print_line(&self, line: &str) -> io::Result<()> { + lock(&self.out).push(line.to_string()); + Ok(()) + } + + fn print_error_line(&self, line: &str) -> io::Result<()> { + lock(&self.err).push(line.to_string()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_consoles_keep_lines() { + let console = TestConsole::new(); + let shared = console.clone(); + console.print_line("a").unwrap(); + shared.print_error_line("oops").unwrap(); + console.print_line("b").unwrap(); + assert_eq!(console.lines(), ["a", "b"]); + assert_eq!(console.error_lines(), ["oops"]); + } +} diff --git a/crates/loko-random/src/js.rs b/crates/loko-random/src/js.rs new file mode 100644 index 0000000..5d79656 --- /dev/null +++ b/crates/loko-random/src/js.rs @@ -0,0 +1,44 @@ +use js_sys::{Function, Reflect, Uint8Array}; +use wasm_bindgen::JsCast; + +use crate::Random; + +/// `crypto.getRandomValues` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): +/// browsers, Workers, and Node. Hosts without it fall back to `Math.random`. +#[derive(Clone, Copy, Debug, Default)] +pub struct JsRandom; + +impl Random for JsRandom { + fn next_u64(&self) -> u64 { + let mut bytes = [0u8; 8]; + self.fill(&mut bytes); + u64::from_le_bytes(bytes) + } + + fn fill(&self, dest: &mut [u8]) { + let crypto = Reflect::get(&js_sys::global(), &"crypto".into()).ok().filter(|c| !c.is_undefined()); + let get_random_values = crypto + .as_ref() + .and_then(|c| Reflect::get(c, &"getRandomValues".into()).ok().and_then(|f| f.dyn_into::().ok())); + // getRandomValues fills at most 65,536 bytes per call. + for chunk in dest.chunks_mut(65_536) { + let filled = match (&crypto, &get_random_values) { + (Some(crypto), Some(f)) => { + let array = Uint8Array::new_with_length(u32::try_from(chunk.len()).unwrap_or(u32::MAX)); + f.call1(crypto, &array).is_ok() && { + array.copy_to(chunk); + true + } + } + _ => false, + }; + if !filled { + for b in chunk.iter_mut() { + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a byte")] + let byte = (js_sys::Math::random() * 256.0) as u8; + *b = byte; + } + } + } + } +} diff --git a/crates/loko-random/src/lib.rs b/crates/loko-random/src/lib.rs index d192222..8aab39d 100644 --- a/crates/loko-random/src/lib.rs +++ b/crates/loko-random/src/lib.rs @@ -14,7 +14,19 @@ //! assert_eq!(a.next_u64(), b.next_u64()); //! ``` -use std::sync::{Arc, Mutex, PoisonError}; +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +mod js; +#[cfg(feature = "rand")] +mod rand; +#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] +mod system; +mod testing; + +#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] +pub use js::JsRandom; +#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] +pub use system::OsRandom; +pub use testing::SeededRandom; /// Random numbers. #[loko_macros::service] @@ -30,147 +42,3 @@ pub trait Random { } } } - -/// A repeatable sequence of numbers from a seed (SplitMix64): the same seed gives the same numbers. -/// Not for secrets. Clones share the sequence. -#[derive(Clone, Debug)] -pub struct SeededRandom { - state: Arc>, -} - -impl SeededRandom { - /// The sequence of `seed`. - pub fn new(seed: u64) -> Self { - Self { state: Arc::new(Mutex::new(seed)) } - } -} - -impl Default for SeededRandom { - /// The sequence of seed 0. - fn default() -> Self { - Self::new(0) - } -} - -impl Random for SeededRandom { - fn next_u64(&self) -> u64 { - // SplitMix64 (Steele, Lea, and Flood, 2014): a Weyl sequence with a mixing function. - let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); - *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = *state; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } -} - -/// The operating system's random number generator (feature `getrandom`, native targets and WASI). -#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] -#[derive(Clone, Copy, Debug, Default)] -pub struct OsRandom; - -#[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] -impl Random for OsRandom { - fn next_u64(&self) -> u64 { - // The operating system's generator only fails when it isn't available at all. - getrandom::u64().expect("the operating system's random number generator") - } - - fn fill(&self, dest: &mut [u8]) { - getrandom::fill(dest).expect("the operating system's random number generator"); - } -} - -/// `crypto.getRandomValues` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): browsers, Workers, and -/// Node. Hosts without it fall back to `Math.random`. -#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -#[derive(Clone, Copy, Debug, Default)] -pub struct JsRandom; - -#[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -impl Random for JsRandom { - fn next_u64(&self) -> u64 { - let mut bytes = [0u8; 8]; - self.fill(&mut bytes); - u64::from_le_bytes(bytes) - } - - fn fill(&self, dest: &mut [u8]) { - use js_sys::{Function, Reflect, Uint8Array}; - use wasm_bindgen::JsCast; - - let crypto = Reflect::get(&js_sys::global(), &"crypto".into()).ok().filter(|c| !c.is_undefined()); - let get_random_values = crypto - .as_ref() - .and_then(|c| Reflect::get(c, &"getRandomValues".into()).ok().and_then(|f| f.dyn_into::().ok())); - // getRandomValues fills at most 65,536 bytes per call. - for chunk in dest.chunks_mut(65_536) { - let filled = match (&crypto, &get_random_values) { - (Some(crypto), Some(f)) => { - let array = Uint8Array::new_with_length(u32::try_from(chunk.len()).unwrap_or(u32::MAX)); - f.call1(crypto, &array).is_ok() && { - array.copy_to(chunk); - true - } - } - _ => false, - }; - if !filled { - for b in chunk.iter_mut() { - #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a byte")] - let byte = (js_sys::Math::random() * 256.0) as u8; - *b = byte; - } - } - } - } -} - -/// Any `rand_core` generator, behind a mutex so it can be shared (feature `rand`). -#[cfg(feature = "rand")] -impl Random for Mutex { - fn next_u64(&self) -> u64 { - self.lock().unwrap_or_else(PoisonError::into_inner).next_u64() - } - - fn fill(&self, dest: &mut [u8]) { - self.lock().unwrap_or_else(PoisonError::into_inner).fill_bytes(dest); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn seeded_sequences_repeat() { - let a = SeededRandom::new(42); - let b = SeededRandom::new(42); - let first: Vec = (0..5).map(|_| a.next_u64()).collect(); - let second: Vec = (0..5).map(|_| b.next_u64()).collect(); - assert_eq!(first, second); - assert_ne!(first[0], first[1]); - assert_ne!(SeededRandom::new(1).next_u64(), SeededRandom::new(2).next_u64()); - // SplitMix64's first output for seed 0, as published. - assert_eq!(SeededRandom::new(0).next_u64(), 0xE220_A839_7B1D_CDAF); - } - - #[test] - fn fill_covers_odd_lengths() { - let random = SeededRandom::new(3); - let mut bytes = [0u8; 13]; - random.fill(&mut bytes); - assert!(bytes.iter().any(|&b| b != 0)); - let clone = random.clone(); - assert_ne!(random.next_u64(), clone.next_u64(), "clones share one sequence"); - } - - #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] - #[test] - fn os_random_numbers_differ() { - assert_ne!(OsRandom.next_u64(), OsRandom.next_u64()); - let mut bytes = [0u8; 32]; - OsRandom.fill(&mut bytes); - assert!(bytes.iter().any(|&b| b != 0)); - } -} diff --git a/crates/loko-random/src/rand.rs b/crates/loko-random/src/rand.rs new file mode 100644 index 0000000..d3c1559 --- /dev/null +++ b/crates/loko-random/src/rand.rs @@ -0,0 +1,14 @@ +use std::sync::{Mutex, PoisonError}; + +use crate::Random; + +/// Any `rand_core` generator, behind a mutex so it can be shared (feature `rand`). +impl Random for Mutex { + fn next_u64(&self) -> u64 { + self.lock().unwrap_or_else(PoisonError::into_inner).next_u64() + } + + fn fill(&self, dest: &mut [u8]) { + self.lock().unwrap_or_else(PoisonError::into_inner).fill_bytes(dest); + } +} diff --git a/crates/loko-random/src/system.rs b/crates/loko-random/src/system.rs new file mode 100644 index 0000000..4610887 --- /dev/null +++ b/crates/loko-random/src/system.rs @@ -0,0 +1,29 @@ +use crate::Random; + +/// The operating system's random number generator (feature `getrandom`, native targets and WASI). +#[derive(Clone, Copy, Debug, Default)] +pub struct OsRandom; + +impl Random for OsRandom { + fn next_u64(&self) -> u64 { + // The operating system's generator only fails when it isn't available at all. + getrandom::u64().expect("the operating system's random number generator") + } + + fn fill(&self, dest: &mut [u8]) { + getrandom::fill(dest).expect("the operating system's random number generator"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn os_random_numbers_differ() { + assert_ne!(OsRandom.next_u64(), OsRandom.next_u64()); + let mut bytes = [0u8; 32]; + OsRandom.fill(&mut bytes); + assert!(bytes.iter().any(|&b| b != 0)); + } +} diff --git a/crates/loko-random/src/testing.rs b/crates/loko-random/src/testing.rs new file mode 100644 index 0000000..6c2e148 --- /dev/null +++ b/crates/loko-random/src/testing.rs @@ -0,0 +1,64 @@ +use std::sync::{Arc, Mutex, PoisonError}; + +use crate::Random; + +/// A repeatable sequence of numbers from a seed (SplitMix64): the same seed gives the same numbers. +/// Not for secrets. Clones share the sequence. +#[derive(Clone, Debug)] +pub struct SeededRandom { + state: Arc>, +} + +impl SeededRandom { + /// The sequence of `seed`. + pub fn new(seed: u64) -> Self { + Self { state: Arc::new(Mutex::new(seed)) } + } +} + +impl Default for SeededRandom { + /// The sequence of seed 0. + fn default() -> Self { + Self::new(0) + } +} + +impl Random for SeededRandom { + fn next_u64(&self) -> u64 { + // SplitMix64 (Steele, Lea, and Flood, 2014): a Weyl sequence with a mixing function. + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seeded_sequences_repeat() { + let a = SeededRandom::new(42); + let b = SeededRandom::new(42); + let first: Vec = (0..5).map(|_| a.next_u64()).collect(); + let second: Vec = (0..5).map(|_| b.next_u64()).collect(); + assert_eq!(first, second); + assert_ne!(first[0], first[1]); + assert_ne!(SeededRandom::new(1).next_u64(), SeededRandom::new(2).next_u64()); + // SplitMix64's first output for seed 0, as published. + assert_eq!(SeededRandom::new(0).next_u64(), 0xE220_A839_7B1D_CDAF); + } + + #[test] + fn fill_covers_odd_lengths() { + let random = SeededRandom::new(3); + let mut bytes = [0u8; 13]; + random.fill(&mut bytes); + assert!(bytes.iter().any(|&b| b != 0)); + let clone = random.clone(); + assert_ne!(random.next_u64(), clone.next_u64(), "clones share one sequence"); + } +} From 5a6f520f92516303274a43b21ad500a819a4bf04 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 00:11:24 -0400 Subject: [PATCH 04/19] Object store: keep the URL's port, read empty ranges as no bytes, list from an offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From CodeRabbit's review of lakekit#12, where the adapter started: - from_url built its root from the host alone, so http://localhost:9000/… reached the wrong endpoint and every URI fell outside the root. The root keeps the port and leaves out credentials. - An empty range, or one starting past the end, failed in object_store; it now reads as no bytes when the object exists, as in MemoryStore. - Listing a prefix lists its directory from an offset just before the prefix (`v` → `u`), which still includes an object named exactly the prefix. Co-Authored-By: Claude Opus 5.5 --- crates/loko-object-store/src/lib.rs | 103 ++++++++++++++++++++++++---- spec/0006-store.md | 10 ++- spec/README.md | 2 +- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/crates/loko-object-store/src/lib.rs b/crates/loko-object-store/src/lib.rs index c130bbd..c0ced78 100644 --- a/crates/loko-object-store/src/lib.rs +++ b/crates/loko-object-store/src/lib.rs @@ -63,11 +63,7 @@ impl ObjectStoreAdapter { V: Into, { let parsed = Url::parse(url).map_err(|e| Error::Invalid(format!("{url}: {e}")))?; - let root = match parsed.host_str().filter(|h| !h.is_empty()) { - Some(host) => format!("{}://{host}/", parsed.scheme()), - // `file:///data/t`: a file system rooted at `/`. - None => format!("{}:///", parsed.scheme()), - }; + let root = root_of(&parsed); let root_url = Url::parse(&root).map_err(|e| Error::Invalid(format!("{root}: {e}")))?; let (store, _) = object_store::parse_url_opts(&root_url, options).map_err(store_error)?; Ok(Self::new(Arc::from(store), root)) @@ -87,6 +83,31 @@ impl ObjectStoreAdapter { } } +/// The root of `url`: its scheme, host, and port (`http://localhost:9000/`), without the path or +/// credentials. `file:///data/t` gives `file:///`, a file system rooted at `/`. +fn root_of(url: &Url) -> String { + match (url.host_str().filter(|h| !h.is_empty()), url.port()) { + (Some(host), Some(port)) => format!("{}://{host}:{port}/", url.scheme()), + (Some(host), None) => format!("{}://{host}/", url.scheme()), + (None, _) => format!("{}:///", url.scheme()), + } +} + +/// An offset for listing directory `dir` that skips objects sorting before `name`, the start of the +/// object names wanted. `object_store` lists the objects after the offset, so the offset is `name` +/// with its last character one lower (`v` → `u`), which still keeps an object named exactly `name`. +/// Only for letters and digits, whose predecessor is a letter or digit that paths keep as it is. +fn list_offset(dir: &str, name: &str) -> Option { + let last = name.chars().next_back()?; + if !last.is_ascii_alphanumeric() || matches!(last, '0' | 'A' | 'a') { + return None; + } + let lower = char::from(u8::try_from(last).ok()? - 1); + let start = &name[..name.len() - 1]; + let offset = if dir.is_empty() { format!("{start}{lower}") } else { format!("{dir}/{start}{lower}") }; + Path::parse(offset).ok() +} + /// A multipart upload through `object_store`. #[derive(Debug)] pub struct ObjectStoreUpload(Box); @@ -117,8 +138,20 @@ impl Store for ObjectStoreAdapter { } async fn get_range(&self, uri: &str, start: u64, len: u64) -> Result> { - let range = start..start.saturating_add(len); - Ok(self.store.get_range(&self.path(uri)?, range).await.map_err(store_error)?.to_vec()) + // `object_store` rejects empty ranges and ranges starting past the end, which a `Store` + // answers with no bytes (as `MemoryStore` does) when the object exists. + let path = self.path(uri)?; + if len == 0 { + return self.store.head(&path).await.map(|_| Vec::new()).map_err(store_error); + } + match self.store.get_range(&path, start..start.saturating_add(len)).await { + Ok(bytes) => Ok(bytes.to_vec()), + Err(e @ object_store::Error::NotFound { .. }) => Err(store_error(e)), + Err(e) => match self.store.head(&path).await { + Ok(meta) if start >= meta.size => Ok(Vec::new()), + _ => Err(store_error(e)), + }, + } } async fn exists(&self, uri: &str) -> Result { @@ -148,14 +181,18 @@ impl Store for ObjectStoreAdapter { fn list(&self, prefix: &str) -> impl Future>> { // `object_store` lists by directory; loko by string prefix (`…/metadata/v`). List the - // directory the prefix is in and keep what starts with it. - let relative = prefix.strip_prefix(&self.root).map(|rest| rest.rsplit_once('/').map_or("", |(dir, _)| dir)); + // directory the prefix is in, from just before the prefix, and keep what starts with it. + let split = prefix.strip_prefix(&self.root).map(|rest| rest.rsplit_once('/').unwrap_or(("", rest))); async move { - let relative = - relative.ok_or_else(|| Error::Invalid(format!("{prefix} is outside this store ({})", self.root)))?; + let (relative, name) = + split.ok_or_else(|| Error::Invalid(format!("{prefix} is outside this store ({})", self.root)))?; let dir = Path::parse(relative).map_err(|e| Error::Invalid(format!("{prefix}: {e}")))?; let dir = (!relative.is_empty()).then_some(&dir); - let objects: Vec<_> = self.store.list(dir).try_collect().await.map_err(store_error)?; + let listing = match list_offset(relative, name) { + Some(offset) => self.store.list_with_offset(dir, &offset), + None => self.store.list(dir), + }; + let objects: Vec<_> = listing.try_collect().await.map_err(store_error)?; Ok(objects .into_iter() .map(|o| Listed { uri: self.uri(&o.location), size: o.size }) @@ -230,6 +267,48 @@ mod tests { assert_eq!(store.list("memory://t").await.unwrap().len(), 5, "a prefix right under the root"); } + #[tokio::test] + async fn empty_and_past_the_end_ranges_are_empty() { + let store = adapter(); + store.put("memory://t/a", b"abc".to_vec()).await.unwrap(); + assert_eq!(store.get_range("memory://t/a", 1, 0).await.unwrap(), b""); + assert_eq!(store.get_range("memory://t/a", 3, 2).await.unwrap(), b""); + assert_eq!(store.get_range("memory://t/a", 9, 2).await.unwrap(), b""); + assert_eq!(store.get_range("memory://t/a", 1, 10).await.unwrap(), b"bc", "cut at the end"); + assert!(matches!(store.get_range("memory://t/missing", 0, 0).await, Err(Error::NotFound(_)))); + assert!(matches!(store.get_range("memory://t/missing", 0, 2).await, Err(Error::NotFound(_)))); + } + + #[tokio::test] + async fn listing_from_an_offset_keeps_the_exact_prefix() { + let store = adapter(); + for name in ["snap-1.avro", "u9", "v", "v1.metadata.json", "w"] { + store.put(&format!("memory://t/metadata/{name}"), vec![]).await.unwrap(); + } + let mut listed: Vec = + store.list("memory://t/metadata/v").await.unwrap().into_iter().map(|o| o.uri).collect(); + listed.sort(); + assert_eq!(listed, ["memory://t/metadata/v", "memory://t/metadata/v1.metadata.json"]); + } + + #[test] + fn offsets_sort_just_before_the_prefix() { + assert_eq!(list_offset("t/metadata", "v").map(|p| p.to_string()).as_deref(), Some("t/metadata/u")); + assert_eq!(list_offset("", "t").map(|p| p.to_string()).as_deref(), Some("s")); + assert_eq!(list_offset("t", "snap-9").map(|p| p.to_string()).as_deref(), Some("t/snap-8")); + assert!(list_offset("t", "").is_none(), "a whole directory"); + assert!(list_offset("t", "a").is_none() && list_offset("t", "x-").is_none(), "no safe predecessor"); + } + + #[test] + fn roots_keep_the_port_and_drop_credentials() { + let root = |url: &str| root_of(&Url::parse(url).unwrap()); + assert_eq!(root("http://localhost:9000/bucket/t"), "http://localhost:9000/"); + assert_eq!(root("https://user:secret@example.com:443/t"), "https://example.com/", "443 is https's own"); + assert_eq!(root("s3://bucket/warehouse/t"), "s3://bucket/"); + assert_eq!(root("file:///data/t"), "file:///"); + } + #[tokio::test] async fn local_files_have_file_uris() { let dir = tempfile::tempdir().unwrap(); diff --git a/spec/0006-store.md b/spec/0006-store.md index 57b059f..aa05aec 100644 --- a/spec/0006-store.md +++ b/spec/0006-store.md @@ -2,7 +2,7 @@ number: 6 title: Store status: done -updated: 2026-10-20 +updated: 2026-10-21 versions: [unreleased] related: [1] issues: [6] @@ -10,7 +10,7 @@ issues: [6] # 6. Store -**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) +**Done** · updated 2026-10-21 · versions unreleased · milestone: [0.2](milestones/v0-2.md) ## Summary @@ -28,7 +28,9 @@ read and write through it. Libraries with their own error type convert from it. - **`MemoryStore`:** objects in memory, for tests. Clones share the objects. - **`ObjectStoreAdapter`** (`loko-object-store`): a `Store` over any `object_store` client, from a client and a root - URI or from a URL with options. Put-if-absent is `PutMode::Create`. The crate enables no backends; the application + URI or from a URL with options (the root keeps the URL's port and drops any credentials). Put-if-absent is + `PutMode::Create`. An empty range, or one starting past the end, reads as no bytes, as in `MemoryStore`. Listing a + prefix lists its directory from an offset just before the prefix, so `…/metadata/v` skips the manifests before it. The crate enables no backends; the application turns on the ones it needs on its own `object_store` dependency. - **Not in the platforms:** a program bundles a store with a platform ([spec 7](0007-platforms.md)). @@ -45,3 +47,5 @@ read and write through it. ## History - 2026-10-20: built for 0.2. Moved from lakekit (`lakekit-core`'s `Store` and `MemoryStore`, and `lakekit-object-store`). +- 2026-10-21: review fixes from lakekit#12: `from_url` keeps the port, empty and past-the-end ranges read as no bytes, + and listing starts from an offset. diff --git a/spec/README.md b/spec/README.md index 6e119be..6eedac0 100644 --- a/spec/README.md +++ b/spec/README.md @@ -38,7 +38,7 @@ loko's design, laid out like lakekit's, Timelake's, and lakeglass's. Start here, | 3 | [Random](0003-random.md) | Done | 2026-10-20 | | 4 | [Env](0004-env.md) | Done | 2026-10-20 | | 5 | [Console](0005-console.md) | Done | 2026-10-20 | -| 6 | [Store](0006-store.md) | Done | 2026-10-20 | +| 6 | [Store](0006-store.md) | Done | 2026-10-21 | | 7 | [Platforms](0007-platforms.md) | Done | 2026-10-20 | ## Milestones From 009756afed9566f22b6b25bb843eb8761a3bc1b3 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 00:40:29 -0400 Subject: [PATCH 05/19] Resources, Provides, and wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The things loko passes are resources: #[loko::resource] (was #[service]), since "service" means a request handler in Rust (tower) and a server elsewhere. The derive is #[derive(Provides)] with #[provides(X, …)] on fields. #[loko::wraps(field: Type)] on an `impl X for Wrapper` makes a wrapper that writes only the methods it changes. HasX now carries a hidden wrapped_ for each method, forwarding to the held resource; the blanket impl calls them, the derive keeps them, and #[wraps] overrides the ones written. Wrappers stack. Ready-made: OffsetClock (a clock ahead or behind another), CountingStore (counts calls), ReadOnlyStore (refuses writes), and PrefixedStore (passes on only URIs under a prefix, refusing `..`). Spec 1 records D16–D18; the Layer question is #7. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- CHANGELOG.md | 24 +- CONTRIBUTING.md | 12 +- Cargo.toml | 4 +- PRIOR_ART.md | 7 +- README.md | 76 +++-- crates/loko-clock/Cargo.toml | 2 +- crates/loko-clock/README.md | 3 +- crates/loko-clock/src/lib.rs | 20 +- crates/loko-clock/src/offset.rs | 64 ++++ crates/loko-console/Cargo.toml | 2 +- crates/loko-console/README.md | 2 +- crates/loko-console/src/lib.rs | 4 +- crates/loko-env/Cargo.toml | 2 +- crates/loko-env/README.md | 2 +- crates/loko-env/src/lib.rs | 4 +- crates/loko-macros/Cargo.toml | 2 +- crates/loko-macros/README.md | 27 +- crates/loko-macros/src/lib.rs | 297 +++++++++++++----- crates/loko-random/Cargo.toml | 2 +- crates/loko-random/README.md | 2 +- crates/loko-random/src/lib.rs | 4 +- crates/loko-store/Cargo.toml | 2 +- crates/loko-store/README.md | 4 +- crates/loko-store/src/counting.rs | 145 +++++++++ crates/loko-store/src/lib.rs | 22 +- crates/loko-store/src/memory.rs | 12 +- crates/loko-store/src/prefixed.rs | 104 ++++++ crates/loko-store/src/read_only.rs | 65 ++++ crates/loko-store/src/store.rs | 2 +- crates/loko/Cargo.toml | 4 +- crates/loko/README.md | 10 +- crates/loko/src/lib.rs | 32 +- crates/loko/src/platform.rs | 56 ++-- .../loko/tests/{services.rs => resources.rs} | 85 ++++- spec/0001-resources.md | 76 +++++ spec/0001-services.md | 64 ---- spec/0002-clock.md | 8 +- spec/0003-random.md | 2 +- spec/0006-store.md | 7 +- spec/0007-platforms.md | 10 +- spec/README.md | 10 +- spec/milestones/README.md | 2 +- spec/milestones/v0-2.md | 8 +- 44 files changed, 985 insertions(+), 313 deletions(-) create mode 100644 crates/loko-clock/src/offset.rs create mode 100644 crates/loko-store/src/counting.rs create mode 100644 crates/loko-store/src/prefixed.rs create mode 100644 crates/loko-store/src/read_only.rs rename crates/loko/tests/{services.rs => resources.rs} (51%) create mode 100644 spec/0001-resources.md delete mode 100644 spec/0001-services.md diff --git a/AGENTS.md b/AGENTS.md index eb9900a..0964b69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Agent instructions -loko: services for Rust programs (`Clock`, `Random`, `Env`, `Console`, `Store`) passed to code as traits, with test, -system, and JavaScript implementations. One crate per service (`crates/loko-`), the derive macros +loko: resources for Rust programs (`Clock`, `Random`, `Env`, `Console`, `Store`) passed to code as traits, with test, +system, and JavaScript implementations. One crate per resource (`crates/loko-`), the derive macros (`crates/loko-macros`), and the `loko` crate, which re-exports them and adds the platforms. The polythings projects (Timelake, lakekit, lakeglass) use it. @@ -22,7 +22,7 @@ system, and JavaScript implementations. One crate per service (`crates/loko->`). - **Errors:** what the platform gives (`io::Result` for the console), or the crate's own `non_exhaustive` error enum (`loko_store::Error`). No `unwrap()` outside tests; a poisoned lock is recovered, not a panic. - **Layout:** the trait in `lib.rs`, and each implementation in a module named for where it runs: `testing.rs`, `system.rs` (native and WASI), `js.rs`, or a runtime's (`tokio_clock.rs`). Each module has its own tests. + A wrapper gets a module named for what it does (`offset.rs`, `counting.rs`) and is written with + `#[loko_macros::wraps]`. - **Targets:** a module that only builds on some targets is gated once, on its `mod` line and its `pub use`: JavaScript code under `#[cfg(all(feature = "js", target_arch = "wasm32", target_os = "unknown"))]`, system code under `#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]`. diff --git a/Cargo.toml b/Cargo.toml index e614690..738948a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ edition = "2024" rust-version = "1.99" license = "Apache-2.0" repository = "https://github.com/polythings/loko" -keywords = ["services", "clock", "random", "testing", "wasm"] +keywords = ["resources", "clock", "random", "testing", "wasm"] categories = ["development-tools::testing", "wasm", "asynchronous"] [workspace.dependencies] @@ -41,7 +41,7 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" proc-macro2 = "1" quote = "1" -syn = { version = "2", features = ["full"] } +syn = { version = "2", features = ["full", "visit-mut"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/PRIOR_ART.md b/PRIOR_ART.md index a3a6a1d..96d9ba0 100644 --- a/PRIOR_ART.md +++ b/PRIOR_ART.md @@ -18,9 +18,10 @@ never code: every line of loko is our own. How we credit is in [CONTRIBUTING.md] | Idea | From | License of the source | Where in loko | |---|---|---|---| -| The time, randomness, the console, and the system as services passed to code, each with a test implementation (a clock that moves when told) | [ZIO](https://github.com/zio/zio) (`Clock`, `Random`, `Console`, `System`, `TestClock`) | Apache-2.0 | The service crates and `TestPlatform` | -| An environment of services described by "has a" traits (`HasX`), so code asks for the services it needs and any environment holding them qualifies | [RIO](https://github.com/commercialhaskell/rio) (`HasLogFunc` and the "Has" type class pattern) | MIT | `#[service]`'s `HasX` traits | -| Implementing a trait for a struct by forwarding to a field, chosen with a derive and a field attribute | [ambassador](https://github.com/hobofan/ambassador) | Apache-2.0 / MIT | `#[derive(Services)]` | +| The time, randomness, the console, and the system as resources passed to code, each with a test implementation (a clock that moves when told) | [ZIO](https://github.com/zio/zio) (`Clock`, `Random`, `Console`, `System`, `TestClock`) | Apache-2.0 | The resource crates and `TestPlatform` | +| An environment of resources described by "has a" traits (`HasX`), so code asks for the resources it needs and any environment holding them qualifies | [RIO](https://github.com/commercialhaskell/rio) (`HasLogFunc` and the "Has" type class pattern) | MIT | `#[resource]`'s `HasX` traits | +| Implementing a trait for a struct by forwarding to a field, chosen with a derive and a field attribute | [ambassador](https://github.com/hobofan/ambassador) | Apache-2.0 / MIT | `#[derive(Provides)]` and `#[wraps]` | +| Wrappers that are the trait they wrap, so they stack | [tower](https://github.com/tower-rs/tower) (`Layer`) | MIT | `#[wraps]`, `CountingStore`, `ReadOnlyStore`, `PrefixedStore`, `OffsetClock` | | SplitMix64: a 64-bit state advanced by a constant and mixed with two multiply-xorshift rounds | Steele, Lea, and Flood, "Fast Splittable Pseudorandom Number Generators" (OOPSLA 2014) | A published algorithm | `SeededRandom` | | A storage interface with no I/O library of its own, supplied by the application | [delta-kernel-rs](https://github.com/delta-io/delta-kernel-rs) (its `Engine` trait), by way of lakekit | Apache-2.0 | `Store` | | One crate per backend under an umbrella crate with a feature each | [arrow-rs](https://github.com/apache/arrow-rs), [Apache OpenDAL](https://github.com/apache/opendal) | Apache-2.0 | The workspace layout | diff --git a/README.md b/README.md index 63962b7..cbf2afe 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # loko -Services for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed +Resources for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed to code as traits instead of reached for as globals. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the -program passes the system's services, and tests pass services they control. The same code runs natively, under WASI, +program passes the system's resources, and tests pass resources they control. The same code runs natively, under WASI, and in browsers and Workers. loko is what the [polythings](https://github.com/polythings) projects share (Timelake, lakekit, and lakeglass among @@ -14,8 +14,8 @@ use std::time::Duration; use loko::{Clock, Random, TestPlatform}; /// An ID from the time and a random suffix. -fn id(services: &(impl Clock + Random)) -> String { - format!("{}-{:04x}", services.now_ms(), services.next_u64() & 0xffff) +fn id(resources: &(impl Clock + Random)) -> String { + format!("{}-{:04x}", resources.now_ms(), resources.next_u64() & 0xffff) } let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program @@ -23,9 +23,9 @@ platform.clock.advance(Duration::from_secs(1)); assert_eq!(id(&platform), "1000-cdaf"); // the same on every run ``` -## Services +## Resources -| Service | Methods | For tests | System | JavaScript (feature `js`) | +| Resource | Methods | For tests | System | JavaScript (feature `js`) | |---|---|---|---|---| | [`Clock`](crates/loko-clock) | `now`, `now_ms`, `sleep` | `TestClock` | `StdClock`, `TokioClock` (feature `tokio`) | `JsClock` | | [`Random`](crates/loko-random) | `next_u64`, `fill` | `SeededRandom` | `OsRandom` (feature `getrandom`) | `JsRandom` | @@ -33,7 +33,7 @@ assert_eq!(id(&platform), "1000-cdaf"); // the same on every run | [`Console`](crates/loko-console) | `print_line`, `print_error_line` | `TestConsole` | `StdConsole` | `JsConsole` | | [`Store`](crates/loko-store) (feature `store`) | get, put, put-if-absent, upload, list, delete | `MemoryStore` | [`ObjectStoreAdapter`](crates/loko-object-store) (feature `object-store`) | the application's binding | -Futures aren't required to be `Send`, so a service can wrap JavaScript promises. With feature `rand`, any `rand_core` +Futures aren't required to be `Send`, so a resource can wrap JavaScript promises. With feature `rand`, any `rand_core` generator in a `Mutex` is a `Random`. ## Platforms @@ -47,27 +47,27 @@ A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, and is eac | `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native, under tokio (feature `tokio`) | | `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | Browsers, Workers, and Node (feature `js`) | -## Your own services and bundles +## Your own resources and bundles -`#[loko::service]` makes a trait a service, and `#[derive(Services)]` makes a struct each service its fields provide. -A program bundles a platform with a store and its own services; a service's implementation can need other services. +`#[loko::resource]` makes a trait a resource, and `#[derive(Provides)]` makes a struct each resource its fields provide. +A program bundles a platform with a store and its own resources; a resource's implementation can need other resources. ```rust use loko::prelude::*; use loko::store::MemoryStore; -#[loko::service] +#[loko::resource] trait Greeter { fn greet(&self, name: &str) -> String; } -#[derive(Services)] +#[derive(Provides)] struct App { - #[service(Clock, Random, Env, Console)] + #[provides(Clock, Random, Env, Console)] platform: TestPlatform, - #[service(Store)] + #[provides(Store)] store: MemoryStore, - #[service(Greeter)] + #[provides(Greeter)] greeter: G, } @@ -77,20 +77,56 @@ fn welcome(app: &(impl Greeter + Env + Console)) -> std::io::Result<()> { } ``` -`#[service]` on `trait Greeter` adds `trait HasGreeter` (a type that holds a `Greeter`) and makes every `HasGreeter` +`#[resource]` on `trait Greeter` adds `trait HasGreeter` (a type that holds a `Greeter`) and makes every `HasGreeter` a `Greeter`; the derive implements `HasGreeter` for `App`. It names `HasGreeter` next to the path you give, so bring -both in (`loko::prelude::*` does for loko's services) or write the path (`#[service(loko::Clock)]`). +both in (`loko::prelude::*` does for loko's resources) or write the path (`#[provides(loko::Clock)]`). + +## Wrappers + +A wrapper holds one implementation of a resource and is itself one, changing some methods. `#[loko::wraps(field: +Type)]` on its `impl` forwards every method it doesn't write to the field: + +```rust +use loko::prelude::*; + +/// Shouts standard output; standard error passes as it is. +struct Shouting { + inner: C, +} + +#[loko::wraps(inner: C)] +impl Console for Shouting { + fn print_line(&self, line: &str) -> std::io::Result<()> { + self.inner.print_line(&line.to_uppercase()) + } +} +``` + +Wrappers stack, and loko has some ready-made: + +| Wrapper | What it does | +|---|---| +| `OffsetClock` | Another clock, ahead or behind by a fixed amount, for testing against clock skew | +| `CountingStore` | Counts the calls of each kind it passes on | +| `ReadOnlyStore` | Reads through, refuses writes | +| `PrefixedStore` | Passes on only URIs under a prefix, refusing `..` and the rest | + +`CountingStore::new(ReadOnlyStore::new(PrefixedStore::new(s3, "s3://bucket/tenant-a/")))` is a store that counts +its requests, never writes, and stays inside one tenant's data. + +A wrapper keeps the inner resource's associated types, and a provided method it doesn't write (such as `now_ms`) +forwards to the inner one too, so write it when it depends on a method the wrapper changes. ## Crates | Crate | What it holds | |---|---| | [`loko`](crates/loko) | Everything below, the platforms, and a prelude, with a feature per optional part | -| [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One service each, with its implementations | +| [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One resource each, with its implementations | | [`loko-object-store`](crates/loko-object-store) | A `Store` over an `object_store` client: S3, GCS, Azure, local files, memory | -| [`loko-macros`](crates/loko-macros) | `#[service]` and `#[derive(Services)]` | +| [`loko-macros`](crates/loko-macros) | `#[resource]`, `#[derive(Provides)]`, and `#[wraps]` | -A library that needs one service can depend on its crate alone. +A library that needs one resource can depend on its crate alone. ## Development diff --git a/crates/loko-clock/Cargo.toml b/crates/loko-clock/Cargo.toml index 6ab64ad..6753186 100644 --- a/crates/loko-clock/Cargo.toml +++ b/crates/loko-clock/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-clock" -description = "loko's Clock service: the time and a timer, with test, tokio, and JavaScript clocks" +description = "loko's Clock resource: the time and a timer, with test, tokio, and JavaScript clocks" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-clock/README.md b/crates/loko-clock/README.md index 0fe40ba..59da30a 100644 --- a/crates/loko-clock/README.md +++ b/crates/loko-clock/README.md @@ -1,6 +1,6 @@ # loko-clock -[loko](https://github.com/polythings/loko)'s `Clock` service: the time and a timer, passed to code instead of read +[loko](https://github.com/polythings/loko)'s `Clock` resource: the time and a timer, passed to code instead of read from the system, so tests control time and the same code runs natively, under WASI, and in browsers. - `Clock`: `now` (a `SystemTime`), `now_ms` (milliseconds since the Unix epoch), and `sleep(Duration)`. The future @@ -10,6 +10,7 @@ from the system, so tests control time and the same code runs natively, under WA - `StdClock`: the system clock, with a `sleep` that blocks the thread (native and WASI, no async runtime needed). - `TokioClock` (feature `tokio`): the system clock and tokio's timer. - `JsClock` (feature `js`, `wasm32-unknown-unknown`): `Date.now` and `setTimeout`. +- `OffsetClock`: another clock, ahead or behind by a fixed amount, for testing against clock skew. ```rust use std::time::Duration; diff --git a/crates/loko-clock/src/lib.rs b/crates/loko-clock/src/lib.rs index 7fc9e74..596eddc 100644 --- a/crates/loko-clock/src/lib.rs +++ b/crates/loko-clock/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Clock` service: the time and a timer, passed to code instead of read from the system, so +//! loko's `Clock` resource: the time and a timer, passed to code instead of read from the system, so //! tests control time and the same code runs natively and in WebAssembly. //! //! - [`Clock`]: `now`, `now_ms`, and `sleep`. @@ -6,6 +6,7 @@ //! - `StdClock`: the system clock with a blocking sleep, for programs without an async runtime. //! - `TokioClock` (feature `tokio`): the system clock and tokio's timer, on native targets. //! - `JsClock` (feature `js`): `Date.now` and `setTimeout`, in browsers, Workers, and Node. +//! - [`OffsetClock`]: another clock, ahead or behind by a fixed amount. //! //! ``` //! use std::time::Duration; @@ -22,6 +23,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] mod js; +mod offset; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] mod system; mod testing; @@ -30,6 +32,7 @@ mod tokio_clock; #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] pub use js::JsClock; +pub use offset::OffsetClock; #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub use system::StdClock; pub use testing::TestClock; @@ -39,23 +42,28 @@ pub use tokio_clock::TokioClock; /// The time and a timer. /// /// The future isn't required to be `Send`, so a clock can wrap JavaScript timers. -#[loko_macros::service] +#[loko_macros::resource] pub trait Clock { /// The current time. fn now(&self) -> SystemTime; /// Milliseconds since the Unix epoch (negative before it). fn now_ms(&self) -> i64 { - match self.now().duration_since(UNIX_EPOCH) { - Ok(since) => i64::try_from(since.as_millis()).unwrap_or(i64::MAX), - Err(before) => i64::try_from(before.duration().as_millis()).map_or(i64::MIN, |ms| -ms), - } + to_ms(self.now()) } /// Waits `duration`. fn sleep(&self, duration: Duration) -> impl Future; } +/// Milliseconds from the Unix epoch to `time` (negative before it), saturating. +pub fn to_ms(time: SystemTime) -> i64 { + match time.duration_since(UNIX_EPOCH) { + Ok(since) => i64::try_from(since.as_millis()).unwrap_or(i64::MAX), + Err(before) => i64::try_from(before.duration().as_millis()).map_or(i64::MIN, |ms| -ms), + } +} + /// The time `ms` milliseconds after the Unix epoch (before it, if negative). pub fn from_ms(ms: i64) -> SystemTime { let magnitude = Duration::from_millis(ms.unsigned_abs()); diff --git a/crates/loko-clock/src/offset.rs b/crates/loko-clock/src/offset.rs new file mode 100644 index 0000000..c4ea978 --- /dev/null +++ b/crates/loko-clock/src/offset.rs @@ -0,0 +1,64 @@ +use std::time::{Duration, SystemTime}; + +use crate::{Clock, HasClock, to_ms}; + +/// Another clock, ahead or behind by a fixed amount: what a machine whose clock is off reads, for +/// testing code against clock skew. Sleeping is the other clock's. +#[derive(Clone, Debug, Default)] +pub struct OffsetClock { + inner: C, + offset: Duration, + behind: bool, +} + +impl OffsetClock { + /// `inner`, ahead by `offset`. + pub fn ahead(inner: C, offset: Duration) -> Self { + Self { inner, offset, behind: false } + } + + /// `inner`, behind by `offset`. + pub fn behind(inner: C, offset: Duration) -> Self { + Self { inner, offset, behind: true } + } + + /// The clock offset. + pub fn inner(&self) -> &C { + &self.inner + } +} + +#[loko_macros::wraps(inner: C)] +impl Clock for OffsetClock { + fn now(&self) -> SystemTime { + let now = self.inner.now(); + let moved = if self.behind { now.checked_sub(self.offset) } else { now.checked_add(self.offset) }; + // Past what `SystemTime` holds: stay at the other clock's time rather than panic. + moved.unwrap_or(now) + } + + // The other clock's `now_ms` would leave out the offset. + fn now_ms(&self) -> i64 { + to_ms(Clock::now(self)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TestClock; + + #[test] + fn offset_clocks_follow_the_other_clock() { + let clock = TestClock::at_ms(10_000); + let ahead = OffsetClock::ahead(clock.clone(), Duration::from_secs(2)); + let behind = OffsetClock::behind(clock.clone(), Duration::from_secs(3)); + assert_eq!((ahead.now_ms(), behind.now_ms()), (12_000, 7_000)); + clock.advance(Duration::from_secs(1)); + assert_eq!((ahead.now_ms(), behind.now_ms()), (13_000, 8_000)); + + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!(std::pin::pin!(ahead.sleep(Duration::from_secs(5))).as_mut().poll(&mut cx).is_ready()); + assert_eq!(clock.now_ms(), 16_000, "sleeping is the other clock's"); + } +} diff --git a/crates/loko-console/Cargo.toml b/crates/loko-console/Cargo.toml index a68131e..6c6160f 100644 --- a/crates/loko-console/Cargo.toml +++ b/crates/loko-console/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-console" -description = "loko's Console service: lines to standard output and error, captured in tests, or to the JavaScript console" +description = "loko's Console resource: lines to standard output and error, captured in tests, or to the JavaScript console" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-console/README.md b/crates/loko-console/README.md index 995962e..2aec827 100644 --- a/crates/loko-console/README.md +++ b/crates/loko-console/README.md @@ -1,6 +1,6 @@ # loko-console -[loko](https://github.com/polythings/loko)'s `Console` service: lines to standard output and standard error, passed to +[loko](https://github.com/polythings/loko)'s `Console` resource: lines to standard output and standard error, passed to code instead of printed directly, so tests read what was printed and code in a browser logs to its console. - `Console`: `print_line` and `print_error_line`. diff --git a/crates/loko-console/src/lib.rs b/crates/loko-console/src/lib.rs index 4117195..2477054 100644 --- a/crates/loko-console/src/lib.rs +++ b/crates/loko-console/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Console` service: lines to standard output and standard error, passed to code instead of +//! loko's `Console` resource: lines to standard output and standard error, passed to code instead of //! printed directly, so tests read what was printed and code in a browser logs to its console. //! //! - [`Console`]: `print_line` and `print_error_line`. @@ -28,7 +28,7 @@ pub use system::StdConsole; pub use testing::TestConsole; /// Lines of text to standard output and standard error. -#[loko_macros::service] +#[loko_macros::resource] pub trait Console { /// Writes `line` and a newline to standard output. fn print_line(&self, line: &str) -> io::Result<()>; diff --git a/crates/loko-env/Cargo.toml b/crates/loko-env/Cargo.toml index bd9fbb3..950eedf 100644 --- a/crates/loko-env/Cargo.toml +++ b/crates/loko-env/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-env" -description = "loko's Env service: environment variables, from the process or from a map" +description = "loko's Env resource: environment variables, from the process or from a map" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-env/README.md b/crates/loko-env/README.md index 7a5a372..f8f3637 100644 --- a/crates/loko-env/README.md +++ b/crates/loko-env/README.md @@ -1,6 +1,6 @@ # loko-env -[loko](https://github.com/polythings/loko)'s `Env` service: environment variables, passed to code instead of read from +[loko](https://github.com/polythings/loko)'s `Env` resource: environment variables, passed to code instead of read from the process, so tests set their own and code in a browser (which has none) reads them from a map. - `Env`: `var` and `vars`. diff --git a/crates/loko-env/src/lib.rs b/crates/loko-env/src/lib.rs index 0b5a73a..e145366 100644 --- a/crates/loko-env/src/lib.rs +++ b/crates/loko-env/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Env` service: environment variables, passed to code instead of read from the process, so +//! loko's `Env` resource: environment variables, passed to code instead of read from the process, so //! tests set their own and code in a browser (which has none) gets them from a map. //! //! - [`Env`]: `var` and `vars`. @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; /// Environment variables. -#[loko_macros::service] +#[loko_macros::resource] pub trait Env { /// The variable `name`, or `None` if it isn't set (or isn't valid Unicode). fn var(&self, name: &str) -> Option; diff --git a/crates/loko-macros/Cargo.toml b/crates/loko-macros/Cargo.toml index 0579536..96e2e95 100644 --- a/crates/loko-macros/Cargo.toml +++ b/crates/loko-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-macros" -description = "loko's macros: #[service] makes a trait a service, #[derive(Services)] wires services into a struct" +description = "loko's macros: #[resource] makes a trait a resource, #[derive(Provides)] wires resources into a struct" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-macros/README.md b/crates/loko-macros/README.md index f734bda..2b77dcf 100644 --- a/crates/loko-macros/README.md +++ b/crates/loko-macros/README.md @@ -2,28 +2,39 @@ [loko](https://github.com/polythings/loko)'s macros. Use them through the `loko` crate. -- `#[service]` on a trait `X` adds a trait `HasX` (a type that holds an `X`, with an accessor) and implements `X` for +- `#[resource]` on a trait `X` adds a trait `HasX` (a type that holds an `X`, with an accessor) and implements `X` for every `HasX`, forwarding each method, associated type, and constant. Methods take `&self`; the trait can't be generic. -- `#[derive(Services)]` implements `HasX` for each `#[service(X, …)]` on a struct's fields, so the struct is each of - those services. It names `HasX` next to the path given: `#[service(loko::Clock)]` uses `loko::HasClock`, and a bare - `#[service(Clock)]` needs `HasClock` in scope (`loko::prelude::*` brings in both). +- `#[derive(Provides)]` implements `HasX` for each `#[provides(X, …)]` on a struct's fields, so the struct is each of + those resources. It names `HasX` next to the path given: `#[provides(loko::Clock)]` uses `loko::HasClock`, and a bare + `#[provides(Clock)]` needs `HasClock` in scope (`loko::prelude::*` brings in both). +- `#[wraps(field: Type)]` on `impl X for Wrapper` makes a wrapper: the methods written replace `X`'s, and the rest + forward to `self.field`. A wrapper keeps the inner resource's associated types. ```rust use loko::prelude::*; -#[loko::service] +#[loko::resource] trait Greeter { fn greet(&self, name: &str) -> String; } -#[derive(Services)] +#[derive(Provides)] struct App { - #[service(Clock, Random)] + #[provides(Clock, Random)] platform: TestPlatform, - #[service(Greeter)] + #[provides(Greeter)] greeter: G, } + +struct Excited(G); + +#[loko::wraps(0: G)] +impl Greeter for Excited { + fn greet(&self, name: &str) -> String { + format!("{}!", self.0.greet(name)) + } +} ``` Apache License 2.0. diff --git a/crates/loko-macros/src/lib.rs b/crates/loko-macros/src/lib.rs index ce52cdf..c7be5b2 100644 --- a/crates/loko-macros/src/lib.rs +++ b/crates/loko-macros/src/lib.rs @@ -1,32 +1,37 @@ -//! loko's macros, used by its service crates and re-exported by `loko`. +//! loko's macros, used by its resource crates and re-exported by `loko`. //! -//! - `#[service]` on a trait `X` adds a trait `HasX` (a type that holds an `X`) and implements `X` -//! for every `HasX` by forwarding each method to the held service. -//! - `#[derive(Services)]` implements `HasX` for a struct's fields marked `#[service(X, …)]`, so the -//! struct is every service its fields provide. +//! - `#[resource]` on a trait `X` adds a trait `HasX` (a type that holds an `X`) and implements `X` +//! for every `HasX`. Each method goes through a `HasX` method (`wrapped_`) that forwards +//! to the held resource unless the implementation says otherwise. +//! - `#[derive(Provides)]` implements `HasX` for a struct's fields marked `#[provides(X, …)]`, so the +//! struct is every resource its fields provide. +//! - `#[wraps(field: Type)]` on an `impl X for Wrapper` implements `HasX` instead: the methods +//! written replace the forwarding ones, and the rest forward to the field. //! //! The `HasX` traits follow RIO's "Has" type classes, and choosing a field to forward to with a derive //! follows ambassador (`PRIOR_ART.md`). use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::{format_ident, quote}; +use quote::{ToTokens, format_ident, quote}; +use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; +use syn::visit_mut::VisitMut; use syn::{ - Data, DeriveInput, Error, FnArg, Ident, ItemTrait, Pat, PatIdent, Path, ReturnType, Signature, Token, TraitItem, - TraitItemFn, parse_macro_input, parse_quote, + Data, DeriveInput, Error, FnArg, Ident, ImplItem, ItemImpl, ItemTrait, Member, Pat, PatIdent, Path, ReturnType, + Signature, Token, TraitItem, TraitItemFn, Type, TypePath, parse_macro_input, parse_quote, }; -/// Makes a trait a loko service: adds `Has` and implements the trait for every type that has -/// one, forwarding each method. Methods must take `&self`; the trait can't be generic. +/// Makes a trait a loko resource: adds `Has` and implements the trait for every type that +/// has one. Methods must take `&self`; the trait can't be generic. #[proc_macro_attribute] -pub fn service(args: TokenStream, input: TokenStream) -> TokenStream { +pub fn resource(args: TokenStream, input: TokenStream) -> TokenStream { if !args.is_empty() { - return Error::new(Span::call_site(), "#[service] takes no arguments").to_compile_error().into(); + return Error::new(Span::call_site(), "#[resource] takes no arguments").to_compile_error().into(); } let item = parse_macro_input!(input as ItemTrait); - match expand_service(&item) { + match expand_resource(&item) { Ok(extra) => quote! { #item #extra }.into(), Err(e) => { let e = e.to_compile_error(); @@ -35,7 +40,7 @@ pub fn service(args: TokenStream, input: TokenStream) -> TokenStream { } } -/// The accessor of service `name`: `Clock` → `clock`, `ObjectStore` → `object_store`. +/// The accessor of resource `name`: `Clock` → `clock`, `ObjectStore` → `object_store`. fn accessor(name: &Ident) -> Ident { let mut snake = String::new(); for (i, c) in name.to_string().chars().enumerate() { @@ -51,39 +56,116 @@ fn accessor(name: &Ident) -> Ident { Ident::new(&snake, name.span()) } -fn expand_service(item: &ItemTrait) -> syn::Result { +/// The `HasX` method behind method `method` of resource `X`. +fn wrapped(method: &Ident) -> Ident { + format_ident!("wrapped_{}", method) +} + +/// `Has`, at the same path: `loko::Clock` → `loko::HasClock`. +fn has_path(resource: &Path) -> syn::Result<(Path, Ident)> { + let name = resource.segments.last().ok_or_else(|| Error::new(resource.span(), "a resource path"))?.ident.clone(); + let mut has = resource.clone(); + if let Some(last) = has.segments.last_mut() { + last.ident = format_ident!("Has{}", name); + last.arguments = syn::PathArguments::None; + } + Ok((has, name)) +} + +/// Rewrites `Self::Assoc` to the held resource's `Assoc`, for signatures moved into `HasX`, where +/// `Self` is the holder. `Self::X` (the `HasX` type itself) stays. +struct HeldAssoc<'a> { + has: &'a Path, + name: &'a Ident, +} + +impl VisitMut for HeldAssoc<'_> { + fn visit_type_path_mut(&mut self, path: &mut TypePath) { + let segments = &path.path.segments; + if path.qself.is_none() + && segments.len() >= 2 + && segments[0].ident == "Self" + && segments[0].arguments.is_none() + && segments[1].ident != *self.name + { + let (has, name) = (self.has, self.name); + let rest: Vec<_> = segments.iter().skip(1).collect(); + *path = parse_quote! { <::#name as #name>::#(#rest)::* }; + } + syn::visit_mut::visit_type_path_mut(self, path); + } +} + +/// Names the arguments of `sig` (after `&self`) `arg0`, `arg1`, …, so a body can pass them on. +fn name_arguments(sig: &mut Signature) -> syn::Result> { + match sig.inputs.first() { + Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none() => {} + _ => return Err(Error::new(sig.span(), "loko resource methods take `&self`")), + } + let mut args = vec![]; + for (i, input) in sig.inputs.iter_mut().skip(1).enumerate() { + let FnArg::Typed(typed) = input else { continue }; + let ident = format_ident!("arg{}", i); + *typed.pat = + Pat::Ident(PatIdent { attrs: vec![], by_ref: None, mutability: None, ident: ident.clone(), subpat: None }); + args.push(ident); + } + Ok(args) +} + +/// `sig { call }`, awaiting the call in an async method. +fn method(sig: &Signature, call: &TokenStream2) -> TokenStream2 { + let body = if sig.asyncness.is_some() { + quote! { #call.await } + } else { + call.clone() + }; + match sig.output { + ReturnType::Default => quote! { #[inline] #sig { #body; } }, + ReturnType::Type(..) => quote! { #[inline] #sig { #body } }, + } +} + +fn expand_resource(item: &ItemTrait) -> syn::Result { if !item.generics.params.is_empty() { - return Err(Error::new(item.generics.span(), "a loko service can't be generic")); + return Err(Error::new(item.generics.span(), "a loko resource can't be generic")); } if item.unsafety.is_some() { - return Err(Error::new(item.span(), "a loko service can't be an unsafe trait")); + return Err(Error::new(item.span(), "a loko resource can't be an unsafe trait")); } let vis = &item.vis; let name = &item.ident; - let has = format_ident!("Has{}", name); + let has: Path = format_ident!("Has{}", name).into(); let get = accessor(name); - let supertraits = &item.supertraits; let held = quote! { <::#name as #name> }; - let mut items = vec![]; + let (mut has_items, mut impl_items) = (vec![], vec![]); for trait_item in &item.items { match trait_item { TraitItem::Type(t) => { let ident = &t.ident; let (_, ty_generics, where_clause) = t.generics.split_for_impl(); - items.push(quote! { type #ident #ty_generics = #held::#ident #ty_generics #where_clause; }); + impl_items.push(quote! { type #ident #ty_generics = #held::#ident #ty_generics #where_clause; }); } TraitItem::Const(c) => { let (ident, ty) = (&c.ident, &c.ty); - items.push(quote! { const #ident: #ty = #held::#ident; }); + impl_items.push(quote! { const #ident: #ty = #held::#ident; }); + } + TraitItem::Fn(f) => { + let (has_item, impl_item) = forward_fn(f, &has, name, &get)?; + has_items.push(has_item); + impl_items.push(impl_item); } - TraitItem::Fn(f) => items.push(forward_fn(f, &held, &get)?), - other => return Err(Error::new(other.span(), "loko services can hold types, constants, and methods")), + other => return Err(Error::new(other.span(), "loko resources can hold types, constants, and methods")), } } - let doc = format!("A type that holds a [`{name}`], and so is one (see `#[loko::service]`)."); + let doc = format!( + "A type that holds a [`{name}`], and so is one: `#[derive(Provides)]` implements this for a field, \ + and `#[loko::wraps]` for a wrapper that changes some methods." + ); let get_doc = format!("The [`{name}`] this holds."); + let supertraits = &item.supertraits; let bound = if supertraits.is_empty() { quote! {} } else { @@ -92,61 +174,54 @@ fn expand_service(item: &ItemTrait) -> syn::Result { Ok(quote! { #[doc = #doc] #vis trait #has { - /// The service held. + /// The resource held. type #name: #name; #[doc = #get_doc] fn #get(&self) -> &Self::#name; + #(#has_items)* } #[automatically_derived] impl #name for T { - #(#items)* + #(#impl_items)* } }) } -/// A method of the blanket impl: the trait's signature, calling the held service's method. -fn forward_fn(f: &TraitItemFn, held: &TokenStream2, get: &Ident) -> syn::Result { - let mut sig: Signature = f.sig.clone(); - match sig.inputs.first() { - Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none() => {} - _ => return Err(Error::new(sig.span(), "loko service methods take `&self`")), - } - let mut args = vec![]; - for (i, input) in sig.inputs.iter_mut().skip(1).enumerate() { - let FnArg::Typed(typed) = input else { continue }; - let ident = format_ident!("__loko_arg{}", i); - *typed.pat = - Pat::Ident(PatIdent { attrs: vec![], by_ref: None, mutability: None, ident: ident.clone(), subpat: None }); - args.push(ident); - } - let method = &sig.ident; - let call = quote! { #held::#method(self.#get(), #(#args),*) }; - let body = if sig.asyncness.is_some() { - quote! { #call.await } - } else { - call - }; - Ok(match sig.output { - ReturnType::Default => quote! { #[inline] #sig { #body; } }, - ReturnType::Type(..) => quote! { #[inline] #sig { #body } }, - }) +/// For a method of the resource: its forwarding method in `HasX` (`wrapped_`, calling the +/// held resource), and its method in the blanket impl (calling the `HasX` one). +fn forward_fn(f: &TraitItemFn, has: &Path, name: &Ident, get: &Ident) -> syn::Result<(TokenStream2, TokenStream2)> { + let mut sig = f.sig.clone(); + let args = name_arguments(&mut sig)?; + let ident = sig.ident.clone(); + let wrapped_ident = wrapped(&ident); + + let mut has_sig = sig.clone(); + has_sig.ident = wrapped_ident.clone(); + HeldAssoc { has, name }.visit_signature_mut(&mut has_sig); + let has_call = quote! { <::#name as #name>::#ident(#has::#get(self), #(#args),*) }; + let doc = + format!("Forwards [`{name}::{ident}`] to the held resource; a `#[loko::wraps]` implementation may replace it."); + let has_method = method(&has_sig, &has_call); + + let impl_call = quote! { ::#wrapped_ident(self, #(#args),*) }; + Ok((quote! { #[doc = #doc] #[doc(hidden)] #has_method }, method(&sig, &impl_call))) } -/// Implements `Has` for each `#[service(Service, …)]` on a field, so the struct is each of -/// those services, forwarding to the field. -#[proc_macro_derive(Services, attributes(service))] -pub fn derive_services(input: TokenStream) -> TokenStream { +/// Implements `Has` for each `#[provides(Resource, …)]` on a field, so the struct is each +/// of those resources, forwarding to the field. +#[proc_macro_derive(Provides, attributes(provides))] +pub fn derive_provides(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - match expand_services(&input) { + match expand_provides(&input) { Ok(tokens) => tokens.into(), Err(e) => e.to_compile_error().into(), } } -fn expand_services(input: &DeriveInput) -> syn::Result { +fn expand_provides(input: &DeriveInput) -> syn::Result { let Data::Struct(data) = &input.data else { - return Err(Error::new(input.span(), "#[derive(Services)] is for structs")); + return Err(Error::new(input.span(), "#[derive(Provides)] is for structs")); }; let name = &input.ident; let (impl_generics, ty_generics, _) = input.generics.split_for_impl(); @@ -155,24 +230,19 @@ fn expand_services(input: &DeriveInput) -> syn::Result { let index = syn::Index::from(i); let access = field.ident.as_ref().map_or_else(|| quote! { #index }, |ident| quote! { #ident }); let ty = &field.ty; - for attr in field.attrs.iter().filter(|a| a.path().is_ident("service")) { - let services = attr.parse_args_with(Punctuated::::parse_terminated)?; - for service in services { - let last = service.segments.last().ok_or_else(|| Error::new(service.span(), "a service path"))?; - let service_name = last.ident.clone(); - let mut has = service.clone(); - if let Some(last) = has.segments.last_mut() { - last.ident = format_ident!("Has{}", service_name); - } - let get = accessor(&service_name); - // A generic field type must be the service it provides. + for attr in field.attrs.iter().filter(|a| a.path().is_ident("provides")) { + let resources = attr.parse_args_with(Punctuated::::parse_terminated)?; + for resource in resources { + let (has, resource_name) = has_path(&resource)?; + let get = accessor(&resource_name); + // A generic field type must be the resource it provides. let mut generics = input.generics.clone(); - generics.make_where_clause().predicates.push(parse_quote! { #ty: #service }); + generics.make_where_clause().predicates.push(parse_quote! { #ty: #resource }); let where_clause = &generics.where_clause; impls.push(quote! { #[automatically_derived] impl #impl_generics #has for #name #ty_generics #where_clause { - type #service_name = #ty; + type #resource_name = #ty; #[inline] fn #get(&self) -> &#ty { &self.#access @@ -183,11 +253,72 @@ fn expand_services(input: &DeriveInput) -> syn::Result { } } if impls.is_empty() { - return Err(Error::new(input.span(), "no field has #[service(…)]")); + return Err(Error::new(input.span(), "no field has #[provides(…)]")); } Ok(quote! { #(#impls)* }) } +/// `field: Type`, the arguments of `#[wraps]`. +struct WrapsArgs { + field: Member, + ty: Type, +} + +impl Parse for WrapsArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let field = input.parse()?; + input.parse::()?; + Ok(Self { field, ty: input.parse()? }) + } +} + +/// Makes `impl Resource for Wrapper` a wrapper around a field: `#[wraps(inner: S)]`. The methods +/// written replace the resource's; the others forward to `self.inner`. A wrapper keeps the inner +/// resource's associated types. +#[proc_macro_attribute] +pub fn wraps(args: TokenStream, input: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as WrapsArgs); + let item = parse_macro_input!(input as ItemImpl); + match expand_wraps(&args, &item) { + Ok(tokens) => tokens.into(), + Err(e) => e.to_compile_error().into(), + } +} + +fn expand_wraps(args: &WrapsArgs, item: &ItemImpl) -> syn::Result { + let Some((None, resource, _)) = &item.trait_ else { + return Err(Error::new(item.span(), "#[wraps] goes on an `impl Resource for Wrapper`")); + }; + let (has, name) = has_path(resource)?; + let get = accessor(&name); + let mut methods = vec![]; + for impl_item in &item.items { + let ImplItem::Fn(f) = impl_item else { + return Err(Error::new( + impl_item.span(), + "a wrapper only replaces methods; it keeps the inner resource's types and constants", + )); + }; + let mut f = f.clone(); + f.sig.ident = wrapped(&f.sig.ident); + HeldAssoc { has: &has, name: &name }.visit_signature_mut(&mut f.sig); + methods.push(f.into_token_stream()); + } + let (impl_generics, _, where_clause) = item.generics.split_for_impl(); + let (attrs, self_ty, (field, ty)) = (&item.attrs, &item.self_ty, (&args.field, &args.ty)); + Ok(quote! { + #(#attrs)* + impl #impl_generics #has for #self_ty #where_clause { + type #name = #ty; + #[inline] + fn #get(&self) -> &#ty { + &self.#field + } + #(#methods)* + } + }) +} + #[cfg(test)] mod tests { use syn::parse::Parser; @@ -201,9 +332,25 @@ mod tests { } #[test] - fn services_parse() { + fn resources_parse() { let parser = Punctuated::::parse_terminated; let paths = parser.parse_str("Clock, loko::Random").unwrap(); assert_eq!(paths.len(), 2); + let (has, name) = has_path(&paths[1]).unwrap(); + assert_eq!( + (has.to_token_stream().to_string(), name.to_string()), + ("loko :: HasRandom".into(), "Random".into()) + ); + } + + #[test] + fn self_types_become_the_held_resources() { + let mut sig: Signature = parse_quote! { fn upload(&self) -> Result }; + let has: Path = parse_quote! { HasStore }; + HeldAssoc { has: &has, name: &format_ident!("Store") }.visit_signature_mut(&mut sig); + assert_eq!( + sig.output.to_token_stream().to_string(), + "-> Result < < < Self as HasStore > :: Store as Store > :: Upload >" + ); } } diff --git a/crates/loko-random/Cargo.toml b/crates/loko-random/Cargo.toml index dbbf1d8..da4c951 100644 --- a/crates/loko-random/Cargo.toml +++ b/crates/loko-random/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-random" -description = "loko's Random service: random numbers, with seeded, operating-system, and JavaScript sources" +description = "loko's Random resource: random numbers, with seeded, operating-system, and JavaScript sources" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-random/README.md b/crates/loko-random/README.md index 5975c64..0d29455 100644 --- a/crates/loko-random/README.md +++ b/crates/loko-random/README.md @@ -1,6 +1,6 @@ # loko-random -[loko](https://github.com/polythings/loko)'s `Random` service: random numbers, passed to code instead of drawn from a +[loko](https://github.com/polythings/loko)'s `Random` resource: random numbers, passed to code instead of drawn from a global source, so tests are repeatable and the same code runs natively, under WASI, and in browsers. - `Random`: `next_u64` and `fill`. diff --git a/crates/loko-random/src/lib.rs b/crates/loko-random/src/lib.rs index 8aab39d..4f119d8 100644 --- a/crates/loko-random/src/lib.rs +++ b/crates/loko-random/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Random` service: random numbers, passed to code instead of drawn from a global source, so +//! loko's `Random` resource: random numbers, passed to code instead of drawn from a global source, so //! tests are repeatable and the same code runs natively and in WebAssembly. //! //! - [`Random`]: `next_u64` and `fill`. @@ -29,7 +29,7 @@ pub use system::OsRandom; pub use testing::SeededRandom; /// Random numbers. -#[loko_macros::service] +#[loko_macros::resource] pub trait Random { /// A random number. fn next_u64(&self) -> u64; diff --git a/crates/loko-store/Cargo.toml b/crates/loko-store/Cargo.toml index f210a2b..f343554 100644 --- a/crates/loko-store/Cargo.toml +++ b/crates/loko-store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-store" -description = "loko's Store service: objects by URI, with conditional writes, multipart uploads, and prefix listing; a store in memory" +description = "loko's Store resource: objects by URI, with conditional writes, multipart uploads, and prefix listing; a store in memory" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-store/README.md b/crates/loko-store/README.md index e928a6f..07b0d6c 100644 --- a/crates/loko-store/README.md +++ b/crates/loko-store/README.md @@ -1,6 +1,6 @@ # loko-store -[loko](https://github.com/polythings/loko)'s `Store` service: objects by absolute URI, passed to code instead of +[loko](https://github.com/polythings/loko)'s `Store` resource: objects by absolute URI, passed to code instead of reached through a particular client, so the same code works over S3, a local directory, memory, or a JavaScript binding. @@ -9,6 +9,8 @@ binding. (missing objects are not an error). The futures aren't required to be `Send`, so a store can wrap JavaScript promises. - `MemoryStore`: objects in memory, for tests. Clones share the objects. +- Wrappers, each a `Store` around another: `CountingStore` counts calls, `ReadOnlyStore` refuses writes, and + `PrefixedStore` passes on only URIs under a prefix. They stack. - `Error`: `NotFound`, `Invalid`, `Unsupported`, and `Store` (the backend's error, as text). - [`loko-object-store`](https://crates.io/crates/loko-object-store) implements `Store` over an `object_store` client. diff --git a/crates/loko-store/src/counting.rs b/crates/loko-store/src/counting.rs new file mode 100644 index 0000000..05c3dac --- /dev/null +++ b/crates/loko-store/src/counting.rs @@ -0,0 +1,145 @@ +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::{HasStore, Listed, Result, Store}; + +/// How many calls of each kind a [`CountingStore`] passed on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct StoreCounts { + /// `get` calls. + pub get: u64, + /// `get_range` calls. + pub get_range: u64, + /// `exists` calls. + pub exists: u64, + /// `put` calls. + pub put: u64, + /// `put_if_absent` calls. + pub put_if_absent: u64, + /// `upload` calls (uploads started). + pub upload: u64, + /// `list` calls. + pub list: u64, + /// `delete` calls (each may delete several objects). + pub delete: u64, +} + +impl StoreCounts { + /// All calls. + pub fn total(&self) -> u64 { + self.get + self.get_range + self.exists + self.put + self.put_if_absent + self.upload + self.list + self.delete + } +} + +/// The calls of each kind, in [`StoreCounts`] order. +#[derive(Debug, Default)] +struct Counters([AtomicU64; 8]); + +/// A store that counts the calls it passes on to another: to show what reading a table costs, or to +/// check in a test that a cache saves requests. Clones share the counts. +#[derive(Clone, Debug, Default)] +pub struct CountingStore { + inner: S, + counters: Arc, +} + +impl CountingStore { + /// Counts the calls to `inner`, from zero. + pub fn new(inner: S) -> Self { + Self { inner, counters: Arc::default() } + } + + /// The calls so far. + pub fn counts(&self) -> StoreCounts { + let [get, get_range, exists, put, put_if_absent, upload, list, delete] = + self.counters.0.each_ref().map(|c| c.load(Ordering::Relaxed)); + StoreCounts { get, get_range, exists, put, put_if_absent, upload, list, delete } + } + + /// Starts the counts again from zero. + pub fn reset(&self) { + for counter in &self.counters.0 { + counter.store(0, Ordering::Relaxed); + } + } + + /// The store counted. + pub fn inner(&self) -> &S { + &self.inner + } + + fn count(&self, kind: usize) { + self.counters.0[kind].fetch_add(1, Ordering::Relaxed); + } +} + +#[loko_macros::wraps(inner: S)] +impl Store for CountingStore { + fn get(&self, uri: &str) -> impl Future>>> { + self.count(0); + self.inner.get(uri) + } + + fn get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>> { + self.count(1); + self.inner.get_range(uri, start, len) + } + + fn exists(&self, uri: &str) -> impl Future> { + self.count(2); + self.inner.exists(uri) + } + + fn put(&self, uri: &str, bytes: Vec) -> impl Future> { + self.count(3); + self.inner.put(uri, bytes) + } + + fn put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future> { + self.count(4); + self.inner.put_if_absent(uri, bytes) + } + + fn upload(&self, uri: &str) -> impl Future> { + self.count(5); + self.inner.upload(uri) + } + + fn list(&self, prefix: &str) -> impl Future>> { + self.count(6); + self.inner.list(prefix) + } + + fn delete(&self, uris: &[String]) -> impl Future> { + self.count(7); + self.inner.delete(uris) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{MemoryStore, now}; + + #[test] + fn calls_are_counted_by_kind() { + let store = CountingStore::new(MemoryStore::new()); + now(store.put("memory://t/a", b"abc".to_vec())).unwrap(); + now(store.get("memory://t/a")).unwrap(); + now(store.get_range("memory://t/a", 0, 1)).unwrap(); + now(store.get_range("memory://t/a", 1, 1)).unwrap(); + now(store.list("memory://t/")).unwrap(); + let shared = store.clone(); + assert_eq!( + shared.counts(), + StoreCounts { put: 1, get: 1, get_range: 2, list: 1, ..StoreCounts::default() }, + "clones share the counts" + ); + assert_eq!(store.counts().total(), 5); + store.reset(); + assert_eq!(store.counts(), StoreCounts::default()); + assert_eq!(store.inner().uris(), ["memory://t/a"]); + } +} diff --git a/crates/loko-store/src/lib.rs b/crates/loko-store/src/lib.rs index d602a7e..5bbb219 100644 --- a/crates/loko-store/src/lib.rs +++ b/crates/loko-store/src/lib.rs @@ -1,9 +1,11 @@ -//! loko's `Store` service: objects by URI, passed to code instead of reached through a particular +//! loko's `Store` resource: objects by URI, passed to code instead of reached through a particular //! client, so the same code works over S3, a local directory, memory, or a JavaScript binding. //! //! - [`Store`]: get, ranged get, exists, put, put-if-absent (a conditional write), multipart upload, //! list by prefix, delete. //! - [`MemoryStore`]: objects in memory, for tests. +//! - Wrappers, each a `Store` around another: [`CountingStore`] counts requests, [`ReadOnlyStore`] +//! refuses writes, and [`PrefixedStore`] keeps to URIs under a prefix. They stack. //! - `loko-object-store`: a `Store` over an `object_store` client (S3, GCS, Azure, local files). //! //! ``` @@ -23,10 +25,16 @@ //! # } //! ``` +mod counting; mod memory; +mod prefixed; +mod read_only; mod store; +pub use counting::{CountingStore, StoreCounts}; pub use memory::{MemoryStore, MemoryUpload}; +pub use prefixed::PrefixedStore; +pub use read_only::ReadOnlyStore; pub use store::{HasStore, Listed, Store, Upload}; /// An error of a store. @@ -49,3 +57,15 @@ pub enum Error { /// `Result` with [`Error`]. pub type Result = std::result::Result; + +/// Runs a future that is ready at once, as the memory store's are. +#[cfg(test)] +fn now(f: F) -> F::Output { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut f = std::pin::pin!(f); + loop { + if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + } +} diff --git a/crates/loko-store/src/memory.rs b/crates/loko-store/src/memory.rs index 0913a74..1e431c6 100644 --- a/crates/loko-store/src/memory.rs +++ b/crates/loko-store/src/memory.rs @@ -114,17 +114,7 @@ impl Store for MemoryStore { #[cfg(test)] mod tests { use super::*; - - /// Runs a future that is ready at once, as the memory store's are. - fn now(f: F) -> F::Output { - let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - let mut f = std::pin::pin!(f); - loop { - if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { - return v; - } - } - } + use crate::now; #[test] fn objects_round_trip() { diff --git a/crates/loko-store/src/prefixed.rs b/crates/loko-store/src/prefixed.rs new file mode 100644 index 0000000..b18ac99 --- /dev/null +++ b/crates/loko-store/src/prefixed.rs @@ -0,0 +1,104 @@ +use crate::{Error, HasStore, Listed, Result, Store}; + +/// A store that passes on only requests for URIs under a prefix (`s3://bucket/tenant-a/`), and +/// refuses the rest with [`Error::Invalid`]: to keep code to one table or one tenant's data. +/// `.` and `..` path segments are refused, so a URI can't climb out of the prefix. +#[derive(Clone, Debug, Default)] +pub struct PrefixedStore { + inner: S, + prefix: String, +} + +impl PrefixedStore { + /// `inner`, kept to URIs under `prefix`. A `/` is added to the prefix if it has none, so + /// `s3://bucket/a` doesn't let `s3://bucket/ab/…` through. + pub fn new(inner: S, prefix: impl Into) -> Self { + let mut prefix = prefix.into(); + if !prefix.ends_with('/') { + prefix.push('/'); + } + Self { inner, prefix } + } + + /// The prefix requests must be under. + pub fn prefix(&self) -> &str { + &self.prefix + } + + /// The store requests go to. + pub fn inner(&self) -> &S { + &self.inner + } + + fn check(&self, uri: &str) -> Result<()> { + match uri.strip_prefix(&self.prefix) { + Some(rest) if !rest.split('/').any(|segment| segment == "." || segment == "..") => Ok(()), + _ => Err(Error::Invalid(format!("{uri} is outside {}", self.prefix))), + } + } +} + +#[loko_macros::wraps(inner: S)] +impl Store for PrefixedStore { + async fn get(&self, uri: &str) -> Result>> { + self.check(uri)?; + self.inner.get(uri).await + } + + async fn get_range(&self, uri: &str, start: u64, len: u64) -> Result> { + self.check(uri)?; + self.inner.get_range(uri, start, len).await + } + + async fn exists(&self, uri: &str) -> Result { + self.check(uri)?; + self.inner.exists(uri).await + } + + async fn put(&self, uri: &str, bytes: Vec) -> Result<()> { + self.check(uri)?; + self.inner.put(uri, bytes).await + } + + async fn put_if_absent(&self, uri: &str, bytes: Vec) -> Result { + self.check(uri)?; + self.inner.put_if_absent(uri, bytes).await + } + + async fn upload(&self, uri: &str) -> Result { + self.check(uri)?; + self.inner.upload(uri).await + } + + async fn list(&self, prefix: &str) -> Result> { + self.check(prefix)?; + self.inner.list(prefix).await + } + + async fn delete(&self, uris: &[String]) -> Result<()> { + uris.iter().try_for_each(|uri| self.check(uri))?; + self.inner.delete(uris).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{MemoryStore, now}; + + #[test] + fn only_uris_under_the_prefix_pass() { + let store = PrefixedStore::new(MemoryStore::new(), "memory://wh/a"); + assert_eq!(store.prefix(), "memory://wh/a/"); + now(store.put("memory://wh/a/t/1", b"x".to_vec())).unwrap(); + assert!(now(store.exists("memory://wh/a/t/1")).unwrap()); + assert_eq!(now(store.list("memory://wh/a/t/")).unwrap().len(), 1); + for outside in ["memory://wh/b/t/1", "memory://wh/ab/t/1", "memory://wh/a/../b/t/1", "memory://wh/a/./t/1"] { + assert!(matches!(now(store.get(outside)), Err(Error::Invalid(_))), "{outside}"); + } + assert!(matches!(now(store.list("memory://wh/")), Err(Error::Invalid(_)))); + let delete = ["memory://wh/a/t/1".to_string(), "memory://wh/b/t/1".to_string()]; + assert!(matches!(now(store.delete(&delete)), Err(Error::Invalid(_)))); + assert_eq!(store.inner().uris(), ["memory://wh/a/t/1"], "a refused delete deletes nothing"); + } +} diff --git a/crates/loko-store/src/read_only.rs b/crates/loko-store/src/read_only.rs new file mode 100644 index 0000000..8d5f363 --- /dev/null +++ b/crates/loko-store/src/read_only.rs @@ -0,0 +1,65 @@ +use std::future::{Future, ready}; + +use crate::{Error, HasStore, Result, Store}; + +/// A store that reads through to another and refuses writes with [`Error::Unsupported`]: for a +/// viewer that must not change the tables it opens. +#[derive(Clone, Debug, Default)] +pub struct ReadOnlyStore { + inner: S, +} + +impl ReadOnlyStore { + /// Reads from `inner`, never writes. + pub fn new(inner: S) -> Self { + Self { inner } + } + + /// The store read. + pub fn inner(&self) -> &S { + &self.inner + } +} + +fn refused(uri: &str) -> Error { + Error::Unsupported(format!("{uri}: the store is read-only")) +} + +#[loko_macros::wraps(inner: S)] +impl Store for ReadOnlyStore { + fn put(&self, uri: &str, _bytes: Vec) -> impl Future> { + ready(Err(refused(uri))) + } + + fn put_if_absent(&self, uri: &str, _bytes: Vec) -> impl Future> { + ready(Err(refused(uri))) + } + + fn upload(&self, uri: &str) -> impl Future> { + ready(Err(refused(uri))) + } + + fn delete(&self, uris: &[String]) -> impl Future> { + ready(Err(refused(uris.first().map_or("", String::as_str)))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{MemoryStore, now}; + + #[test] + fn reads_pass_and_writes_are_refused() { + let memory = MemoryStore::new(); + now(memory.put("memory://t/a", b"abc".to_vec())).unwrap(); + let store = ReadOnlyStore::new(memory.clone()); + assert_eq!(now(store.get("memory://t/a")).unwrap().as_deref(), Some(&b"abc"[..])); + assert_eq!(now(store.list("memory://t/")).unwrap().len(), 1); + assert!(matches!(now(store.put("memory://t/b", vec![])), Err(Error::Unsupported(_)))); + assert!(matches!(now(store.put_if_absent("memory://t/b", vec![])), Err(Error::Unsupported(_)))); + assert!(matches!(now(store.upload("memory://t/b")), Err(Error::Unsupported(_)))); + assert!(matches!(now(store.delete(&["memory://t/a".into()])), Err(Error::Unsupported(_)))); + assert_eq!(memory.uris(), ["memory://t/a"], "nothing changed"); + } +} diff --git a/crates/loko-store/src/store.rs b/crates/loko-store/src/store.rs index ee10047..b8395ec 100644 --- a/crates/loko-store/src/store.rs +++ b/crates/loko-store/src/store.rs @@ -15,7 +15,7 @@ pub struct Listed { /// Paths are absolute URIs, as table metadata records them. /// /// The futures aren't required to be `Send`, so a store can wrap JavaScript promises. -#[loko_macros::service] +#[loko_macros::resource] pub trait Store { /// A multipart upload in progress. type Upload: Upload; diff --git a/crates/loko/Cargo.toml b/crates/loko/Cargo.toml index 43de14c..89fb84e 100644 --- a/crates/loko/Cargo.toml +++ b/crates/loko/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko" -description = "Services passed to code instead of reached for: Clock, Random, Env, Console, and Store, with test, native, and JavaScript implementations" +description = "Resources passed to code instead of reached for: Clock, Random, Env, Console, and Store, with test, native, and JavaScript implementations" readme = "README.md" version.workspace = true edition.workspace = true @@ -20,7 +20,7 @@ tokio = ["getrandom", "loko-clock/tokio"] js = ["loko-clock/js", "loko-random/js", "loko-console/js"] # `Random` for a `Mutex` around any `rand_core` generator. rand = ["loko-random/rand"] -# `loko::store`: the `Store` service and `MemoryStore`. +# `loko::store`: the `Store` resource and `MemoryStore`. store = ["dep:loko-store"] # `loko::object_store`: a `Store` over an `object_store` client. object-store = ["store", "dep:loko-object-store"] diff --git a/crates/loko/README.md b/crates/loko/README.md index 431314e..db98769 100644 --- a/crates/loko/README.md +++ b/crates/loko/README.md @@ -1,6 +1,6 @@ # loko -Services for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed +Resources for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed to code as traits instead of reached for as globals, with test, system, and JavaScript implementations, and platforms that bundle one of each. @@ -9,8 +9,8 @@ use std::time::Duration; use loko::{Clock, Random, TestPlatform}; -fn id(services: &(impl Clock + Random)) -> String { - format!("{}-{:04x}", services.now_ms(), services.next_u64() & 0xffff) +fn id(resources: &(impl Clock + Random)) -> String { + format!("{}-{:04x}", resources.now_ms(), resources.next_u64() & 0xffff) } let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program @@ -24,10 +24,10 @@ assert_eq!(id(&platform), "1000-cdaf"); | `tokio` | `TokioClock` and `TokioPlatform` (native) | | `js` | `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown`) | | `rand` | `Random` for a `Mutex` around a `rand_core` generator | -| `store` | `loko::store`: the `Store` service and `MemoryStore` | +| `store` | `loko::store`: the `Store` resource and `MemoryStore` | | `object-store` | `loko::object_store`: a `Store` over an `object_store` client | -`#[loko::service]` makes your own traits services and `#[derive(loko::Services)]` bundles them with loko's. The +`#[loko::resource]` makes your own traits resources and `#[derive(loko::Provides)]` bundles them with loko's. The [repository](https://github.com/polythings/loko) has the details. Apache License 2.0. diff --git a/crates/loko/src/lib.rs b/crates/loko/src/lib.rs index eb52732..b5591dd 100644 --- a/crates/loko/src/lib.rs +++ b/crates/loko/src/lib.rs @@ -1,14 +1,14 @@ -//! Services passed to code instead of reached for: the time, randomness, environment variables, the -//! console, and object storage. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the +//! Resources passed to code instead of reached for: the time, randomness, environment variables, +//! the console, and object storage. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the //! program passes a platform, and tests pass a [`TestPlatform`] they control. The same code runs //! natively and in WebAssembly. //! -//! - Services: [`clock`], [`random`], [`env`], [`console`], and `store` (feature `store`), each a +//! - Resources: [`clock`], [`random`], [`env`](mod@env), [`console`], and `store` (feature `store`), each a //! trait with test, native, and JavaScript implementations. The traits are also at the root. //! - Platforms: [`TestPlatform`], `StdPlatform`, `TokioPlatform`, and `JsPlatform` bundle one of each. -//! - [`service`] makes a trait a service, and [`Services`] makes a struct each service its fields -//! provide, so new services and new bundles work like loko's own. [`prelude`] brings in the -//! services with the `HasClock`-style traits the derive names. +//! - [`resource`] makes a trait a resource, [`Provides`] makes a struct each resource its fields +//! provide, and [`wraps`] makes a wrapper that changes some of a resource's methods and forwards +//! the rest. [`prelude`] brings in the resources with the `HasClock`-style traits these name. //! //! ``` //! use std::time::Duration; @@ -16,8 +16,8 @@ //! use loko::{Clock, Random, TestPlatform}; //! //! /// An ID from the time and a random suffix. -//! fn id(services: &(impl Clock + Random)) -> String { -//! format!("{}-{:04x}", services.now_ms(), services.next_u64() & 0xffff) +//! fn id(resources: &(impl Clock + Random)) -> String { +//! format!("{}-{:04x}", resources.now_ms(), resources.next_u64() & 0xffff) //! } //! //! let platform = TestPlatform::default(); @@ -25,18 +25,18 @@ //! assert_eq!(id(&platform), "1000-cdaf"); //! ``` //! -//! A program bundles the services it needs, its own among them: +//! A program bundles the resources it needs, its own among them: //! //! ``` //! # #[cfg(feature = "store")] { //! use loko::prelude::*; //! use loko::store::MemoryStore; //! -//! #[derive(Services)] +//! #[derive(Provides)] //! struct App { -//! #[service(Store)] +//! #[provides(Store)] //! store: MemoryStore, -//! #[service(Clock, Random)] +//! #[provides(Clock, Random)] //! platform: TestPlatform, //! } //! @@ -52,7 +52,7 @@ pub use loko_console as console; pub use loko_console::{Console, HasConsole}; pub use loko_env as env; pub use loko_env::{Env, HasEnv}; -pub use loko_macros::{Services, service}; +pub use loko_macros::{Provides, resource, wraps}; #[cfg(feature = "object-store")] pub use loko_object_store as object_store; pub use loko_random as random; @@ -64,8 +64,8 @@ pub use loko_store::{HasStore, Store}; mod platform; -/// The services, the `Has` traits that `#[derive(Services)]` names next to them, the macros, and -/// the platforms. +/// The resources, the `Has` traits that `#[derive(Provides)]` and `#[wraps]` name next to them, the +/// macros, and the platforms. pub mod prelude { #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] pub use crate::JsPlatform; @@ -74,7 +74,7 @@ pub mod prelude { #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] pub use crate::TokioPlatform; pub use crate::{ - Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Random, Services, TestPlatform, service, + Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Provides, Random, TestPlatform, resource, wraps, }; #[cfg(feature = "store")] pub use crate::{HasStore, Store}; diff --git a/crates/loko/src/platform.rs b/crates/loko/src/platform.rs index 68e6aa6..6962e8d 100644 --- a/crates/loko/src/platform.rs +++ b/crates/loko/src/platform.rs @@ -1,29 +1,29 @@ -//! Platforms: one of each service, ready to pass. A platform is each service it holds; a program -//! that needs more (a `Store`, its own services) puts a platform in a `#[derive(Services)]` struct. +//! Platforms: one of each resource, ready to pass. A platform is each resource it holds; a program +//! that needs more (a `Store`, its own resources) puts a platform in a `#[derive(Provides)]` struct. use crate::clock::{Clock, TestClock}; use crate::console::{Console, TestConsole}; use crate::env::{Env, MapEnv}; use crate::random::{Random, SeededRandom}; -use crate::{HasClock, HasConsole, HasEnv, HasRandom, Services}; +use crate::{HasClock, HasConsole, HasEnv, HasRandom, Provides}; -/// Services for tests: a [`TestClock`] at the Unix epoch, a [`SeededRandom`] with seed 0, an empty +/// Resources for tests: a [`TestClock`] at the Unix epoch, a [`SeededRandom`] with seed 0, an empty /// [`MapEnv`], and a [`TestConsole`] that records lines. Clones share the clock, the generator, and /// the console; the fields are public so a test can set them up and look at them. -#[derive(Clone, Debug, Default, Services)] +#[derive(Clone, Debug, Default, Provides)] #[non_exhaustive] pub struct TestPlatform { /// The time, moved by the test. - #[service(Clock)] + #[provides(Clock)] pub clock: TestClock, /// Random numbers, the same on every run. - #[service(Random)] + #[provides(Random)] pub random: SeededRandom, /// Environment variables set by the test. - #[service(Env)] + #[provides(Env)] pub env: MapEnv, /// The lines printed. - #[service(Console)] + #[provides(Console)] pub console: TestConsole, } @@ -55,66 +55,66 @@ impl TestPlatform { } } -/// The system's services for programs without an async runtime (feature `getrandom`, native +/// The system's resources for programs without an async runtime (feature `getrandom`, native /// targets and WASI): [`StdClock`](crate::clock::StdClock), whose `sleep` blocks the thread, /// [`OsRandom`](crate::random::OsRandom), [`SystemEnv`](crate::env::SystemEnv), and /// [`StdConsole`](crate::console::StdConsole). #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] -#[derive(Clone, Copy, Debug, Default, Services)] +#[derive(Clone, Copy, Debug, Default, Provides)] #[non_exhaustive] pub struct StdPlatform { /// The system clock. - #[service(Clock)] + #[provides(Clock)] pub clock: crate::clock::StdClock, /// The operating system's generator. - #[service(Random)] + #[provides(Random)] pub random: crate::random::OsRandom, /// The process's environment variables. - #[service(Env)] + #[provides(Env)] pub env: crate::env::SystemEnv, /// Standard output and standard error. - #[service(Console)] + #[provides(Console)] pub console: crate::console::StdConsole, } -/// The system's services under tokio (feature `tokio`, native targets): [`StdPlatform`] with +/// The system's resources under tokio (feature `tokio`, native targets): [`StdPlatform`] with /// [`TokioClock`](crate::clock::TokioClock), whose `sleep` is tokio's timer. #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] -#[derive(Clone, Copy, Debug, Default, Services)] +#[derive(Clone, Copy, Debug, Default, Provides)] #[non_exhaustive] pub struct TokioPlatform { /// The system clock and tokio's timer. - #[service(Clock)] + #[provides(Clock)] pub clock: crate::clock::TokioClock, /// The operating system's generator. - #[service(Random)] + #[provides(Random)] pub random: crate::random::OsRandom, /// The process's environment variables. - #[service(Env)] + #[provides(Env)] pub env: crate::env::SystemEnv, /// Standard output and standard error. - #[service(Console)] + #[provides(Console)] pub console: crate::console::StdConsole, } -/// The JavaScript host's services (feature `js`, `wasm32-unknown-unknown`): [`JsClock`](crate::clock::JsClock), +/// The JavaScript host's resources (feature `js`, `wasm32-unknown-unknown`): [`JsClock`](crate::clock::JsClock), /// [`JsRandom`](crate::random::JsRandom), a [`MapEnv`] (JavaScript hosts have no common environment; /// set the variables the program reads), and [`JsConsole`](crate::console::JsConsole). #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -#[derive(Clone, Debug, Default, Services)] +#[derive(Clone, Debug, Default, Provides)] #[non_exhaustive] pub struct JsPlatform { /// `Date.now` and `setTimeout`. - #[service(Clock)] + #[provides(Clock)] pub clock: crate::clock::JsClock, /// `crypto.getRandomValues`. - #[service(Random)] + #[provides(Random)] pub random: crate::random::JsRandom, /// Variables set by the program. - #[service(Env)] + #[provides(Env)] pub env: MapEnv, /// `console.log` and `console.error`. - #[service(Console)] + #[provides(Console)] pub console: crate::console::JsConsole, } @@ -125,7 +125,7 @@ mod tests { use super::*; #[test] - fn a_test_platform_is_each_service() { + fn a_test_platform_is_each_resource() { let platform = TestPlatform::new().at_ms(1_000).seeded(0).with_var("REGION", "us-east-2"); platform.clock.advance(Duration::from_millis(500)); assert_eq!(platform.now_ms(), 1_500); diff --git a/crates/loko/tests/services.rs b/crates/loko/tests/resources.rs similarity index 51% rename from crates/loko/tests/services.rs rename to crates/loko/tests/resources.rs index 7aac391..6a2cf02 100644 --- a/crates/loko/tests/services.rs +++ b/crates/loko/tests/resources.rs @@ -1,18 +1,18 @@ -//! Services and bundles defined outside loko work like loko's own. +//! Resources and bundles defined outside loko work like loko's own. use std::time::Duration; use loko::clock::TestClock; use loko::prelude::*; -/// A service of this crate's own. -#[loko::service] +/// A resource of this crate's own. +#[loko::resource] trait Greeter { /// A greeting for `name`. fn greet(&self, name: &str) -> String; } -/// A greeter that needs other services: the clock for the time of day. +/// A greeter that needs other resources: the clock for the time of day. struct ClockGreeter { clock: C, } @@ -25,22 +25,22 @@ impl Greeter for ClockGreeter { } /// A bundle of a platform and the greeter, generic over the greeter. -#[derive(Services)] +#[derive(Provides)] struct App { - #[service(Clock, Random, Env, Console)] + #[provides(Clock, Random, Env, Console)] platform: TestPlatform, - #[service(Greeter)] + #[provides(Greeter)] greeter: G, } -/// Code that needs a mix of loko's services and this crate's. +/// Code that needs a mix of loko's resources and this crate's. fn welcome(app: &(impl Greeter + Console + Env)) { let name = app.var("USER").unwrap_or_else(|| "stranger".into()); app.print_line(&app.greet(&name)).expect("a test console doesn't fail"); } #[test] -fn own_services_bundle_with_loko_services() { +fn own_resources_bundle_with_loko_resources() { let platform = TestPlatform::new().with_var("USER", "Ada"); let greeter = ClockGreeter { clock: platform.clock.clone() }; let app = App { platform: platform.clone(), greeter }; @@ -52,8 +52,8 @@ fn own_services_bundle_with_loko_services() { } /// Tuple structs bundle too. -#[derive(Services)] -struct Pair(#[service(Clock)] TestPlatform, #[service(Greeter)] ClockGreeter); +#[derive(Provides)] +struct Pair(#[provides(Clock)] TestPlatform, #[provides(Greeter)] ClockGreeter); #[test] fn tuple_bundles_forward() { @@ -69,17 +69,17 @@ mod store { use super::*; - #[derive(Clone, Services)] + #[derive(Clone, Provides)] struct Stored { - #[service(Store)] + #[provides(Store)] store: MemoryStore, - #[service(Clock)] + #[provides(Clock)] platform: TestPlatform, } /// Async methods, an associated type, and default methods forward. #[test] - fn async_services_forward() { + fn async_resources_forward() { let app = Stored { store: MemoryStore::new(), platform: TestPlatform::new().at_ms(42) }; let run = async { let uri = format!("memory:///logs/{}", app.now_ms()); @@ -94,10 +94,63 @@ mod store { }; let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); let std::task::Poll::Ready((listed, got)) = std::pin::pin!(run).poll(&mut cx) else { - panic!("memory and test services are ready at once"); + panic!("memory and test resources are ready at once"); }; assert_eq!(listed, ["memory:///big", "memory:///logs/42"]); assert_eq!(got.as_deref(), Some(&b"a"[..])); assert_eq!(app.platform.now_ms(), 1_042); } } + +/// A console that shouts its standard output and passes standard error on as it is. +struct Shouting { + inner: C, +} + +#[loko::wraps(inner: C)] +impl Console for Shouting { + fn print_line(&self, line: &str) -> std::io::Result<()> { + self.inner.print_line(&line.to_uppercase()) + } +} + +/// A greeter around another, adding an exclamation mark. +struct Excited(G); + +#[loko::wraps(0: G)] +impl Greeter for Excited { + fn greet(&self, name: &str) -> String { + format!("{}!", self.0.greet(name)) + } +} + +#[test] +fn wrappers_replace_some_methods_and_forward_the_rest() { + let platform = TestPlatform::new(); + let console = Shouting { inner: platform.console.clone() }; + console.print_line("quiet").unwrap(); + console.print_error_line("as it is").unwrap(); + assert_eq!(platform.console.lines(), ["QUIET"]); + assert_eq!(platform.console.error_lines(), ["as it is"]); + + let greeter = Excited(ClockGreeter { clock: platform.clock.clone() }); + assert_eq!(greeter.greet("Ada"), "Good morning, Ada!"); +} + +#[cfg(feature = "store")] +#[test] +fn store_wrappers_stack() { + use loko::store::{CountingStore, MemoryStore, PrefixedStore, ReadOnlyStore}; + + let memory = MemoryStore::new(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut run = |f: std::pin::Pin<&mut dyn Future>| f.poll(&mut cx).is_ready(); + assert!(run(std::pin::pin!(async { memory.put("memory://wh/a/t", b"x".to_vec()).await.is_ok() }))); + + let counted = CountingStore::new(ReadOnlyStore::new(PrefixedStore::new(memory.clone(), "memory://wh/a/"))); + assert!(run(std::pin::pin!(async { counted.get("memory://wh/a/t").await.unwrap().is_some() }))); + assert!(run(std::pin::pin!(async { counted.get("memory://wh/b/t").await.is_err() }))); + assert!(run(std::pin::pin!(async { counted.put("memory://wh/a/u", vec![]).await.is_err() }))); + assert_eq!((counted.counts().get, counted.counts().put), (2, 1)); + assert_eq!(memory.uris(), ["memory://wh/a/t"]); +} diff --git a/spec/0001-resources.md b/spec/0001-resources.md new file mode 100644 index 0000000..a6c6634 --- /dev/null +++ b/spec/0001-resources.md @@ -0,0 +1,76 @@ +--- +number: 1 +title: Resources, Provides, and wrappers +status: done +updated: 2026-10-21 +versions: [unreleased] +related: [2, 3, 4, 5, 6, 7] +issues: [1, 2, 4, 7] +--- + +# 1. Resources, Provides, and wrappers + +**Done** · updated 2026-10-21 · versions unreleased · milestone: [0.2](milestones/v0-2.md) + +## Summary + +A resource is a trait for something code would otherwise reach for as a global: the time, random numbers, +environment variables, the console, object storage. Code takes the resources it needs as `&impl Trait`, the program +passes the system's, and tests pass ones they control. `#[resource]`, `#[derive(Provides)]`, and `#[wraps]` let +anyone define new resources, bundle them with loko's, and wrap one implementation in another. + +## Design + +- **A resource** is a trait marked `#[loko_macros::resource]` (`#[loko::resource]` through the `loko` crate). Its + methods take `&self`; async ones return `impl Future` with no `Send` bound, so an implementation can wrap JavaScript + promises. The trait can't be generic; it can have associated types, constants, and provided methods. +- **`#[resource]` on `trait X`** adds: + - `trait HasX { type X: X; fn x(&self) -> &Self::X; … }` (the accessor is the trait's name in snake case), with + a hidden method `wrapped_m` for each method `m` of `X` that forwards to the held resource by default, and + - `impl X for T`, whose methods call the `wrapped_` ones, and whose associated types and constants are the + held resource's. Provided methods forward too, so an implementation's overrides are kept. +- **`#[derive(Provides)]`** on a struct implements `HasX` for each `#[provides(X, …)]` on a field, with the field's + type as the held resource (and `FieldType: X` as a bound, so generic fields work). Named and tuple fields both work. + A field can provide several resources, such as a platform providing `Clock, Random`. +- **Paths:** the derive names `HasX` next to the path given: `#[provides(loko::Clock)]` uses `loko::HasClock`, and a + bare `#[provides(Clock)]` needs `HasClock` in scope. `loko::prelude` brings in each resource with its `HasX`. +- **`#[wraps(field: Type)]`** on `impl X for Wrapper` implements `HasX` instead, with `field` (a name or a tuple + index) of type `Type` as the held resource. The methods written become the `wrapped_` methods, replacing the + forwarding ones; `Self::Assoc` in their signatures means the held resource's. A wrapper keeps the held resource's + associated types and constants, and a provided method it doesn't write forwards to the held one, so a wrapper writes + the provided methods that depend on what it changes (`OffsetClock` writes `now_ms` as well as `now`). Wrappers + stack: `CountingStore>`. +- **Composition:** an implementation of one resource can hold others (a greeter that holds a clock), and a program's + bundle can mix loko's resources with its own. Code asks only for what it uses: `&(impl Store + Clock)`. +- **Crates:** one per resource (`loko-clock`, `loko-random`, `loko-env`, `loko-console`, `loko-store`), each with its + trait and implementations; `loko-object-store`; `loko-macros`; and `loko`, which re-exports them + (`loko::clock`, …; the traits also at the root), adds the platforms ([spec 7](0007-platforms.md)), and has a feature + per optional part. A library that needs one resource depends on its crate alone. +- **Targets:** every crate builds for native targets, `wasm32-unknown-unknown`, and `wasm32-wasip2`. JavaScript + implementations need feature `js` and `wasm32-unknown-unknown`; system ones are on every target with a system + (native and WASI). + +## Decisions + +| ID | Decision | Status | +|---|---|---| +| D1 | A crate per resource, re-exported by `loko`, so a library takes only the resource it needs. | Accepted | +| D2 | Futures aren't `Send`: resources can wrap JavaScript promises; multi-threaded executors are an open question (#2). | Accepted | +| D3 | `#[resource]` adds `HasX` and a blanket `impl X for T`; the derive implements `HasX` per field attribute. A resource implemented by hand and one held in a bundle look the same to callers. | Accepted | +| D4 | The derive names `HasX` next to the path given, and `loko::prelude` brings each resource in with its `HasX`, rather than generating macros named after the traits. | Accepted | +| D5 | The crate is `loko`, at 0.2.0: the name's owner already published an unrelated 0.1.0 in 2022. | Accepted | +| D16 | The things passed are called resources (`#[resource]`), not services: "service" means a request handler in Rust (tower) and a server elsewhere, and a resource is what Bevy calls its shared `Time`. | Accepted | +| D17 | The derive is `Provides`, with `#[provides(X, …)]` on fields: a field provides resources. | Accepted | +| D18 | Wrappers implement `HasX` through `#[wraps]`, overriding hidden `wrapped_` methods, so they write only what they change. The forwarding lives in `HasX`, whose signatures resolve where the trait is defined, rather than in generated macros that would resolve them at the wrapper. | Accepted | + +## Open questions and issues + +- [#1](https://github.com/polythings/loko/issues/1) Publish 0.2.0 to crates.io +- [#2](https://github.com/polythings/loko/issues/2) Send futures for multi-threaded executors +- [#4](https://github.com/polythings/loko/issues/4) Which resources come next? +- [#7](https://github.com/polythings/loko/issues/7) Layers: stacking wrappers in reading order + +## History + +- 2026-10-20: built for 0.2, as services with `#[derive(Services)]`. +- 2026-10-21: services renamed resources (D16), the derive `Provides` (D17); `#[wraps]` added (D18). diff --git a/spec/0001-services.md b/spec/0001-services.md deleted file mode 100644 index 66649a3..0000000 --- a/spec/0001-services.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -number: 1 -title: Services and the derive -status: done -updated: 2026-10-20 -versions: [unreleased] -related: [2, 3, 4, 5, 6, 7] -issues: [1, 2, 4] ---- - -# 1. Services and the derive - -**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) - -## Summary - -A service is a trait for something code would otherwise reach for as a global: the time, random numbers, -environment variables, the console, object storage. Code takes the services it needs as `&impl Trait`, the program -passes the system's, and tests pass ones they control. `#[service]` and `#[derive(Services)]` let anyone define new -services and bundle them with loko's. - -## Design - -- **A service** is a trait marked `#[loko_macros::service]` (`#[loko::service]` through the `loko` crate). Its - methods take `&self`; async ones return `impl Future` with no `Send` bound, so an implementation can wrap JavaScript - promises. The trait can't be generic; it can have associated types, constants, and provided methods. -- **`#[service]` on `trait X`** adds: - - `trait HasX { type X: X; fn x(&self) -> &Self::X; }` (the accessor is the trait's name in snake case), and - - `impl X for T`, forwarding every method (provided ones too, so an implementation's overrides are kept), - associated type, and constant to the held service. -- **`#[derive(Services)]`** on a struct implements `HasX` for each `#[service(X, …)]` on a field, with the field's - type as the held service (and `FieldType: X` as a bound, so generic fields work). Named and tuple fields both work. - A field can provide several services, such as a platform providing `Clock, Random`. -- **Paths:** the derive names `HasX` next to the path given: `#[service(loko::Clock)]` uses `loko::HasClock`, and a - bare `#[service(Clock)]` needs `HasClock` in scope. `loko::prelude` brings in each service with its `HasX`. -- **Composition:** an implementation of one service can hold others (a greeter that holds a clock), and a program's - bundle can mix loko's services with its own. Code asks only for what it uses: `&(impl Store + Clock)`. -- **Crates:** one per service (`loko-clock`, `loko-random`, `loko-env`, `loko-console`, `loko-store`), each with its - trait and implementations; `loko-object-store`; `loko-macros`; and `loko`, which re-exports them - (`loko::clock`, …; the traits also at the root), adds the platforms ([spec 7](0007-platforms.md)), and has a feature - per optional part. A library that needs one service depends on its crate alone. -- **Targets:** every crate builds for native targets, `wasm32-unknown-unknown`, and `wasm32-wasip2`. JavaScript - implementations need feature `js` and `wasm32-unknown-unknown`; system ones are on every target with a system - (native and WASI). - -## Decisions - -| ID | Decision | Status | -|---|---|---| -| D1 | A crate per service, re-exported by `loko`, so a library takes only the service it needs. | Accepted | -| D2 | Futures aren't `Send`: services can wrap JavaScript promises; multi-threaded executors are an open question (#2). | Accepted | -| D3 | `#[service]` adds `HasX` and a blanket `impl X for T`; the derive implements `HasX` per field attribute. A service implemented by hand and one held in a bundle look the same to callers. | Accepted | -| D4 | The derive names `HasX` next to the path given, and `loko::prelude` brings each service in with its `HasX`, rather than generating macros named after the traits. | Accepted | -| D5 | The crate is `loko`, at 0.2.0: the name's owner already published an unrelated 0.1.0 in 2022. | Accepted | - -## Open questions and issues - -- [#1](https://github.com/polythings/loko/issues/1) Publish 0.2.0 to crates.io -- [#2](https://github.com/polythings/loko/issues/2) Send futures for multi-threaded executors -- [#4](https://github.com/polythings/loko/issues/4) Which services come next? - -## History - -- 2026-10-20: built for 0.2. diff --git a/spec/0002-clock.md b/spec/0002-clock.md index eb6dfb2..eca3e5c 100644 --- a/spec/0002-clock.md +++ b/spec/0002-clock.md @@ -2,7 +2,7 @@ number: 2 title: Clock status: done -updated: 2026-10-20 +updated: 2026-10-21 versions: [unreleased] related: [1, 7] issues: [] @@ -10,7 +10,7 @@ issues: [] # 2. Clock -**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) +**Done** · updated 2026-10-21 · versions unreleased · milestone: [0.2](milestones/v0-2.md) ## Summary @@ -27,6 +27,9 @@ and skip the waits. - **`StdClock`:** `SystemTime::now`, and a `sleep` that blocks the thread before returning a ready future: for programs without an async runtime (native and WASI). - **`TokioClock`** (feature `tokio`, native): `SystemTime::now` and `tokio::time::sleep`. +- **`OffsetClock`:** a wrapper ([spec 1](0001-resources.md)): another clock, `ahead` or `behind` by a fixed + `Duration`, for testing code against clock skew. It writes `now_ms` as well as `now`; sleeping is the other clock's. +- **`to_ms(SystemTime) -> i64`:** what `now_ms` computes, for wrappers that change `now`. - **`JsClock`** (feature `js`, `wasm32-unknown-unknown`): `Date.now` and a promise around `setTimeout` (resolved at once on a host without `setTimeout`). @@ -45,3 +48,4 @@ None. ## History - 2026-10-20: built for 0.2. +- 2026-10-21: `OffsetClock` and `to_ms` added. diff --git a/spec/0003-random.md b/spec/0003-random.md index e91894b..eb5aae3 100644 --- a/spec/0003-random.md +++ b/spec/0003-random.md @@ -26,7 +26,7 @@ every run. - **`JsRandom`** (feature `js`, `wasm32-unknown-unknown`): `crypto.getRandomValues` in chunks of at most 65,536 bytes, falling back to `Math.random` on a host without Web Crypto. - **Feature `rand`:** `Mutex` is a `Random` for any `rand_core::Rng`, so a program's existing generator works as a - service. + resource. ## Decisions diff --git a/spec/0006-store.md b/spec/0006-store.md index aa05aec..7ef9502 100644 --- a/spec/0006-store.md +++ b/spec/0006-store.md @@ -27,6 +27,11 @@ read and write through it. - **`Error`:** `NotFound`, `Invalid`, `Unsupported`, and `Store` (the backend's error, as text); `non_exhaustive`. Libraries with their own error type convert from it. - **`MemoryStore`:** objects in memory, for tests. Clones share the objects. +- **Wrappers** ([spec 1](0001-resources.md)), each a `Store` around another; they stack: + - `CountingStore`: counts the calls of each kind it passes on (`counts()`, `reset()`); clones share the counts. + - `ReadOnlyStore`: reads through; `put`, `put_if_absent`, `upload`, and `delete` return `Unsupported`. + - `PrefixedStore`: passes on only URIs under its prefix (a `/` is added if missing), refusing the rest and any `.` + or `..` segment with `Invalid`. A `delete` with one URI outside deletes nothing. - **`ObjectStoreAdapter`** (`loko-object-store`): a `Store` over any `object_store` client, from a client and a root URI or from a URL with options (the root keeps the URL's port and drops any credentials). Put-if-absent is `PutMode::Create`. An empty range, or one starting past the end, reads as no bytes, as in `MemoryStore`. Listing a @@ -48,4 +53,4 @@ read and write through it. - 2026-10-20: built for 0.2. Moved from lakekit (`lakekit-core`'s `Store` and `MemoryStore`, and `lakekit-object-store`). - 2026-10-21: review fixes from lakekit#12: `from_url` keeps the port, empty and past-the-end ranges read as no bytes, - and listing starts from an offset. + and listing starts from an offset. Wrappers `CountingStore`, `ReadOnlyStore`, and `PrefixedStore` added. diff --git a/spec/0007-platforms.md b/spec/0007-platforms.md index b9a7fe5..050dd2e 100644 --- a/spec/0007-platforms.md +++ b/spec/0007-platforms.md @@ -14,9 +14,9 @@ issues: [3] ## Summary -A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, ready to pass: the test services, the +A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, ready to pass: the test resources, the system's, the system's under tokio, or a JavaScript host's. A program that needs more puts a platform in its own -`#[derive(Services)]` bundle. +`#[derive(Provides)]` bundle. ## Design @@ -27,9 +27,9 @@ system's, the system's under tokio, or a JavaScript host's. A program that needs | `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native (feature `tokio`) | | `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | `wasm32-unknown-unknown` (feature `js`) | -- Each platform is a struct with public fields, one per service, built with `#[derive(Services)]`, so it is each - service and a test can set up and inspect the parts (`platform.clock.advance(…)`, `platform.console.lines()`). -- The structs are `non_exhaustive`, so a service added later isn't a breaking change; they are built with `default()` +- Each platform is a struct with public fields, one per resource, built with `#[derive(Provides)]`, so it is each + resource and a test can set up and inspect the parts (`platform.clock.advance(…)`, `platform.console.lines()`). +- The structs are `non_exhaustive`, so a resource added later isn't a breaking change; they are built with `default()` (and `TestPlatform`'s `at_ms`, `seeded`, and `with_var`). - `Store` is not in a platform: which store a program uses is its own choice. diff --git a/spec/README.md b/spec/README.md index 6eedac0..95d789a 100644 --- a/spec/README.md +++ b/spec/README.md @@ -18,7 +18,7 @@ loko's design, laid out like lakekit's, Timelake's, and lakeglass's. Start here, ## Writing and changing a spec -- **When:** anything beyond a small fix gets a spec, or a change to one, agreed before it is built. A new service is +- **When:** anything beyond a small fix gets a spec, or a change to one, agreed before it is built. A new resource is a new spec. - **New spec:** copy [TEMPLATE.md](TEMPLATE.md) to `NNNN-short-name.md` with the next free number (numbers are never reused), and add it to a milestone and to the index below. @@ -29,12 +29,12 @@ loko's design, laid out like lakekit's, Timelake's, and lakeglass's. Start here, ## Index -7 specs: 7 done. Decisions D1–D15. +7 specs: 7 done. Decisions D1–D18. | # | Spec | Status | Updated | |---:|---|---|---| -| 1 | [Services and the derive](0001-services.md) | Done | 2026-10-20 | -| 2 | [Clock](0002-clock.md) | Done | 2026-10-20 | +| 1 | [Resources, Provides, and wrappers](0001-resources.md) | Done | 2026-10-21 | +| 2 | [Clock](0002-clock.md) | Done | 2026-10-21 | | 3 | [Random](0003-random.md) | Done | 2026-10-20 | | 4 | [Env](0004-env.md) | Done | 2026-10-20 | | 5 | [Console](0005-console.md) | Done | 2026-10-20 | @@ -45,4 +45,4 @@ loko's design, laid out like lakekit's, Timelake's, and lakeglass's. Start here, | Milestone | Status | |---|---| -| [0.2: Services for the polythings projects](milestones/v0-2.md) | In progress | +| [0.2: Resources for the polythings projects](milestones/v0-2.md) | In progress | diff --git a/spec/milestones/README.md b/spec/milestones/README.md index 36c8b5f..64aa15b 100644 --- a/spec/milestones/README.md +++ b/spec/milestones/README.md @@ -4,4 +4,4 @@ The roadmap: goals made of specs. A milestone is done, in progress, planned, or | Milestone | Status | Specs | |---|---|---| -| [0.2: Services for the polythings projects](v0-2.md) | In progress | 1, 2, 3, 4, 5, 6, 7 | +| [0.2: Resources for the polythings projects](v0-2.md) | In progress | 1, 2, 3, 4, 5, 6, 7 | diff --git a/spec/milestones/v0-2.md b/spec/milestones/v0-2.md index 8886fba..84b8760 100644 --- a/spec/milestones/v0-2.md +++ b/spec/milestones/v0-2.md @@ -1,14 +1,14 @@ -# 0.2: Services for the polythings projects +# 0.2: Resources for the polythings projects **In progress** -The first release as a services library: `Clock`, `Random`, `Env`, `Console`, and `Store` with test, system, and -JavaScript implementations, the platforms, and the macros for new services and bundles. Enough for lakekit to take +The first release as a resources library: `Clock`, `Random`, `Env`, `Console`, and `Store` with test, system, and +JavaScript implementations, the platforms, and the macros for new resources and bundles. Enough for lakekit to take its store, clock, and randomness from loko instead of defining them, and for Timelake and lakeglass to follow. | # | Spec | Status | |---:|---|---| -| 1 | [Services and the derive](../0001-services.md) | Done | +| 1 | [Resources, Provides, and wrappers](../0001-resources.md) | Done | | 2 | [Clock](../0002-clock.md) | Done | | 3 | [Random](../0003-random.md) | Done | | 4 | [Env](../0004-env.md) | Done | From f59288bd05e4e1e80c10d0bc12082f3454e7f8c2 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 00:57:26 -0400 Subject: [PATCH 06/19] Review fixes from CodeRabbit (loko#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Macros: accessors split acronyms as words (HTTPClient → http_client); methods taking or returning `Self` are refused with a clear error; #[cfg] and #[cfg_attr] on trait items carry over to the generated items; the blanket impl's type is LokoXHolder, so a method's own `T` can't clash. - Clock: from_ms returns None where the platform can't hold the time (before the epoch on WASI); JsClock sleeps in steps of at most 2^31 - 1 ms, past which setTimeout fires at once; OffsetClock::inner's doc. - Env: SystemEnv::vars skips variables that aren't Unicode instead of panicking; SystemEnv and StdConsole are only on targets with a system, and loko-env has a module per implementation. - Object store: the root keeps the URL's userinfo (Azure's filesystem), and a password shows as *** in errors and Debug. - CI runs the commit-stage hooks (YAML, TOML, private keys, rustfmt). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 13 ++++ Cargo.toml | 2 +- crates/loko-clock/src/js.rs | 43 ++++++++---- crates/loko-clock/src/lib.rs | 7 +- crates/loko-clock/src/offset.rs | 2 +- crates/loko-clock/src/testing.rs | 13 +++- crates/loko-console/src/lib.rs | 4 +- crates/loko-env/src/lib.rs | 101 +++------------------------- crates/loko-env/src/map.rs | 72 ++++++++++++++++++++ crates/loko-env/src/system.rs | 29 ++++++++ crates/loko-macros/src/lib.rs | 98 ++++++++++++++++++++++----- crates/loko-object-store/src/lib.rs | 60 +++++++++++++---- crates/loko/tests/resources.rs | 28 ++++++++ spec/0002-clock.md | 7 +- spec/0006-store.md | 3 +- 15 files changed, 333 insertions(+), 149 deletions(-) create mode 100644 crates/loko-env/src/map.rs create mode 100644 crates/loko-env/src/system.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f177ce..f51131d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,19 @@ env: CARGO_INCREMENTAL: 0 jobs: + hooks: + name: Hooks (YAML, TOML, private keys, rustfmt) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install # rustfmt, for the rustfmt hook + # The commit-stage hooks of .pre-commit-config.yaml; the push-stage ones (clippy) run in the jobs below. + - uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0 + with: + prek-version: 0.5.7 + native: name: Native (format, lint, tests) runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 738948a..b93dbdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" proc-macro2 = "1" quote = "1" -syn = { version = "2", features = ["full", "visit-mut"] } +syn = { version = "2", features = ["full", "visit", "visit-mut"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/loko-clock/src/js.rs b/crates/loko-clock/src/js.rs index 3824968..b830e7d 100644 --- a/crates/loko-clock/src/js.rs +++ b/crates/loko-clock/src/js.rs @@ -18,20 +18,35 @@ impl Clock for JsClock { } async fn sleep(&self, duration: Duration) { - #[expect(clippy::cast_precision_loss, reason = "timeouts are far below 2^53 ms")] - let ms = JsValue::from_f64(duration.as_millis() as f64); - let promise = Promise::new(&mut |resolve, _| { - let global = js_sys::global(); - match Reflect::get(&global, &"setTimeout".into()).and_then(JsCast::dyn_into::) { - Ok(set_timeout) => { - let _ = set_timeout.call2(&global, &resolve, &ms); - } - // A host without timers: resolve at once. - Err(_) => { - let _ = resolve.call0(&JsValue::NULL); - } + // `setTimeout` holds its delay in a signed 32-bit integer and fires at once above it, so a + // longer sleep waits in steps of at most that. + const STEP_MS: u128 = i32::MAX as u128; + let mut left = duration.as_millis(); + loop { + let step = left.min(STEP_MS); + timeout(u32::try_from(step).unwrap_or(u32::MAX)).await; + left -= step; + if left == 0 { + break; } - }); - let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } } } + +/// Waits `ms` milliseconds on `setTimeout`, or not at all on a host without timers. +async fn timeout(ms: u32) { + let ms = JsValue::from_f64(f64::from(ms)); + let promise = Promise::new(&mut |resolve, _| { + let global = js_sys::global(); + match Reflect::get(&global, &"setTimeout".into()).and_then(JsCast::dyn_into::) { + Ok(set_timeout) => { + let _ = set_timeout.call2(&global, &resolve, &ms); + } + // A host without timers: resolve at once. + Err(_) => { + let _ = resolve.call0(&JsValue::NULL); + } + } + }); + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; +} diff --git a/crates/loko-clock/src/lib.rs b/crates/loko-clock/src/lib.rs index 596eddc..0ea458f 100644 --- a/crates/loko-clock/src/lib.rs +++ b/crates/loko-clock/src/lib.rs @@ -64,8 +64,9 @@ pub fn to_ms(time: SystemTime) -> i64 { } } -/// The time `ms` milliseconds after the Unix epoch (before it, if negative). -pub fn from_ms(ms: i64) -> SystemTime { +/// The time `ms` milliseconds after the Unix epoch (before it, if negative), or `None` where the +/// platform can't hold it (WASI has no times before the epoch). +pub fn from_ms(ms: i64) -> Option { let magnitude = Duration::from_millis(ms.unsigned_abs()); - if ms >= 0 { UNIX_EPOCH + magnitude } else { UNIX_EPOCH - magnitude } + if ms >= 0 { UNIX_EPOCH.checked_add(magnitude) } else { UNIX_EPOCH.checked_sub(magnitude) } } diff --git a/crates/loko-clock/src/offset.rs b/crates/loko-clock/src/offset.rs index c4ea978..17ce7da 100644 --- a/crates/loko-clock/src/offset.rs +++ b/crates/loko-clock/src/offset.rs @@ -22,7 +22,7 @@ impl OffsetClock { Self { inner, offset, behind: true } } - /// The clock offset. + /// The clock this one is offset from. pub fn inner(&self) -> &C { &self.inner } diff --git a/crates/loko-clock/src/testing.rs b/crates/loko-clock/src/testing.rs index c12d7eb..9fe65f9 100644 --- a/crates/loko-clock/src/testing.rs +++ b/crates/loko-clock/src/testing.rs @@ -18,8 +18,12 @@ impl TestClock { } /// A clock `ms` milliseconds after the Unix epoch. + /// + /// # Panics + /// + /// If the platform can't hold that time ([`from_ms`]): before the epoch on WASI. pub fn at_ms(ms: i64) -> Self { - Self::new(from_ms(ms)) + Self::new(from_ms(ms).expect("a time this platform can hold")) } /// Moves the clock forward by `duration`. @@ -61,8 +65,11 @@ mod tests { let other = clock.clone(); other.advance(Duration::from_millis(250)); assert_eq!(clock.now_ms(), 5_250, "clones share the time"); - clock.set(from_ms(-1_500)); - assert_eq!(clock.now_ms(), -1_500); + // Times before the epoch exist natively, not on WASI. + if let Some(before) = from_ms(-1_500) { + clock.set(before); + assert_eq!(clock.now_ms(), -1_500); + } assert_eq!(TestClock::default().now(), UNIX_EPOCH); } diff --git a/crates/loko-console/src/lib.rs b/crates/loko-console/src/lib.rs index 2477054..18386f8 100644 --- a/crates/loko-console/src/lib.rs +++ b/crates/loko-console/src/lib.rs @@ -2,7 +2,7 @@ //! printed directly, so tests read what was printed and code in a browser logs to its console. //! //! - [`Console`]: `print_line` and `print_error_line`. -//! - [`StdConsole`]: the process's standard output and error. +//! - `StdConsole`: the process's standard output and error (native targets and WASI). //! - [`TestConsole`]: keeps the lines, for tests. //! - `JsConsole` (feature `js`): `console.log` and `console.error`, in browsers, Workers, and Node. //! @@ -19,11 +19,13 @@ use std::io; #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] mod js; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] mod system; mod testing; #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] pub use js::JsConsole; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub use system::StdConsole; pub use testing::TestConsole; diff --git a/crates/loko-env/src/lib.rs b/crates/loko-env/src/lib.rs index e145366..ebe01e3 100644 --- a/crates/loko-env/src/lib.rs +++ b/crates/loko-env/src/lib.rs @@ -2,7 +2,7 @@ //! tests set their own and code in a browser (which has none) gets them from a map. //! //! - [`Env`]: `var` and `vars`. -//! - [`SystemEnv`]: the process's variables. +//! - `SystemEnv`: the process's variables (native targets and WASI). //! - [`MapEnv`]: variables in a map, for tests and for hosts without a process environment. //! //! ``` @@ -13,8 +13,13 @@ //! assert_eq!(env.var("MISSING"), None); //! ``` -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +mod map; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +mod system; + +pub use map::MapEnv; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub use system::SystemEnv; /// Environment variables. #[loko_macros::resource] @@ -25,93 +30,3 @@ pub trait Env { /// Every variable, as `(name, value)` pairs. fn vars(&self) -> Vec<(String, String)>; } - -/// The process's environment variables (`std::env`). On targets without one -/// (`wasm32-unknown-unknown`) nothing is set. -#[derive(Clone, Copy, Debug, Default)] -pub struct SystemEnv; - -impl Env for SystemEnv { - fn var(&self, name: &str) -> Option { - std::env::var(name).ok() - } - - fn vars(&self) -> Vec<(String, String)> { - std::env::vars().collect() - } -} - -/// Environment variables in a map. Clones share the map. -#[derive(Clone, Debug, Default)] -pub struct MapEnv { - vars: Arc>>, -} - -impl MapEnv { - /// No variables. - pub fn new() -> Self { - Self::default() - } - - /// These variables and `name` set to `value`. - #[must_use] - pub fn with(self, name: impl Into, value: impl Into) -> Self { - self.set(name, value); - self - } - - /// Sets `name` to `value`. - pub fn set(&self, name: impl Into, value: impl Into) { - self.map().insert(name.into(), value.into()); - } - - /// Unsets `name`. - pub fn remove(&self, name: &str) { - self.map().remove(name); - } - - fn map(&self) -> MutexGuard<'_, BTreeMap> { - self.vars.lock().unwrap_or_else(PoisonError::into_inner) - } -} - -impl, V: Into> FromIterator<(K, V)> for MapEnv { - fn from_iter>(iter: I) -> Self { - let env = Self::new(); - for (k, v) in iter { - env.set(k, v); - } - env - } -} - -impl Env for MapEnv { - fn var(&self, name: &str) -> Option { - self.map().get(name).cloned() - } - - fn vars(&self) -> Vec<(String, String)> { - self.map().iter().map(|(k, v)| (k.clone(), v.clone())).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn map_envs_set_and_unset() { - let env: MapEnv = [("A", "1"), ("B", "2")].into_iter().collect(); - let shared = env.clone(); - shared.set("C", "3"); - shared.remove("A"); - assert_eq!(env.vars(), [("B".to_string(), "2".to_string()), ("C".to_string(), "3".to_string())]); - } - - #[test] - fn the_system_env_reads_the_process() { - // PATH is set wherever these tests run. - assert!(SystemEnv.var("PATH").is_some()); - assert!(SystemEnv.vars().iter().any(|(k, _)| k == "PATH")); - } -} diff --git a/crates/loko-env/src/map.rs b/crates/loko-env/src/map.rs new file mode 100644 index 0000000..6c31b57 --- /dev/null +++ b/crates/loko-env/src/map.rs @@ -0,0 +1,72 @@ +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use crate::Env; + +/// Environment variables in a map. Clones share the map. +#[derive(Clone, Debug, Default)] +pub struct MapEnv { + vars: Arc>>, +} + +impl MapEnv { + /// No variables. + pub fn new() -> Self { + Self::default() + } + + /// These variables and `name` set to `value`. + #[must_use] + pub fn with(self, name: impl Into, value: impl Into) -> Self { + self.set(name, value); + self + } + + /// Sets `name` to `value`. + pub fn set(&self, name: impl Into, value: impl Into) { + self.map().insert(name.into(), value.into()); + } + + /// Unsets `name`. + pub fn remove(&self, name: &str) { + self.map().remove(name); + } + + fn map(&self) -> MutexGuard<'_, BTreeMap> { + self.vars.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl, V: Into> FromIterator<(K, V)> for MapEnv { + fn from_iter>(iter: I) -> Self { + let env = Self::new(); + for (k, v) in iter { + env.set(k, v); + } + env + } +} + +impl Env for MapEnv { + fn var(&self, name: &str) -> Option { + self.map().get(name).cloned() + } + + fn vars(&self) -> Vec<(String, String)> { + self.map().iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_envs_set_and_unset() { + let env: MapEnv = [("A", "1"), ("B", "2")].into_iter().collect(); + let shared = env.clone(); + shared.set("C", "3"); + shared.remove("A"); + assert_eq!(env.vars(), [("B".to_string(), "2".to_string()), ("C".to_string(), "3".to_string())]); + } +} diff --git a/crates/loko-env/src/system.rs b/crates/loko-env/src/system.rs new file mode 100644 index 0000000..2c5924a --- /dev/null +++ b/crates/loko-env/src/system.rs @@ -0,0 +1,29 @@ +use crate::Env; + +/// The process's environment variables (`std::env`), on native targets and WASI. Variables whose +/// name or value isn't valid Unicode are left out, as `var` leaves them out. +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemEnv; + +impl Env for SystemEnv { + fn var(&self, name: &str) -> Option { + std::env::var(name).ok() + } + + fn vars(&self) -> Vec<(String, String)> { + // `std::env::vars` panics on the first variable that isn't Unicode. + std::env::vars_os().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_system_env_reads_the_process() { + // PATH is set wherever these tests run. + assert!(SystemEnv.var("PATH").is_some()); + assert!(SystemEnv.vars().iter().any(|(k, _)| k == "PATH")); + } +} diff --git a/crates/loko-macros/src/lib.rs b/crates/loko-macros/src/lib.rs index c7be5b2..a8668f3 100644 --- a/crates/loko-macros/src/lib.rs +++ b/crates/loko-macros/src/lib.rs @@ -40,18 +40,21 @@ pub fn resource(args: TokenStream, input: TokenStream) -> TokenStream { } } -/// The accessor of resource `name`: `Clock` → `clock`, `ObjectStore` → `object_store`. +/// The accessor of resource `name`: `Clock` → `clock`, `ObjectStore` → `object_store`, +/// `HTTPClient` → `http_client`. A word starts at a capital after a lowercase letter or digit, or at +/// the last capital of a run followed by a lowercase letter. fn accessor(name: &Ident) -> Ident { + let chars: Vec = name.to_string().chars().collect(); let mut snake = String::new(); - for (i, c) in name.to_string().chars().enumerate() { - if c.is_uppercase() { - if i > 0 { + for (i, &c) in chars.iter().enumerate() { + if c.is_uppercase() && i > 0 { + let after_lower = chars[i - 1].is_lowercase() || chars[i - 1].is_ascii_digit(); + let ends_run = chars[i - 1].is_uppercase() && chars.get(i + 1).is_some_and(|n| n.is_lowercase()); + if after_lower || ends_run { snake.push('_'); } - snake.extend(c.to_lowercase()); - } else { - snake.push(c); } + snake.extend(c.to_lowercase()); } Ident::new(&snake, name.span()) } @@ -96,6 +99,36 @@ impl VisitMut for HeldAssoc<'_> { } } +/// Finds `Self` used as a type on its own (`-> Self`, `Option`), not as `Self::Assoc`. +#[derive(Default)] +struct BareSelf(Option); + +impl syn::visit::Visit<'_> for BareSelf { + fn visit_type_path(&mut self, path: &TypePath) { + if path.qself.is_none() && path.path.segments.len() == 1 && path.path.segments[0].ident == "Self" { + self.0.get_or_insert(path.span()); + } + syn::visit::visit_type_path(self, path); + } +} + +/// An error if `sig` uses `Self` as a type: the holder of a resource isn't the resource held, so a +/// forwarded `Self` would be the wrong type. +fn reject_bare_self(sig: &Signature) -> syn::Result<()> { + let mut found = BareSelf::default(); + for input in sig.inputs.iter().skip(1) { + syn::visit::Visit::visit_fn_arg(&mut found, input); + } + syn::visit::Visit::visit_return_type(&mut found, &sig.output); + match found.0 { + Some(span) => Err(Error::new( + span, + "loko resource methods can't take or return `Self`: a type holding the resource isn't the resource", + )), + None => Ok(()), + } +} + /// Names the arguments of `sig` (after `&self`) `arg0`, `arg1`, …, so a body can pass them on. fn name_arguments(sig: &mut Signature) -> syn::Result> { match sig.inputs.first() { @@ -113,6 +146,11 @@ fn name_arguments(sig: &mut Signature) -> syn::Result> { Ok(args) } +/// The `#[cfg]` and `#[cfg_attr]` attributes of a trait item, which its generated items need too. +fn cfgs(attrs: &[syn::Attribute]) -> Vec<&syn::Attribute> { + attrs.iter().filter(|a| a.path().is_ident("cfg") || a.path().is_ident("cfg_attr")).collect() +} + /// `sig { call }`, awaiting the call in an async method. fn method(sig: &Signature, call: &TokenStream2) -> TokenStream2 { let body = if sig.asyncness.is_some() { @@ -137,22 +175,25 @@ fn expand_resource(item: &ItemTrait) -> syn::Result { let name = &item.ident; let has: Path = format_ident!("Has{}", name).into(); let get = accessor(name); - let held = quote! { <::#name as #name> }; + // A name for the blanket impl's type that a method's own generics won't use. + let holder = format_ident!("Loko{}Holder", name); + let held = quote! { <<#holder as #has>::#name as #name> }; let (mut has_items, mut impl_items) = (vec![], vec![]); for trait_item in &item.items { match trait_item { TraitItem::Type(t) => { - let ident = &t.ident; + let (ident, cfgs) = (&t.ident, cfgs(&t.attrs)); let (_, ty_generics, where_clause) = t.generics.split_for_impl(); - impl_items.push(quote! { type #ident #ty_generics = #held::#ident #ty_generics #where_clause; }); + impl_items + .push(quote! { #(#cfgs)* type #ident #ty_generics = #held::#ident #ty_generics #where_clause; }); } TraitItem::Const(c) => { - let (ident, ty) = (&c.ident, &c.ty); - impl_items.push(quote! { const #ident: #ty = #held::#ident; }); + let (ident, ty, cfgs) = (&c.ident, &c.ty, cfgs(&c.attrs)); + impl_items.push(quote! { #(#cfgs)* const #ident: #ty = #held::#ident; }); } TraitItem::Fn(f) => { - let (has_item, impl_item) = forward_fn(f, &has, name, &get)?; + let (has_item, impl_item) = forward_fn(f, &has, name, &get, &holder)?; has_items.push(has_item); impl_items.push(impl_item); } @@ -182,7 +223,7 @@ fn expand_resource(item: &ItemTrait) -> syn::Result { } #[automatically_derived] - impl #name for T { + impl<#holder: #has #bound> #name for #holder { #(#impl_items)* } }) @@ -190,9 +231,17 @@ fn expand_resource(item: &ItemTrait) -> syn::Result { /// For a method of the resource: its forwarding method in `HasX` (`wrapped_`, calling the /// held resource), and its method in the blanket impl (calling the `HasX` one). -fn forward_fn(f: &TraitItemFn, has: &Path, name: &Ident, get: &Ident) -> syn::Result<(TokenStream2, TokenStream2)> { +fn forward_fn( + f: &TraitItemFn, + has: &Path, + name: &Ident, + get: &Ident, + holder: &Ident, +) -> syn::Result<(TokenStream2, TokenStream2)> { + reject_bare_self(&f.sig)?; let mut sig = f.sig.clone(); let args = name_arguments(&mut sig)?; + let cfgs = cfgs(&f.attrs); let ident = sig.ident.clone(); let wrapped_ident = wrapped(&ident); @@ -204,8 +253,9 @@ fn forward_fn(f: &TraitItemFn, has: &Path, name: &Ident, get: &Ident) -> syn::Re format!("Forwards [`{name}::{ident}`] to the held resource; a `#[loko::wraps]` implementation may replace it."); let has_method = method(&has_sig, &has_call); - let impl_call = quote! { ::#wrapped_ident(self, #(#args),*) }; - Ok((quote! { #[doc = #doc] #[doc(hidden)] #has_method }, method(&sig, &impl_call))) + let impl_call = quote! { <#holder as #has>::#wrapped_ident(self, #(#args),*) }; + let impl_method = method(&sig, &impl_call); + Ok((quote! { #(#cfgs)* #[doc = #doc] #[doc(hidden)] #has_method }, quote! { #(#cfgs)* #impl_method })) } /// Implements `Has` for each `#[provides(Resource, …)]` on a field, so the struct is each @@ -329,6 +379,9 @@ mod tests { fn accessors_are_snake_case() { assert_eq!(accessor(&format_ident!("Clock")), "clock"); assert_eq!(accessor(&format_ident!("ObjectStore")), "object_store"); + assert_eq!(accessor(&format_ident!("HTTPClient")), "http_client"); + assert_eq!(accessor(&format_ident!("S3Store")), "s3_store"); + assert_eq!(accessor(&format_ident!("IOError")), "io_error"); } #[test] @@ -343,6 +396,17 @@ mod tests { ); } + #[test] + fn bare_self_is_rejected() { + for sig in + [quote! { fn duplicate(&self) -> Self where Self: Sized }, quote! { fn merge(&self, other: Option) }] + { + assert!(reject_bare_self(&syn::parse2(sig).unwrap()).is_err()); + } + let ok: Signature = parse_quote! { fn upload(&self) -> Result where Self: Sized }; + assert!(reject_bare_self(&ok).is_ok(), "associated types and where clauses are fine"); + } + #[test] fn self_types_become_the_held_resources() { let mut sig: Signature = parse_quote! { fn upload(&self) -> Result }; diff --git a/crates/loko-object-store/src/lib.rs b/crates/loko-object-store/src/lib.rs index c0ced78..55923af 100644 --- a/crates/loko-object-store/src/lib.rs +++ b/crates/loko-object-store/src/lib.rs @@ -15,6 +15,7 @@ //! let store = ObjectStoreAdapter::new(Arc::new(InMemory::new()), "memory://"); //! ``` +use std::fmt; use std::future::Future; use std::sync::Arc; @@ -25,10 +26,18 @@ use object_store::{MultipartUpload, ObjectStore, ObjectStoreExt, PutMode, PutOpt use url::Url; /// A [`Store`] over an `object_store` client whose objects have URIs under `root`. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct ObjectStoreAdapter { store: Arc, root: String, + /// The root as shown in errors and `Debug`, without a password. + shown: String, +} + +impl fmt::Debug for ObjectStoreAdapter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ObjectStoreAdapter").field("store", &self.store).field("root", &self.shown).finish() + } } fn store_error(e: object_store::Error) -> Error { @@ -50,7 +59,8 @@ impl ObjectStoreAdapter { if !root.ends_with('/') { root.push('/'); } - Self { store, root } + let shown = redacted(&root); + Self { store, root, shown } } /// The store for `url`'s scheme and bucket (`s3://bucket/…`), configured with `options` @@ -73,7 +83,7 @@ impl ObjectStoreAdapter { fn path(&self, uri: &str) -> Result { let rest = uri .strip_prefix(&self.root) - .ok_or_else(|| Error::Invalid(format!("{uri} is outside this store ({})", self.root)))?; + .ok_or_else(|| Error::Invalid(format!("{uri} is outside this store ({})", self.shown)))?; Path::parse(rest).map_err(|e| Error::Invalid(format!("{uri}: {e}"))) } @@ -83,14 +93,29 @@ impl ObjectStoreAdapter { } } -/// The root of `url`: its scheme, host, and port (`http://localhost:9000/`), without the path or -/// credentials. `file:///data/t` gives `file:///`, a file system rooted at `/`. +/// The root of `url`: everything before the path, so its scheme, userinfo, host, and port +/// (`abfss://filesystem@account.dfs.core.windows.net/`, `http://localhost:9000/`), which the +/// URIs of its objects start with. `file:///data/t` gives `file:///`, a file system rooted at `/`. fn root_of(url: &Url) -> String { - match (url.host_str().filter(|h| !h.is_empty()), url.port()) { - (Some(host), Some(port)) => format!("{}://{host}:{port}/", url.scheme()), - (Some(host), None) => format!("{}://{host}/", url.scheme()), - (None, _) => format!("{}:///", url.scheme()), + let Some(host) = url.host_str().filter(|h| !h.is_empty()) else { + return format!("{}:///", url.scheme()); + }; + let userinfo = match (url.username(), url.password()) { + ("", None) => String::new(), + (user, None) => format!("{user}@"), + (user, Some(password)) => format!("{user}:{password}@"), + }; + let port = url.port().map(|p| format!(":{p}")).unwrap_or_default(); + format!("{}://{userinfo}{host}{port}/", url.scheme()) +} + +/// `root` with any password replaced by `***`, for errors and `Debug`. +fn redacted(root: &str) -> String { + let Ok(mut url) = Url::parse(root) else { return root.to_string() }; + if url.password().is_none() || url.set_password(Some("***")).is_err() { + return root.to_string(); } + url.to_string() } /// An offset for listing directory `dir` that skips objects sorting before `name`, the start of the @@ -185,7 +210,7 @@ impl Store for ObjectStoreAdapter { let split = prefix.strip_prefix(&self.root).map(|rest| rest.rsplit_once('/').unwrap_or(("", rest))); async move { let (relative, name) = - split.ok_or_else(|| Error::Invalid(format!("{prefix} is outside this store ({})", self.root)))?; + split.ok_or_else(|| Error::Invalid(format!("{prefix} is outside this store ({})", self.shown)))?; let dir = Path::parse(relative).map_err(|e| Error::Invalid(format!("{prefix}: {e}")))?; let dir = (!relative.is_empty()).then_some(&dir); let listing = match list_offset(relative, name) { @@ -301,14 +326,25 @@ mod tests { } #[test] - fn roots_keep_the_port_and_drop_credentials() { + fn roots_keep_what_uris_start_with() { let root = |url: &str| root_of(&Url::parse(url).unwrap()); assert_eq!(root("http://localhost:9000/bucket/t"), "http://localhost:9000/"); - assert_eq!(root("https://user:secret@example.com:443/t"), "https://example.com/", "443 is https's own"); + assert_eq!(root("https://example.com:443/t"), "https://example.com/", "443 is https's own"); + assert_eq!(root("abfss://fs@account.dfs.core.windows.net/t"), "abfss://fs@account.dfs.core.windows.net/"); + assert_eq!(root("https://user:secret@example.com/t"), "https://user:secret@example.com/"); assert_eq!(root("s3://bucket/warehouse/t"), "s3://bucket/"); assert_eq!(root("file:///data/t"), "file:///"); } + #[tokio::test] + async fn passwords_stay_out_of_errors_and_debug() { + let store = ObjectStoreAdapter::new(Arc::new(InMemory::new()), "memory://user:secret@host/"); + assert!(!format!("{store:?}").contains("secret")); + let Err(Error::Invalid(message)) = store.get("s3://elsewhere/x").await else { panic!("outside the store") }; + assert!(message.contains("user:***@host") && !message.contains("secret"), "{message}"); + assert_eq!(redacted("s3://bucket/"), "s3://bucket/"); + } + #[tokio::test] async fn local_files_have_file_uris() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/loko/tests/resources.rs b/crates/loko/tests/resources.rs index 6a2cf02..ed36958 100644 --- a/crates/loko/tests/resources.rs +++ b/crates/loko/tests/resources.rs @@ -154,3 +154,31 @@ fn store_wrappers_stack() { assert_eq!((counted.counts().get, counted.counts().put), (2, 1)); assert_eq!(memory.uris(), ["memory://wh/a/t"]); } + +/// A resource with a generic method (whose `T` the generated code mustn't clash with) and a method +/// compiled out by `#[cfg]` (whose generated items must go with it). +#[loko::resource] +trait Log { + /// The line logged for `value`. + fn line(&self, value: T) -> String; + + /// Never compiled. + #[cfg(any())] + fn never(&self); +} + +struct Plain; + +impl Log for Plain { + fn line(&self, value: T) -> String { + format!("log: {value}") + } +} + +#[derive(Provides)] +struct Logged(#[provides(Log)] Plain); + +#[test] +fn generic_and_cfg_gated_methods_forward() { + assert_eq!(Logged(Plain).line(42), "log: 42"); +} diff --git a/spec/0002-clock.md b/spec/0002-clock.md index eca3e5c..fcd40e8 100644 --- a/spec/0002-clock.md +++ b/spec/0002-clock.md @@ -21,7 +21,8 @@ and skip the waits. - **`Clock`:** `now() -> SystemTime`, `now_ms() -> i64` (provided: milliseconds since the Unix epoch, negative before it, saturating), and `sleep(Duration) -> impl Future`. -- **`from_ms(i64) -> SystemTime`:** the inverse of `now_ms`. +- **`from_ms(i64) -> Option`:** the inverse of `now_ms`, `None` where the platform can't hold the time + (before the epoch on WASI). - **`TestClock`:** starts where told (`new`, `at_ms`; the Unix epoch by default), moves on `advance` and `set`, and on `sleep`, which moves it forward by the sleep and returns at once. Clones share the time. - **`StdClock`:** `SystemTime::now`, and a `sleep` that blocks the thread before returning a ready future: for @@ -30,8 +31,8 @@ and skip the waits. - **`OffsetClock`:** a wrapper ([spec 1](0001-resources.md)): another clock, `ahead` or `behind` by a fixed `Duration`, for testing code against clock skew. It writes `now_ms` as well as `now`; sleeping is the other clock's. - **`to_ms(SystemTime) -> i64`:** what `now_ms` computes, for wrappers that change `now`. -- **`JsClock`** (feature `js`, `wasm32-unknown-unknown`): `Date.now` and a promise around `setTimeout` (resolved at - once on a host without `setTimeout`). +- **`JsClock`** (feature `js`, `wasm32-unknown-unknown`): `Date.now` and a promise around `setTimeout`, in steps of + at most 2³¹ − 1 ms (the longest delay `setTimeout` holds); resolved at once on a host without `setTimeout`. ## Decisions diff --git a/spec/0006-store.md b/spec/0006-store.md index 7ef9502..264f5cf 100644 --- a/spec/0006-store.md +++ b/spec/0006-store.md @@ -33,7 +33,8 @@ read and write through it. - `PrefixedStore`: passes on only URIs under its prefix (a `/` is added if missing), refusing the rest and any `.` or `..` segment with `Invalid`. A `delete` with one URI outside deletes nothing. - **`ObjectStoreAdapter`** (`loko-object-store`): a `Store` over any `object_store` client, from a client and a root - URI or from a URL with options (the root keeps the URL's port and drops any credentials). Put-if-absent is + URI or from a URL with options (the root is everything before the path: scheme, userinfo such as Azure's + filesystem, host, and port; a password shows as `***` in errors and `Debug`). Put-if-absent is `PutMode::Create`. An empty range, or one starting past the end, reads as no bytes, as in `MemoryStore`. Listing a prefix lists its directory from an offset just before the prefix, so `…/metadata/v` skips the manifests before it. The crate enables no backends; the application turns on the ones it needs on its own `object_store` dependency. From 83a278f3279d764c878753e80edb22eb9661dcbe Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 01:11:38 -0400 Subject: [PATCH 07/19] README: a guide from one resource to bundles and wrappers, run as doctests Nine steps, each building on the last: one resource, waiting without waiting, several resources, storage, bundles, keeping resources in a struct, your own resource, wrappers, and the browser; the tables move to a Reference section. The loko crate includes the README under cfg(doctest), so CI runs every example (with features store and tokio). loko's files no longer name the other projects that use it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- CHANGELOG.md | 5 +- PRIOR_ART.md | 2 +- README.md | 381 ++++++++++++++++++++++++++++++++------ crates/loko/src/lib.rs | 5 + spec/0006-store.md | 9 +- spec/README.md | 4 +- spec/milestones/README.md | 2 +- spec/milestones/v0-2.md | 8 +- 9 files changed, 348 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0964b69..0dd789b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,7 @@ loko: resources for Rust programs (`Clock`, `Random`, `Env`, `Console`, `Store`) passed to code as traits, with test, system, and JavaScript implementations. One crate per resource (`crates/loko-`), the derive macros -(`crates/loko-macros`), and the `loko` crate, which re-exports them and adds the platforms. The polythings projects -(Timelake, lakekit, lakeglass) use it. +(`crates/loko-macros`), and the `loko` crate, which re-exports them and adds the platforms. ## Before changing code @@ -19,6 +18,8 @@ system, and JavaScript implementations. One crate per resource (`crates/loko- String { + format!("[{}] {message}", clock.now_ms()) +} + +// A program: the system clock. +let line = stamp(&StdClock, "started"); +assert!(line.ends_with("] started")); + +// A test: a clock that moves only when told. +let clock = TestClock::at_ms(1_000); +assert_eq!(stamp(&clock, "started"), "[1000] started"); +``` + +### 2. Waiting without waiting + +`sleep` is a `Clock` method too. A test clock's sleep returns at once and moves the clock forward, so code that waits +(retries, timeouts, polling) runs in tests without waiting. Random numbers come from a `Random`, so the jitter is the +same on every test run. ```rust use std::time::Duration; use loko::{Clock, Random, TestPlatform}; -/// An ID from the time and a random suffix. -fn id(resources: &(impl Clock + Random)) -> String { - format!("{}-{:04x}", resources.now_ms(), resources.next_u64() & 0xffff) +/// Calls `attempt` up to five times, waiting one to two seconds between tries. +async fn retry( + resources: &(impl Clock + Random), + mut attempt: impl FnMut() -> Result, +) -> Result { + let mut tries = 1; + loop { + match attempt() { + Err(_) if tries < 5 => { + let jitter = resources.next_u64() % 1_000; + resources.sleep(Duration::from_millis(1_000 + jitter)).await; + tries += 1; + } + result => return result, + } + } } -let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program -platform.clock.advance(Duration::from_secs(1)); -assert_eq!(id(&platform), "1000-cdaf"); // the same on every run +#[tokio::main(flavor = "current_thread")] +async fn main() { + // A program would pass `TokioPlatform::default()`. + let platform = TestPlatform::new(); + let mut calls = 0; + let result = retry(&platform, || { + calls += 1; + if calls < 3 { Err("busy") } else { Ok(calls) } + }) + .await; + assert_eq!(result, Ok(3)); + // Two waits of one to two seconds each, on the test clock: the test itself took no time. + assert!((2_000..4_000).contains(&platform.now_ms())); +} ``` -## Resources +### 3. Several resources -| Resource | Methods | For tests | System | JavaScript (feature `js`) | -|---|---|---|---|---| -| [`Clock`](crates/loko-clock) | `now`, `now_ms`, `sleep` | `TestClock` | `StdClock`, `TokioClock` (feature `tokio`) | `JsClock` | -| [`Random`](crates/loko-random) | `next_u64`, `fill` | `SeededRandom` | `OsRandom` (feature `getrandom`) | `JsRandom` | -| [`Env`](crates/loko-env) | `var`, `vars` | `MapEnv` | `SystemEnv` | `MapEnv` | -| [`Console`](crates/loko-console) | `print_line`, `print_error_line` | `TestConsole` | `StdConsole` | `JsConsole` | -| [`Store`](crates/loko-store) (feature `store`) | get, put, put-if-absent, upload, list, delete | `MemoryStore` | [`ObjectStoreAdapter`](crates/loko-object-store) (feature `object-store`) | the application's binding | +A function can take each resource as its own argument, or one value that is all of them. A platform is one of each +of `Clock`, `Random`, `Env`, and `Console`: `TestPlatform` for tests, `StdPlatform` or `TokioPlatform` for programs, +and `JsPlatform` in the browser. -Futures aren't required to be `Send`, so a resource can wrap JavaScript promises. With feature `rand`, any `rand_core` -generator in a `Mutex` is a `Random`. +```rust +use loko::{Console, Env, TestPlatform}; -## Platforms +/// Separate arguments: plain, and fine for two or three resources. +fn greet(env: &impl Env, console: &impl Console) -> std::io::Result<()> { + let name = env.var("USER").unwrap_or_else(|| "stranger".into()); + console.print_line(&format!("Hello, {name}")) +} -A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, and is each of them: +/// One value: handier as the list grows, and easy to store. +fn greet_all(resources: &(impl Env + Console)) -> std::io::Result<()> { + greet(resources, resources) +} -| Platform | Clock | Random | Env | Console | Where | -|---|---|---|---|---|---| -| `TestPlatform` | `TestClock` | `SeededRandom` | `MapEnv` | `TestConsole` | Tests, anywhere | -| `StdPlatform` | `StdClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native and WASI, without an async runtime | -| `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native, under tokio (feature `tokio`) | -| `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | Browsers, Workers, and Node (feature `js`) | +// A program would pass `StdPlatform::default()`. +let platform = TestPlatform::new().with_var("USER", "Ada"); +greet_all(&platform).unwrap(); +assert_eq!(platform.console.lines(), ["Hello, Ada"]); +``` + +### 4. Storage + +`Store` reads and writes objects by URI, with a conditional write (`put_if_absent`) for the cases where two writers +must not overwrite each other. Tests use `MemoryStore`; programs use `ObjectStoreAdapter` over S3, GCS, Azure, or local +files (feature `object-store`, with the backend enabled on your `object_store` dependency). + +```rust +use loko::store::{MemoryStore, Store}; + +/// Writes a report unless one with that name exists. +async fn save(store: &impl Store, name: &str, body: &str) -> loko::store::Result { + store.put_if_absent(&format!("memory://reports/{name}"), body.as_bytes().to_vec()).await +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> loko::store::Result<()> { + // A program: ObjectStoreAdapter::from_url("s3://bucket/reports", [("aws_region", "us-east-2")])? + let store = MemoryStore::new(); + assert!(save(&store, "a.txt", "first").await?); + assert!(!save(&store, "a.txt", "second").await?, "the first write wins"); + Ok(()) +} +``` -## Your own resources and bundles +### 5. Bundles -`#[loko::resource]` makes a trait a resource, and `#[derive(Provides)]` makes a struct each resource its fields provide. -A program bundles a platform with a store and its own resources; a resource's implementation can need other resources. +When code needs a store and the time together, put them in one struct with `#[derive(Provides)]`: each field marked +`#[provides(…)]` makes the struct that resource. Generic fields let the program and its tests fill the same struct +differently. ```rust use loko::prelude::*; use loko::store::MemoryStore; -#[loko::resource] -trait Greeter { - fn greet(&self, name: &str) -> String; +#[derive(Provides)] +struct App { + #[provides(Clock, Random, Env, Console)] + platform: P, + #[provides(Store)] + store: S, +} + +/// Saves a report under a name that sorts by time. +async fn save_report(app: &(impl Store + Clock), body: &str) -> loko::store::Result { + let uri = format!("memory://reports/{}.txt", app.now_ms()); + app.put(&uri, body.as_bytes().to_vec()).await?; + Ok(uri) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> loko::store::Result<()> { + // A program: App { platform: TokioPlatform::default(), store: ObjectStoreAdapter::from_url(…)? } + let app = App { platform: TestPlatform::new().at_ms(42), store: MemoryStore::new() }; + assert_eq!(save_report(&app, "all good").await?, "memory://reports/42.txt"); + assert_eq!(app.store.uris(), ["memory://reports/42.txt"]); + Ok(()) +} +``` + +`#[provides(Clock)]` names `HasClock` next to `Clock`, so bring both in: `loko::prelude::*` does for loko's resources, +or write the path (`#[provides(loko::Clock)]`). + +### 6. Keeping resources in a struct + +A library type holds its resources as one generic field and asks for what each method needs. + +```rust +use loko::prelude::*; +use loko::store::MemoryStore; + +/// Uploads files under a prefix, named by time and a random suffix. +struct Uploader { + resources: R, + prefix: String, +} + +impl Uploader { + async fn upload(&self, body: Vec) -> loko::store::Result { + let r = &self.resources; + let uri = format!("{}/{}-{:04x}", self.prefix, r.now_ms(), r.next_u64() & 0xffff); + r.put(&uri, body).await?; + Ok(uri) + } } #[derive(Provides)] -struct App { - #[provides(Clock, Random, Env, Console)] +struct Test { + #[provides(Clock, Random)] platform: TestPlatform, #[provides(Store)] store: MemoryStore, - #[provides(Greeter)] - greeter: G, } -fn welcome(app: &(impl Greeter + Env + Console)) -> std::io::Result<()> { - let name = app.var("USER").unwrap_or_else(|| "stranger".into()); - app.print_line(&app.greet(&name)) +#[tokio::main(flavor = "current_thread")] +async fn main() -> loko::store::Result<()> { + let uploader = Uploader { + resources: Test { platform: TestPlatform::new(), store: MemoryStore::new() }, + prefix: "memory://uploads".into(), + }; + assert_eq!(uploader.upload(b"x".to_vec()).await?, "memory://uploads/0-cdaf"); + Ok(()) } ``` -`#[resource]` on `trait Greeter` adds `trait HasGreeter` (a type that holds a `Greeter`) and makes every `HasGreeter` -a `Greeter`; the derive implements `HasGreeter` for `App`. It names `HasGreeter` next to the path you give, so bring -both in (`loko::prelude::*` does for loko's resources) or write the path (`#[provides(loko::Clock)]`). +### 7. Your own resource + +`#[loko::resource]` makes any trait a resource, so a program's own dependencies (a mailer, a payment client, a feature +flag service) work like loko's: a real implementation for the program, a test one that records, and a place in the +bundle. An implementation can use other resources. + +```rust +use std::future::{Future, ready}; +use std::sync::{Arc, Mutex}; + +use loko::prelude::*; + +#[loko::resource] +trait Mailer { + /// Sends `body` to `to`. + fn send(&self, to: &str, body: &str) -> impl Future>; +} + +/// For tests: keeps what would have been sent. +#[derive(Clone, Default)] +struct TestMailer { + sent: Arc>>, +} + +impl Mailer for TestMailer { + fn send(&self, to: &str, body: &str) -> impl Future> { + self.sent.lock().unwrap().push((to.into(), body.into())); + ready(Ok(())) + } +} + +#[derive(Provides)] +struct App { + #[provides(Clock, Random, Env, Console)] + platform: TestPlatform, + #[provides(Mailer)] + mailer: M, +} + +/// Tells the administrator a backup finished. +async fn report_backup(app: &(impl Mailer + Env + Clock)) -> std::io::Result<()> { + let to = app.var("ADMIN").unwrap_or_else(|| "root@localhost".into()); + app.send(&to, &format!("backup finished at {}", app.now_ms())).await +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> std::io::Result<()> { + let mailer = TestMailer::default(); + let app = App { platform: TestPlatform::new().with_var("ADMIN", "ops@example.com"), mailer: mailer.clone() }; + report_backup(&app).await?; + assert_eq!(*mailer.sent.lock().unwrap(), [("ops@example.com".into(), "backup finished at 0".into())]); + Ok(()) +} +``` -## Wrappers +### 8. Wrappers -A wrapper holds one implementation of a resource and is itself one, changing some methods. `#[loko::wraps(field: -Type)]` on its `impl` forwards every method it doesn't write to the field: +A wrapper holds one implementation of a resource and is one itself, changing some methods. `#[loko::wraps(field: +Type)]` on its `impl` forwards every method it doesn't write to the field. ```rust use loko::prelude::*; @@ -100,29 +295,107 @@ impl Console for Shouting { self.inner.print_line(&line.to_uppercase()) } } + +let platform = TestPlatform::new(); +let console = Shouting { inner: platform.console.clone() }; +console.print_line("done").unwrap(); +console.print_error_line("as it is").unwrap(); +assert_eq!(platform.console.lines(), ["DONE"]); +assert_eq!(platform.console.error_lines(), ["as it is"]); +``` + +Wrappers stack, and loko has some ready-made: `OffsetClock` (a clock ahead or behind another, for clock-skew tests), +`CountingStore`, `ReadOnlyStore`, and `PrefixedStore`. + +```rust +use std::time::Duration; + +use loko::Clock; +use loko::clock::{OffsetClock, TestClock}; +use loko::store::{CountingStore, MemoryStore, PrefixedStore, ReadOnlyStore, Store}; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + // A store that counts its calls, never writes, and stays under one prefix. + let memory = MemoryStore::new(); + memory.put("memory://tenant-a/report", b"x".to_vec()).await.unwrap(); + let store = CountingStore::new(ReadOnlyStore::new(PrefixedStore::new(memory, "memory://tenant-a/"))); + assert!(store.get("memory://tenant-a/report").await.unwrap().is_some()); + assert!(store.get("memory://tenant-b/report").await.is_err(), "outside the prefix"); + assert!(store.put("memory://tenant-a/new", vec![]).await.is_err(), "read-only"); + assert_eq!((store.counts().get, store.counts().put), (2, 1)); + + // A second machine whose clock runs five seconds behind. + let clock = TestClock::at_ms(10_000); + let late = OffsetClock::behind(clock.clone(), Duration::from_secs(5)); + assert_eq!(late.now_ms(), 5_000); +} +``` + +A wrapper keeps the inner resource's associated types, and a provided method it doesn't write (such as `now_ms`) +forwards to the inner one too, so write it when it depends on a method the wrapper changes. + +### 9. In the browser + +With feature `js` on `wasm32-unknown-unknown`, `JsPlatform` uses `Date.now`, `setTimeout`, `crypto.getRandomValues`, +and the JavaScript console. The `retry` from step 2 runs unchanged: + +```rust,ignore +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub async fn fetch_with_retries() -> Result { + let platform = loko::JsPlatform::default(); + retry(&platform, || try_once()).await.map_err(JsValue::from) +} ``` -Wrappers stack, and loko has some ready-made: +Futures aren't required to be `Send`, so a resource can wrap JavaScript promises, as `JsClock` does. + +## Reference + +### Resources + +| Resource | Methods | For tests | System | JavaScript (feature `js`) | +|---|---|---|---|---| +| [`Clock`](crates/loko-clock) | `now`, `now_ms`, `sleep` | `TestClock` | `StdClock`, `TokioClock` (feature `tokio`) | `JsClock` | +| [`Random`](crates/loko-random) | `next_u64`, `fill` | `SeededRandom` | `OsRandom` (feature `getrandom`) | `JsRandom` | +| [`Env`](crates/loko-env) | `var`, `vars` | `MapEnv` | `SystemEnv` | `MapEnv` | +| [`Console`](crates/loko-console) | `print_line`, `print_error_line` | `TestConsole` | `StdConsole` | `JsConsole` | +| [`Store`](crates/loko-store) (feature `store`) | get, put, put-if-absent, upload, list, delete | `MemoryStore` | [`ObjectStoreAdapter`](crates/loko-object-store) (feature `object-store`) | the application's binding | + +### Platforms + +| Platform | Clock | Random | Env | Console | Where | +|---|---|---|---|---|---| +| `TestPlatform` | `TestClock` | `SeededRandom` | `MapEnv` | `TestConsole` | Tests, anywhere | +| `StdPlatform` | `StdClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native and WASI, without an async runtime | +| `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native, under tokio (feature `tokio`) | +| `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | Browsers, Workers, and Node (feature `js`) | + +### Wrappers | Wrapper | What it does | |---|---| -| `OffsetClock` | Another clock, ahead or behind by a fixed amount, for testing against clock skew | +| `OffsetClock` | Another clock, ahead or behind by a fixed amount | | `CountingStore` | Counts the calls of each kind it passes on | | `ReadOnlyStore` | Reads through, refuses writes | | `PrefixedStore` | Passes on only URIs under a prefix, refusing `..` and the rest | -`CountingStore::new(ReadOnlyStore::new(PrefixedStore::new(s3, "s3://bucket/tenant-a/")))` is a store that counts -its requests, never writes, and stays inside one tenant's data. +### Macros -A wrapper keeps the inner resource's associated types, and a provided method it doesn't write (such as `now_ms`) -forwards to the inner one too, so write it when it depends on a method the wrapper changes. +| Macro | What it does | +|---|---| +| `#[loko::resource]` | Makes a trait a resource: adds `HasX` and makes every `HasX` an `X` | +| `#[derive(Provides)]` | Makes a struct each resource its `#[provides(…)]` fields provide | +| `#[loko::wraps(field: Type)]` | Makes an `impl X for Wrapper` a wrapper: the methods written replace `X`'s, the rest forward | -## Crates +### Crates | Crate | What it holds | |---|---| | [`loko`](crates/loko) | Everything below, the platforms, and a prelude, with a feature per optional part | -| [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One resource each, with its implementations | +| [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One resource each, with its implementations and wrappers | | [`loko-object-store`](crates/loko-object-store) | A `Store` over an `object_store` client: S3, GCS, Azure, local files, memory | | [`loko-macros`](crates/loko-macros) | `#[resource]`, `#[derive(Provides)]`, and `#[wraps]` | @@ -131,7 +404,7 @@ A library that needs one resource can depend on its crate alone. ## Development ```bash -cargo test --workspace --all-features # tests, offline +cargo test --workspace --all-features # tests, offline, and this README's examples cargo build --workspace --all-features --target wasm32-unknown-unknown # browsers and Workers cargo build --workspace --all-features --target wasm32-wasip2 # WASI prek run --all-files --hook-stage pre-push # formatters and linters diff --git a/crates/loko/src/lib.rs b/crates/loko/src/lib.rs index b5591dd..8478e3e 100644 --- a/crates/loko/src/lib.rs +++ b/crates/loko/src/lib.rs @@ -64,6 +64,11 @@ pub use loko_store::{HasStore, Store}; mod platform; +/// The repository README's examples, run as doctests. +#[cfg(all(doctest, feature = "store", feature = "tokio"))] +#[doc = include_str!("../../../README.md")] +pub struct ReadmeExamples; + /// The resources, the `Has` traits that `#[derive(Provides)]` and `#[wraps]` name next to them, the /// macros, and the platforms. pub mod prelude { diff --git a/spec/0006-store.md b/spec/0006-store.md index 264f5cf..bf808b5 100644 --- a/spec/0006-store.md +++ b/spec/0006-store.md @@ -15,8 +15,7 @@ issues: [6] ## Summary Objects by absolute URI. Code that reads and writes files in object storage takes a `Store`, so the same code works -over S3, a local directory, memory, or a JavaScript binding. It started as lakekit's `Store`, where table formats -read and write through it. +over S3, a local directory, memory, or a JavaScript binding. ## Design @@ -44,7 +43,7 @@ read and write through it. | ID | Decision | Status | |---|---|---| -| D13 | `Store` and the `object_store` adapter moved from lakekit to loko, so any project can use them; lakekit keeps its own error type, converting from `loko_store::Error`. | Accepted | +| D13 | `Store` and the `object_store` adapter are loko crates of their own, so any library can take a store without a storage client; a library with its own error type converts from `loko_store::Error`. | Accepted | ## Open questions and issues @@ -52,6 +51,6 @@ read and write through it. ## History -- 2026-10-20: built for 0.2. Moved from lakekit (`lakekit-core`'s `Store` and `MemoryStore`, and `lakekit-object-store`). -- 2026-10-21: review fixes from lakekit#12: `from_url` keeps the port, empty and past-the-end ranges read as no bytes, +- 2026-10-20: built for 0.2. +- 2026-10-21: review fixes: `from_url` keeps the port, empty and past-the-end ranges read as no bytes, and listing starts from an offset. Wrappers `CountingStore`, `ReadOnlyStore`, and `PrefixedStore` added. diff --git a/spec/README.md b/spec/README.md index 95d789a..5107770 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,6 +1,6 @@ # Specs -loko's design, laid out like lakekit's, Timelake's, and lakeglass's. Start here, people and agents alike. +loko's design. Start here, people and agents alike. - **Specs** (`NNNN-name.md`): one feature each, with the same template ([TEMPLATE.md](TEMPLATE.md)). A spec says what the feature does and why, what is still open (as GitHub issues), how far it is built, and the releases that changed @@ -45,4 +45,4 @@ loko's design, laid out like lakekit's, Timelake's, and lakeglass's. Start here, | Milestone | Status | |---|---| -| [0.2: Resources for the polythings projects](milestones/v0-2.md) | In progress | +| [0.2: First release](milestones/v0-2.md) | In progress | diff --git a/spec/milestones/README.md b/spec/milestones/README.md index 64aa15b..3810755 100644 --- a/spec/milestones/README.md +++ b/spec/milestones/README.md @@ -4,4 +4,4 @@ The roadmap: goals made of specs. A milestone is done, in progress, planned, or | Milestone | Status | Specs | |---|---|---| -| [0.2: Resources for the polythings projects](v0-2.md) | In progress | 1, 2, 3, 4, 5, 6, 7 | +| [0.2: First release](v0-2.md) | In progress | 1, 2, 3, 4, 5, 6, 7 | diff --git a/spec/milestones/v0-2.md b/spec/milestones/v0-2.md index 84b8760..ddd02a2 100644 --- a/spec/milestones/v0-2.md +++ b/spec/milestones/v0-2.md @@ -1,10 +1,9 @@ -# 0.2: Resources for the polythings projects +# 0.2: First release **In progress** The first release as a resources library: `Clock`, `Random`, `Env`, `Console`, and `Store` with test, system, and -JavaScript implementations, the platforms, and the macros for new resources and bundles. Enough for lakekit to take -its store, clock, and randomness from loko instead of defining them, and for Timelake and lakeglass to follow. +JavaScript implementations, the platforms, and the macros for new resources and bundles. | # | Spec | Status | |---:|---|---| @@ -16,5 +15,4 @@ its store, clock, and randomness from loko instead of defining them, and for Tim | 6 | [Store](../0006-store.md) | Done | | 7 | [Platforms](../0007-platforms.md) | Done | -**Done when:** the crates are on crates.io ([#1](https://github.com/polythings/loko/issues/1)) and lakekit depends on -them by version. +**Done when:** the crates are on crates.io ([#1](https://github.com/polythings/loko/issues/1)). From e3e08eff2ae67d15c6332e681187eabc00adfb97 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 01:13:34 -0400 Subject: [PATCH 08/19] COMPARISON.md: loko next to delegate, ambassador, DI crates, tower, and cap-std What each crate is for, when to use which, what loko does that they don't (ships the resources; async without boxing; no Send/Sync/'static; checked on WebAssembly; wrappers that write only what they change) and what it doesn't (containers, scopes, runtime selection, graph building). Checked against the crates' current versions, sources, and issues. Linked from the README. Co-Authored-By: Claude Opus 5.5 --- COMPARISON.md | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 6 ++ 2 files changed, 161 insertions(+) create mode 100644 COMPARISON.md diff --git a/COMPARISON.md b/COMPARISON.md new file mode 100644 index 0000000..94a9e45 --- /dev/null +++ b/COMPARISON.md @@ -0,0 +1,155 @@ +# How loko compares + +loko does two things: + +1. **It ships resources.** `Clock`, `Random`, `Env`, `Console`, and `Store` come with an implementation for tests, one + for the system, and one for JavaScript hosts, plus platforms that bundle them and wrappers that change them. +2. **It has a small macro layer.** `#[resource]`, `#[derive(Provides)]`, and `#[wraps]` let your own traits work the + same way. + +Each crate below overlaps with one of those. The comparisons were checked against these versions (October 2026): +delegate 0.13.5, ambassador 0.5.1, fundle 0.4.0, shaku 0.6.3, nject 0.5.1, service-builder 0.3.0, dill 0.17.2, +dependency-injector 2.1.0, ferrous-di 0.2.0, reinhardt-di 0.3.25, tower 0.5.3, and cap-std 4.0.3. + +## In short + +| Crate | What it is | Use it when | +|---|---|---| +| [delegate](https://crates.io/crates/delegate) | A macro that writes forwarding methods you list | You want a few methods forwarded to a field, inherent or trait | +| [ambassador](https://crates.io/crates/ambassador) | A derive that forwards a whole trait to a field or enum variants | A type should implement a trait entirely through a field, or an enum through its variants | +| [fundle](https://crates.io/crates/fundle) | A builder for a struct of dependencies, built in order | Many components need each other while being built | +| [shaku](https://crates.io/crates/shaku) | A compile-time-checked container of trait objects | A server needs singletons and per-request instances, with axum, actix, or Rocket integration | +| [nject](https://crates.io/crates/nject) | Compile-time providers that build types from their fields | You want constructors generated from a provider, including in `no_std` | +| [service-builder](https://crates.io/crates/service-builder) | A builder-pattern macro for structs of services | You want a builder per service struct | +| [dill](https://crates.io/crates/dill), [dependency-injector](https://crates.io/crates/dependency-injector), [ferrous-di](https://crates.io/crates/ferrous-di) | Runtime containers: register, then resolve by type | Implementations are chosen at run time, or you want Java/.NET-style lifetimes | +| [reinhardt-di](https://crates.io/crates/reinhardt-di) | FastAPI-style injection for the Reinhardt web framework | You build on Reinhardt | +| [tower](https://crates.io/crates/tower) | Request-to-response services and middleware layers | You compose network clients and servers | +| [cap-std](https://crates.io/crates/cap-std) | The standard library's file system and network through capability handles | Code must not reach files outside a directory it was given | +| loko | Resources with test, system, and JavaScript implementations, and the macros to add your own | Code should get the time, randomness, environment, console, or storage passed in, and run in tests, natively, and in the browser | + +## Forwarding: delegate and ambassador + +These are the closest to loko's macros. + +- **delegate** writes methods that forward to a field: `delegate! { to self.inner { fn get(&self, key: &str) -> + Option; } }`. You list each signature. Methods you don't list you write yourself, so a trait impl can forward + some methods and write others. `async fn` gets an `.await`, and generic methods work. Associated types are yours to + write. +- **ambassador** forwards a whole trait. Mark the trait `#[delegatable_trait]` (or copy a foreign trait's signature + with `#[delegatable_trait_remote]`), and derive `#[delegate(Trait, target = "field")]`. It forwards to enum variants + too, and its tests cover associated types, generic methods, GATs, `async fn`, and `-> impl Future` methods. + Forwarding is all or nothing: overriding some methods is an open request (issues #13 and #67). + +**What loko adds:** +- `#[derive(Provides)]` is ambassador's idea. Credited in [PRIOR_ART.md](PRIOR_ART.md). +- `#[resource]` also generates a `HasX` trait. Code can bound on it, and get the held resource with `.x()`. +- `#[wraps]` forwards everything you don't write, without listing signatures. That combines what delegate and + ambassador each do half of. + +**Use them instead** for forwarding inherent methods (delegate), for enums (ambassador), for traits you can't mark +(ambassador's remote traits), or anywhere you don't need loko's resources. + +## Compile-time wiring: fundle, nject, shaku, and service-builder + +These build a graph of components and check at compile time that every dependency is there. + +- **fundle** generates a builder for a struct of dependencies: + `AppState::builder().logger(|_| Logger::new()).database(|x| Database::connect(url, x)).build()`. Each field is set + once, and its closure sees the fields set before it, so construction happens in order. It implements `AsRef` for + each field type that appears once, and components take `impl AsRef`. It hands out concrete types, with no + trait objects and no `Send` requirement, and has async and fallible setters. +- **nject** generates construction from providers: `provider.provide::()` builds a type from its fields, + sharing what the provider holds by reference. It works with generics and with `&dyn` or `Box` traits, has + scopes, and is `no_std`. +- **shaku** declares components (singletons) and providers (a new instance each time) in a `module!`, injects them into + fields as `Arc`, and resolves them with `module.resolve_ref()`. Interfaces must be `Send + Sync + 'static` + with the default `thread_safe` feature (`'static` without it), and must be dyn-compatible. For async methods it + suggests `async-trait`. It has test overrides (`with_component_override`) and integrations for axum, actix, and + Rocket. +- **service-builder** generates `Service::builder().dependency(…).build()`. `build()` returns a `Result`, and a missing + dependency is a `BuildError::MissingDependency` at run time. + +**Where loko differs:** +- **It doesn't build a graph.** A loko bundle is a struct literal you write in `main` or in a test. Construction order + is ordinary code. +- **It decides what code may use, and with which implementation.** Code bounds on traits (`impl Store + Clock`), not on + concrete types (fundle's `AsRef`). +- **No trait objects.** loko never needs `dyn`, so resources with `-> impl Future` methods (`Store`, `Clock::sleep`) + work as they are. shaku would need them boxed through `async-trait`. +- **No `Send`, `Sync`, or `'static` requirement,** so JavaScript-backed resources work. +- **It ships the resources:** test clocks, seeded randomness, an in-memory store, consoles that record. These crates + wire your types; you bring the implementations, test ones included. + +**Use them alongside or instead** when a large application has many components that need each other at +construction (fundle), or when a server needs per-request instances next to singletons and framework integration +(shaku). They combine with loko: fundle can build the struct that `#[derive(Provides)]` then exposes as resources, and +a shaku component can hold a loko platform. + +## Runtime containers: dill, dependency-injector, ferrous-di, and reinhardt-di + +These register implementations in a container and resolve them by type at run time, as Java and .NET containers do. + +- **dill** has a `Catalog`, `#[component]` constructor injection, `Arc` interfaces, `OneOf`, `AllOf`, + `Maybe`, and `Lazy`, and transient, singleton, and transaction lifetimes. Components are `Send + Sync`. +- **dependency-injector** resolves `get::()` to `Arc` from a lock-free map, with singletons, lazy singletons, + transients, and child scopes that can override their parent's services. Services are `Send + Sync + 'static`. +- **ferrous-di** follows Microsoft.Extensions.DependencyInjection: a `ServiceCollection` of factories, `Arc` and + `Arc`, and singleton, scoped, and transient lifetimes. Everything is `Send + Sync + 'static`; its `async` + feature uses `async-trait` and tokio's multi-threaded runtime. +- **reinhardt-di** is FastAPI-style injection for the Reinhardt web framework: `Depends<…>` in handlers, async + providers, request, singleton, and transient scopes, and test overrides. Injectables are `Send + Sync + 'static`, it + uses `async-trait`, and its README says resolution runs only on native targets. + +**Where loko differs:** nothing is resolved at run time, so a missing resource is a compile error, not a startup +failure. There's no registry to configure, and calls are statically dispatched. loko also has no lifetimes or scopes, +and no choosing implementations from configuration at run time. + +**Use them instead** when the implementation is only known at run time (plugins, configuration), or when you want a +container's lifetimes and are fine with trait objects and `Send + Sync`. + +## Middleware: tower + +tower's `Service` is one generic trait, a request in and a future of a response out, and its `Layer` wraps a service in +another. Timeouts, retries, rate limits, and load balancing stack around any client or server. + +**Where loko differs:** +- **Many specific traits instead of one generic one.** Wrapping is one feature among several, not the point of the + library. +- **The same stacking idea, applied to resources.** loko's wrappers stack the way tower's layers do (credited in + [PRIOR_ART.md](PRIOR_ART.md)), but there's no `Layer` trait yet ([#7](https://github.com/polythings/loko/issues/7)). +- **Complementary:** a tower retry layer could take its backoff from a loko `Clock`, so tests could skip the waits. + +## Capabilities: cap-std + +cap-std gives the standard library's file system and network through handles. A program opens files through a `Dir` +it was given, not by path, so it can't reach outside it. It's a foundation for WASI in Wasmtime. + +**Where loko differs:** the idea is shared, passing access instead of reaching for globals, but the aim isn't. +- **cap-std confines.** It's about security, and it covers files and sockets. +- **loko substitutes.** It's about swapping implementations for tests and platforms, and it covers the time, + randomness, the environment, the console, and object storage. +- **Closest overlap:** `PrefixedStore` (URIs under a prefix, refusing `..`) is the nearest loko comes to cap-std's + confinement. + +## What loko does that the others don't + +- **Ships the resources.** A test clock whose `sleep` moves time instead of waiting, seeded randomness, an environment + in a map, a console that records lines, and an in-memory store with conditional writes. Each has system and + JavaScript counterparts and the same interface. The other crates wire up implementations; none ships these. +- **Async without boxing.** Resources return `impl Future`, and loko passes them as generics, never as `dyn`, so + there's no `async-trait` and no heap allocation per call. +- **No `Send`, `Sync`, or `'static` requirement.** Futures that wrap JavaScript promises aren't `Send`. The containers + above require `Send + Sync`; shaku requires it unless `thread_safe` is turned off. +- **Checked on WebAssembly.** CI builds every crate for `wasm32-unknown-unknown` and `wasm32-wasip2`, and the + JavaScript implementations use the host's clock, timers, `crypto.getRandomValues`, and console. +- **Wrappers that write only what they change,** with `#[wraps]`, and ready-made ones that stack. + +## What loko doesn't do + +- **No container or resolution by type.** Bundles are structs you write. +- **No lifetimes or scopes** (singleton, transient, per request), and no field injection. +- **No choosing implementations at run time** from configuration or plugins. +- **No help building a graph in order,** and no web framework integrations. + +For those, pair loko with the crate that does them, or use that crate on its own. For a program with a handful of +resources, chosen once and swapped in tests and in the browser, loko and ordinary Rust are enough. diff --git a/README.md b/README.md index 883863a..1990ff6 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,12 @@ pub async fn fetch_with_retries() -> Result { Futures aren't required to be `Send`, so a resource can wrap JavaScript promises, as `JsClock` does. +## Related crates + +[COMPARISON.md](COMPARISON.md) compares loko with forwarding macros (delegate, ambassador), dependency-injection crates +(fundle, shaku, nject, service-builder, dill, dependency-injector, ferrous-di, reinhardt-di), tower, and cap-std: what +each is for, when to use which, and what loko does differently. + ## Reference ### Resources From c6d49dab06cc2ef2a12f9156c29e8af21d56d035 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 01:35:38 -0400 Subject: [PATCH 09/19] Docs: why resources take &self, and how their state stays safe A README step (with a tested example), the crate docs, #[resource]'s docs, CONTRIBUTING, and spec 1 say why every resource method takes &self (one value as several resources, clones that share state, use across .await) and the rules for state that changes: lock only for the synchronous part, never hold a Mutex guard or RefCell borrow across .await (Clippy warns), atomics for counters, clones share state. Also when a lock per call matters (SeededRandom in hot loops) and what to do. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 3 ++ README.md | 54 +++++++++++++++++++++++++++++++++-- crates/loko-macros/src/lib.rs | 4 ++- crates/loko/src/lib.rs | 5 ++++ spec/0001-resources.md | 4 +++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3c844b..58d69f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,9 @@ we took. - **Resources:** a trait marked `#[loko_macros::resource]`, methods on `&self`, async methods returning `impl Future` without `Send`. Implementations that hold state share it between clones (`Arc>`). +- **State in resources:** methods take `&self`, so state that changes sits behind a `Mutex` or an atomic (counters). + Lock only for the synchronous part of a call: never hold a `std::sync::Mutex` guard or a `RefCell` borrow across + `.await` (Clippy's `await_holding_lock` and `await_holding_refcell_ref` catch it). Clones share state. - **Errors:** what the platform gives (`io::Result` for the console), or the crate's own `non_exhaustive` error enum (`loko_store::Error`). No `unwrap()` outside tests; a poisoned lock is recovered, not a panic. - **Layout:** the trait in `lib.rs`, and each implementation in a module named for where it runs: `testing.rs`, diff --git a/README.md b/README.md index 1990ff6..9e73f9d 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,57 @@ async fn main() -> std::io::Result<()> { } ``` -### 8. Wrappers +### 8. State in your own resources + +Every resource method takes `&self`, never `&mut self`. That's what lets one value be several resources at once +(`greet(resources, resources)` in step 3), lets clones share state (a test keeps one `TestClock` and advances the one +the code holds), and lets resources be used across `.await`. A resource whose state changes keeps it behind a lock or +an atomic, as `TestMailer` above does with its `Mutex`. Three rules keep that safe: + +- **Lock only for the synchronous part.** Don't hold a `std::sync::Mutex` guard or a `RefCell` borrow across an + `.await`. While the task waits, another task can use the same resource and try to take the lock: a `Mutex` then + blocks that thread for good (on a single-threaded runtime or in the browser, the whole program stops), and a + `RefCell` panics. Clippy warns about both (`await_holding_lock`, `await_holding_refcell_ref`). If state really must + stay locked while waiting, use an async mutex such as `tokio::sync::Mutex`. +- **Counters are atomics,** which need no lock. +- **Clones share state.** Cloning a resource that holds an `Arc` gives a second handle to the same state, not a copy. + +```rust +use std::sync::Mutex; +use std::time::Duration; + +use loko::{Clock, TestPlatform}; + +/// Remembers when each job ran. +struct Jobs { + runs: Mutex>, +} + +impl Jobs { + async fn run(&self, clock: &impl Clock) { + // Wait first, holding nothing; then lock, change, and unlock in one statement. + clock.sleep(Duration::from_secs(1)).await; + self.runs.lock().unwrap().push(clock.now_ms()); + // Not this: `let mut runs = self.runs.lock().unwrap(); clock.sleep(…).await; runs.push(…);` + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let jobs = Jobs { runs: Mutex::new(vec![]) }; + let platform = TestPlatform::new(); + jobs.run(&platform).await; + jobs.run(&platform).await; + assert_eq!(*jobs.runs.lock().unwrap(), [1_000, 2_000]); +} +``` + +A lock taken and released at once costs tens of nanoseconds, which matters only in hot loops. The one that comes up is +`SeededRandom`, which locks for every number: code that draws millions of numbers seeds a local generator from the +resource once (`SmallRng::seed_from_u64(resources.next_u64())`) and draws from that. The system implementations +(`StdClock`, `OsRandom`, `SystemEnv`, `StdConsole`) hold no state and take no lock. + +### 9. Wrappers A wrapper holds one implementation of a resource and is one itself, changing some methods. `#[loko::wraps(field: Type)]` on its `impl` forwards every method it doesn't write to the field. @@ -335,7 +385,7 @@ async fn main() { A wrapper keeps the inner resource's associated types, and a provided method it doesn't write (such as `now_ms`) forwards to the inner one too, so write it when it depends on a method the wrapper changes. -### 9. In the browser +### 10. In the browser With feature `js` on `wasm32-unknown-unknown`, `JsPlatform` uses `Date.now`, `setTimeout`, `crypto.getRandomValues`, and the JavaScript console. The `retry` from step 2 runs unchanged: diff --git a/crates/loko-macros/src/lib.rs b/crates/loko-macros/src/lib.rs index a8668f3..2bb40e9 100644 --- a/crates/loko-macros/src/lib.rs +++ b/crates/loko-macros/src/lib.rs @@ -24,7 +24,9 @@ use syn::{ }; /// Makes a trait a loko resource: adds `Has` and implements the trait for every type that -/// has one. Methods must take `&self`; the trait can't be generic. +/// has one. Methods must take `&self`, so one value can be several resources and clones can share +/// state; an implementation whose state changes keeps it behind a lock or an atomic, never held +/// across `.await`. The trait can't be generic. #[proc_macro_attribute] pub fn resource(args: TokenStream, input: TokenStream) -> TokenStream { if !args.is_empty() { diff --git a/crates/loko/src/lib.rs b/crates/loko/src/lib.rs index 8478e3e..b35674c 100644 --- a/crates/loko/src/lib.rs +++ b/crates/loko/src/lib.rs @@ -10,6 +10,11 @@ //! provide, and [`wraps`] makes a wrapper that changes some of a resource's methods and forwards //! the rest. [`prelude`] brings in the resources with the `HasClock`-style traits these name. //! +//! Resource methods take `&self`, so one value can be several resources and clones can share state. +//! A resource whose state changes keeps it behind a lock or an atomic, locked only for the +//! synchronous part of a call: a `std::sync::Mutex` guard or `RefCell` borrow held across `.await` +//! deadlocks or panics when another task uses the resource meanwhile. +//! //! ``` //! use std::time::Duration; //! diff --git a/spec/0001-resources.md b/spec/0001-resources.md index a6c6634..bbd6623 100644 --- a/spec/0001-resources.md +++ b/spec/0001-resources.md @@ -40,6 +40,10 @@ anyone define new resources, bundle them with loko's, and wrap one implementatio associated types and constants, and a provided method it doesn't write forwards to the held one, so a wrapper writes the provided methods that depend on what it changes (`OffsetClock` writes `now_ms` as well as `now`). Wrappers stack: `CountingStore>`. +- **`&self` and state:** every method takes `&self`, so one value can be several resources (`f(r, r)`), clones can share + state, and resources can be held across `.await`. An implementation whose state changes keeps it behind a lock or an + atomic, locked only for the synchronous part of a call; a lock or `RefCell` borrow held across `.await` deadlocks or + panics when another task uses the resource meanwhile. - **Composition:** an implementation of one resource can hold others (a greeter that holds a clock), and a program's bundle can mix loko's resources with its own. Code asks only for what it uses: `&(impl Store + Clock)`. - **Crates:** one per resource (`loko-clock`, `loko-random`, `loko-env`, `loko-console`, `loko-store`), each with its From 112fb3e81114aef2c6a060eb1c00366cf4927f5b Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 02:51:09 -0400 Subject: [PATCH 10/19] Random: a source of generators (rng()), not of numbers Random is now `type Rng: rand_core::Rng` and `rng(&self) -> Self::Rng`: a generator of the caller's own, drawn from with &mut and no locks, usable with the rand ecosystem. SeededRandom hands out SeededRng (SplitMix64) forked from its seeded sequence; OsRandom hands out OsRng and JsRandom JsRng, both CryptoRng, so code that makes secrets asks for Random. A Mutex around any seedable rand_core generator is a Random that forks it. rand_core is always a dependency, so the rand feature is gone. JsRng panics on a host without crypto.getRandomValues instead of falling back to Math.random. Rng and CryptoRng are re-exported, and Rng is in the prelude. Spec 3 records D9. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 - CHANGELOG.md | 6 +- PRIOR_ART.md | 2 +- README.md | 24 +++---- crates/loko-random/Cargo.toml | 4 +- crates/loko-random/README.md | 27 +++++--- crates/loko-random/src/js.rs | 78 ++++++++++++++-------- crates/loko-random/src/lib.rs | 51 +++++++------- crates/loko-random/src/rand.rs | 26 ++++++-- crates/loko-random/src/system.rs | 54 ++++++++++++--- crates/loko-random/src/testing.rs | 106 +++++++++++++++++++++++------- crates/loko/Cargo.toml | 2 - crates/loko/README.md | 7 +- crates/loko/src/lib.rs | 11 ++-- crates/loko/src/platform.rs | 5 +- crates/loko/tests/resources.rs | 2 +- spec/0003-random.md | 34 ++++++---- spec/README.md | 2 +- 18 files changed, 295 insertions(+), 148 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f51131d..1d2c3a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,14 +51,12 @@ jobs: cargo check -p loko --no-default-features cargo check -p loko cargo check -p loko --features tokio - cargo check -p loko --features rand cargo check -p loko --features store cargo check -p loko --features object-store cargo check -p loko-clock --no-default-features cargo check -p loko-clock --features tokio cargo check -p loko-random --no-default-features cargo check -p loko-random --features getrandom - cargo check -p loko-random --features rand cargo check -p loko-env cargo check -p loko-console cargo check -p loko-store diff --git a/CHANGELOG.md b/CHANGELOG.md index 3acc831..1c56ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,10 @@ unrelated 0.1.0 from 2022 (task scripting), by the same owner. - `loko-clock`: the `Clock` resource (`now`, `now_ms`, `sleep`), `TestClock`, `StdClock`, `TokioClock` (feature `tokio`), `JsClock` (feature `js`), and `OffsetClock`, a clock ahead or behind another. -- `loko-random`: the `Random` resource (`next_u64`, `fill`), `SeededRandom` (SplitMix64), `OsRandom` (feature - `getrandom`), `JsRandom` (feature `js`), and `Random` for a `Mutex` around a `rand_core` generator (feature `rand`). +- `loko-random`: the `Random` resource, a source of `rand_core` generators (`rng()`, drawn from with no locks); + `SeededRandom` (`SeededRng`, SplitMix64, for tests), `OsRandom` (feature `getrandom`, `OsRng`), `JsRandom` (feature + `js`, `JsRng`), and `Random` for a `Mutex` around any seedable `rand_core` generator. The OS and JavaScript generators + are `CryptoRng`, so code that makes secrets can ask for `Random`. - `loko-env`: the `Env` resource (`var`, `vars`), `SystemEnv`, and `MapEnv`. - `loko-console`: the `Console` resource (`print_line`, `print_error_line`), `StdConsole`, `TestConsole`, and `JsConsole` (feature `js`). diff --git a/PRIOR_ART.md b/PRIOR_ART.md index 188725f..552639e 100644 --- a/PRIOR_ART.md +++ b/PRIOR_ART.md @@ -9,7 +9,7 @@ never code: every line of loko is our own. How we credit is in [CONTRIBUTING.md] |---|---|---| | [tokio](https://github.com/tokio-rs/tokio) | MIT | The timer behind `TokioClock` | | [getrandom](https://github.com/rust-random/getrandom) | Apache-2.0 / MIT | The operating system's generator behind `OsRandom` | -| [rand_core](https://github.com/rust-random/rand) | Apache-2.0 / MIT | The generator trait `Random` accepts (feature `rand`) | +| [rand_core](https://github.com/rust-random/rand) | Apache-2.0 / MIT | The generator traits (`Rng`, `CryptoRng`, `SeedableRng`) of the generators `Random` hands out, and `fork` | | [wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen) (`js-sys`, `wasm-bindgen-futures`) | Apache-2.0 / MIT | The JavaScript implementations | | [object_store](https://github.com/apache/arrow-rs-object-store) | Apache-2.0 / MIT | The stores `loko-object-store` adapts | | [syn](https://github.com/dtolnay/syn), [quote](https://github.com/dtolnay/quote) | Apache-2.0 / MIT | The macros | diff --git a/README.md b/README.md index 9e73f9d..368d4d0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ loko = { version = "0.2", features = ["tokio", "store"] } | `getrandom` (default) | `OsRandom` and `StdPlatform` (native and WASI) | | `tokio` | `TokioClock` and `TokioPlatform` (native) | | `js` | `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown`) | -| `rand` | `Random` for a `Mutex` around a `rand_core` generator | | `store` | `loko::store`: the `Store` resource, `MemoryStore`, and the store wrappers | | `object-store` | `loko::object_store`: a `Store` over an `object_store` client (S3, GCS, Azure, local files) | @@ -49,24 +48,25 @@ assert_eq!(stamp(&clock, "started"), "[1000] started"); ### 2. Waiting without waiting `sleep` is a `Clock` method too. A test clock's sleep returns at once and moves the clock forward, so code that waits -(retries, timeouts, polling) runs in tests without waiting. Random numbers come from a `Random`, so the jitter is the -same on every test run. +(retries, timeouts, polling) runs in tests without waiting. Random numbers come from a generator that a `Random` +hands out (`rng()`), so the jitter is the same on every test run. ```rust use std::time::Duration; -use loko::{Clock, Random, TestPlatform}; +use loko::{Clock, Random, Rng, TestPlatform}; /// Calls `attempt` up to five times, waiting one to two seconds between tries. async fn retry( resources: &(impl Clock + Random), mut attempt: impl FnMut() -> Result, ) -> Result { + let mut rng = resources.rng(); let mut tries = 1; loop { match attempt() { Err(_) if tries < 5 => { - let jitter = resources.next_u64() % 1_000; + let jitter = rng.next_u64() % 1_000; resources.sleep(Duration::from_millis(1_000 + jitter)).await; tries += 1; } @@ -196,7 +196,7 @@ struct Uploader { impl Uploader { async fn upload(&self, body: Vec) -> loko::store::Result { let r = &self.resources; - let uri = format!("{}/{}-{:04x}", self.prefix, r.now_ms(), r.next_u64() & 0xffff); + let uri = format!("{}/{}-{:04x}", self.prefix, r.now_ms(), r.rng().next_u64() & 0xffff); r.put(&uri, body).await?; Ok(uri) } @@ -216,7 +216,7 @@ async fn main() -> loko::store::Result<()> { resources: Test { platform: TestPlatform::new(), store: MemoryStore::new() }, prefix: "memory://uploads".into(), }; - assert_eq!(uploader.upload(b"x".to_vec()).await?, "memory://uploads/0-cdaf"); + assert_eq!(uploader.upload(b"x".to_vec()).await?, "memory://uploads/0-7e6f"); Ok(()) } ``` @@ -321,10 +321,10 @@ async fn main() { } ``` -A lock taken and released at once costs tens of nanoseconds, which matters only in hot loops. The one that comes up is -`SeededRandom`, which locks for every number: code that draws millions of numbers seeds a local generator from the -resource once (`SmallRng::seed_from_u64(resources.next_u64())`) and draws from that. The system implementations -(`StdClock`, `OsRandom`, `SystemEnv`, `StdConsole`) hold no state and take no lock. +A lock taken and released at once costs tens of nanoseconds, which matters only in hot loops. That's why `Random` +hands out generators instead of numbers: `resources.rng()` locks at most once, and drawing from the generator it +returns takes no lock at all, however many numbers you draw. The system implementations (`StdClock`, `OsRandom`, +`SystemEnv`, `StdConsole`) hold no state and take no lock. ### 9. Wrappers @@ -415,7 +415,7 @@ each is for, when to use which, and what loko does differently. | Resource | Methods | For tests | System | JavaScript (feature `js`) | |---|---|---|---|---| | [`Clock`](crates/loko-clock) | `now`, `now_ms`, `sleep` | `TestClock` | `StdClock`, `TokioClock` (feature `tokio`) | `JsClock` | -| [`Random`](crates/loko-random) | `next_u64`, `fill` | `SeededRandom` | `OsRandom` (feature `getrandom`) | `JsRandom` | +| [`Random`](crates/loko-random) | `rng`, a generator of your own | `SeededRandom` | `OsRandom` (feature `getrandom`) | `JsRandom` | | [`Env`](crates/loko-env) | `var`, `vars` | `MapEnv` | `SystemEnv` | `MapEnv` | | [`Console`](crates/loko-console) | `print_line`, `print_error_line` | `TestConsole` | `StdConsole` | `JsConsole` | | [`Store`](crates/loko-store) (feature `store`) | get, put, put-if-absent, upload, list, delete | `MemoryStore` | [`ObjectStoreAdapter`](crates/loko-object-store) (feature `object-store`) | the application's binding | diff --git a/crates/loko-random/Cargo.toml b/crates/loko-random/Cargo.toml index da4c951..116cb6d 100644 --- a/crates/loko-random/Cargo.toml +++ b/crates/loko-random/Cargo.toml @@ -16,12 +16,10 @@ default = [] getrandom = ["dep:getrandom"] # `JsRandom`: `crypto.getRandomValues` (`wasm32-unknown-unknown` with a JavaScript host). js = ["dep:js-sys", "dep:wasm-bindgen"] -# `Random` for a `Mutex` around any `rand_core` generator. -rand = ["dep:rand_core"] [dependencies] loko-macros.workspace = true -rand_core = { workspace = true, optional = true } +rand_core.workspace = true [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] getrandom = { workspace = true, optional = true } diff --git a/crates/loko-random/README.md b/crates/loko-random/README.md index 0d29455..2250373 100644 --- a/crates/loko-random/README.md +++ b/crates/loko-random/README.md @@ -1,19 +1,28 @@ # loko-random -[loko](https://github.com/polythings/loko)'s `Random` resource: random numbers, passed to code instead of drawn from a -global source, so tests are repeatable and the same code runs natively, under WASI, and in browsers. +[loko](https://github.com/polythings/loko)'s `Random` resource: a source of random number generators, passed to code +instead of drawn from a global, so tests are repeatable and the same code runs natively, under WASI, and in browsers. -- `Random`: `next_u64` and `fill`. -- `SeededRandom`: a repeatable sequence from a seed (SplitMix64). Not for secrets. Clones share the sequence. -- `OsRandom` (feature `getrandom`): the operating system's generator (native and WASI). -- `JsRandom` (feature `js`, `wasm32-unknown-unknown`): `crypto.getRandomValues`. -- Feature `rand`: any `rand_core` generator in a `Mutex` is a `Random`. +- `Random::rng()` hands out a generator of the caller's own: a `rand_core::Rng`, drawn from with `&mut` and no locks, + and usable with anything in the `rand` ecosystem. +- `SeededRandom`: `SeededRng`s (SplitMix64) forked from one seeded sequence, the same on every run. For tests; not for + secrets. Clones share the sequence. +- `OsRandom` (feature `getrandom`): `OsRng`, the operating system's generator (native and WASI). A `CryptoRng`. +- `JsRandom` (feature `js`, `wasm32-unknown-unknown`): `JsRng`, `crypto.getRandomValues`. A `CryptoRng`. +- A `Mutex` around any seedable `rand_core` generator is a `Random` whose generators are forked from it. ```rust -use loko_random::{Random, SeededRandom}; +use loko_random::{CryptoRng, Random, Rng, SeededRandom}; let (a, b) = (SeededRandom::new(7), SeededRandom::new(7)); -assert_eq!(a.next_u64(), b.next_u64()); +assert_eq!(a.rng().next_u64(), b.rng().next_u64()); + +/// Secrets ask for a cryptographic generator, so a SeededRandom can't be passed here by mistake. +fn token(random: &impl Random) -> [u8; 32] { + let mut bytes = [0; 32]; + random.rng().fill_bytes(&mut bytes); + bytes +} ``` Apache License 2.0. diff --git a/crates/loko-random/src/js.rs b/crates/loko-random/src/js.rs index 5d79656..797db4b 100644 --- a/crates/loko-random/src/js.rs +++ b/crates/loko-random/src/js.rs @@ -1,44 +1,66 @@ +use std::convert::Infallible; + use js_sys::{Function, Reflect, Uint8Array}; -use wasm_bindgen::JsCast; +use rand_core::{TryCryptoRng, TryRng}; +use wasm_bindgen::{JsCast, JsValue}; use crate::Random; /// `crypto.getRandomValues` of the JavaScript host (feature `js`, `wasm32-unknown-unknown`): -/// browsers, Workers, and Node. Hosts without it fall back to `Math.random`. +/// browsers, Workers, Deno, and Node. #[derive(Clone, Copy, Debug, Default)] pub struct JsRandom; impl Random for JsRandom { - fn next_u64(&self) -> u64 { + type Rng = JsRng; + + fn rng(&self) -> JsRng { + JsRng + } +} + +/// Numbers from `crypto.getRandomValues`; fit for secrets. Panics on a host without Web Crypto, +/// rather than handing out numbers that aren't. +#[derive(Clone, Copy, Debug, Default)] +pub struct JsRng; + +fn get_random_values() -> (JsValue, Function) { + let crypto = Reflect::get(&js_sys::global(), &"crypto".into()).ok().filter(|c| !c.is_undefined()); + let function = crypto + .as_ref() + .and_then(|c| Reflect::get(c, &"getRandomValues".into()).ok()) + .and_then(|f| f.dyn_into::().ok()); + match (crypto, function) { + (Some(crypto), Some(function)) => (crypto, function), + _ => panic!("the JavaScript host has no crypto.getRandomValues"), + } +} + +impl TryRng for JsRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + let mut bytes = [0u8; 4]; + self.try_fill_bytes(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) + } + + fn try_next_u64(&mut self) -> Result { let mut bytes = [0u8; 8]; - self.fill(&mut bytes); - u64::from_le_bytes(bytes) + self.try_fill_bytes(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) } - fn fill(&self, dest: &mut [u8]) { - let crypto = Reflect::get(&js_sys::global(), &"crypto".into()).ok().filter(|c| !c.is_undefined()); - let get_random_values = crypto - .as_ref() - .and_then(|c| Reflect::get(c, &"getRandomValues".into()).ok().and_then(|f| f.dyn_into::().ok())); + fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> { + let (crypto, function) = get_random_values(); // getRandomValues fills at most 65,536 bytes per call. - for chunk in dest.chunks_mut(65_536) { - let filled = match (&crypto, &get_random_values) { - (Some(crypto), Some(f)) => { - let array = Uint8Array::new_with_length(u32::try_from(chunk.len()).unwrap_or(u32::MAX)); - f.call1(crypto, &array).is_ok() && { - array.copy_to(chunk); - true - } - } - _ => false, - }; - if !filled { - for b in chunk.iter_mut() { - #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a byte")] - let byte = (js_sys::Math::random() * 256.0) as u8; - *b = byte; - } - } + for chunk in dst.chunks_mut(65_536) { + let array = Uint8Array::new_with_length(u32::try_from(chunk.len()).unwrap_or(u32::MAX)); + function.call1(&crypto, &array).expect("crypto.getRandomValues"); + array.copy_to(chunk); } + Ok(()) } } + +impl TryCryptoRng for JsRng {} diff --git a/crates/loko-random/src/lib.rs b/crates/loko-random/src/lib.rs index 4f119d8..65f0296 100644 --- a/crates/loko-random/src/lib.rs +++ b/crates/loko-random/src/lib.rs @@ -1,44 +1,49 @@ -//! loko's `Random` resource: random numbers, passed to code instead of drawn from a global source, so -//! tests are repeatable and the same code runs natively and in WebAssembly. +//! loko's `Random` resource: a source of random number generators, passed to code instead of drawn +//! from a global, so tests are repeatable and the same code runs natively and in WebAssembly. //! -//! - [`Random`]: `next_u64` and `fill`. -//! - [`SeededRandom`]: a repeatable sequence from a seed, for tests. -//! - `OsRandom` (feature `getrandom`): the operating system's generator, on native targets and WASI. -//! - `JsRandom` (feature `js`): `crypto.getRandomValues`, in browsers, Workers, and Node. -//! - Feature `rand`: any `rand_core` generator in a `Mutex` is a `Random`. +//! [`Random::rng`] hands out a generator of the caller's own, a [`rand_core::Rng`] drawn from with +//! `&mut` and no locks, and usable with anything in the `rand` ecosystem. Each implementation chooses +//! the generator: +//! +//! - [`SeededRandom`]: [`SeededRng`]s forked from one seeded sequence, the same on every run; for +//! tests, not for secrets. +//! - `OsRandom` (feature `getrandom`): `OsRng`, the operating system's generator, on native targets +//! and WASI. A [`CryptoRng`]. +//! - `JsRandom` (feature `js`): `JsRng`, `crypto.getRandomValues`, in browsers, Workers, and Node. +//! A [`CryptoRng`]. +//! - A `Mutex` around any seedable `rand_core` generator: generators forked from it. +//! +//! Code that makes secrets asks for a cryptographic generator: `random: &impl Random`. //! //! ``` -//! use loko_random::{Random, SeededRandom}; +//! use loko_random::{Random, Rng, SeededRandom}; //! //! let (a, b) = (SeededRandom::new(7), SeededRandom::new(7)); -//! assert_eq!(a.next_u64(), b.next_u64()); +//! let (mut x, mut y) = (a.rng(), b.rng()); +//! assert_eq!(x.next_u64(), y.next_u64(), "the same seed gives the same numbers"); //! ``` #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] mod js; -#[cfg(feature = "rand")] mod rand; #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] mod system; mod testing; #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -pub use js::JsRandom; +pub use js::{JsRandom, JsRng}; +pub use rand_core::{self, CryptoRng, Rng, SeedableRng}; #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] -pub use system::OsRandom; -pub use testing::SeededRandom; +pub use system::{OsRandom, OsRng}; +pub use testing::{SeededRandom, SeededRng}; -/// Random numbers. +/// A source of random number generators. #[loko_macros::resource] pub trait Random { - /// A random number. - fn next_u64(&self) -> u64; + /// The generator [`rng`](Random::rng) hands out. + type Rng: rand_core::Rng; - /// Fills `dest` with random bytes. - fn fill(&self, dest: &mut [u8]) { - for chunk in dest.chunks_mut(8) { - let bytes = self.next_u64().to_le_bytes(); - chunk.copy_from_slice(&bytes[..chunk.len()]); - } - } + /// A generator of the caller's own: draw from it with `&mut` as much as needed, with no locks. + /// Whether it is fit for secrets depends on the implementation: ask for `Random`. + fn rng(&self) -> Self::Rng; } diff --git a/crates/loko-random/src/rand.rs b/crates/loko-random/src/rand.rs index d3c1559..d29973d 100644 --- a/crates/loko-random/src/rand.rs +++ b/crates/loko-random/src/rand.rs @@ -1,14 +1,28 @@ use std::sync::{Mutex, PoisonError}; +use rand_core::{Rng, SeedableRng}; + use crate::Random; -/// Any `rand_core` generator, behind a mutex so it can be shared (feature `rand`). -impl Random for Mutex { - fn next_u64(&self) -> u64 { - self.lock().unwrap_or_else(PoisonError::into_inner).next_u64() +/// Any seedable `rand_core` generator, behind a mutex so it can be shared: each +/// [`rng`](Random::rng) is a generator forked from it (`SeedableRng::fork`). +impl Random for Mutex { + type Rng = R; + + fn rng(&self) -> R { + self.lock().unwrap_or_else(PoisonError::into_inner).fork() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SeededRng; - fn fill(&self, dest: &mut [u8]) { - self.lock().unwrap_or_else(PoisonError::into_inner).fill_bytes(dest); + #[test] + fn a_shared_generator_forks() { + let shared = Mutex::new(SeededRng::new(9)); + let (mut a, mut b) = (shared.rng(), shared.rng()); + assert_ne!(a.next_u64(), b.next_u64(), "each fork is seeded from the next numbers"); } } diff --git a/crates/loko-random/src/system.rs b/crates/loko-random/src/system.rs index 4610887..1c285d8 100644 --- a/crates/loko-random/src/system.rs +++ b/crates/loko-random/src/system.rs @@ -1,3 +1,7 @@ +use std::convert::Infallible; + +use rand_core::{TryCryptoRng, TryRng}; + use crate::Random; /// The operating system's random number generator (feature `getrandom`, native targets and WASI). @@ -5,25 +9,55 @@ use crate::Random; pub struct OsRandom; impl Random for OsRandom { - fn next_u64(&self) -> u64 { - // The operating system's generator only fails when it isn't available at all. - getrandom::u64().expect("the operating system's random number generator") + type Rng = OsRng; + + fn rng(&self) -> OsRng { + OsRng + } +} + +/// Numbers straight from the operating system's generator; fit for secrets. +#[derive(Clone, Copy, Debug, Default)] +pub struct OsRng; + +// The operating system's generator only fails when it isn't available at all. +const UNAVAILABLE: &str = "the operating system's random number generator"; + +impl TryRng for OsRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(getrandom::u32().expect(UNAVAILABLE)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(getrandom::u64().expect(UNAVAILABLE)) } - fn fill(&self, dest: &mut [u8]) { - getrandom::fill(dest).expect("the operating system's random number generator"); + fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> { + getrandom::fill(dst).expect(UNAVAILABLE); + Ok(()) } } +impl TryCryptoRng for OsRng {} + #[cfg(test)] mod tests { + use rand_core::{CryptoRng, Rng}; + use super::*; - #[test] - fn os_random_numbers_differ() { - assert_ne!(OsRandom.next_u64(), OsRandom.next_u64()); + fn token(random: &impl Random) -> [u8; 32] { let mut bytes = [0u8; 32]; - OsRandom.fill(&mut bytes); - assert!(bytes.iter().any(|&b| b != 0)); + random.rng().fill_bytes(&mut bytes); + bytes + } + + #[test] + fn os_numbers_differ_and_are_fit_for_secrets() { + let mut rng = OsRandom.rng(); + assert_ne!(rng.next_u64(), rng.next_u64()); + assert_ne!(token(&OsRandom), token(&OsRandom)); } } diff --git a/crates/loko-random/src/testing.rs b/crates/loko-random/src/testing.rs index 6c2e148..85147c3 100644 --- a/crates/loko-random/src/testing.rs +++ b/crates/loko-random/src/testing.rs @@ -1,9 +1,22 @@ +use std::convert::Infallible; use std::sync::{Arc, Mutex, PoisonError}; +use rand_core::{SeedableRng, TryRng}; + use crate::Random; -/// A repeatable sequence of numbers from a seed (SplitMix64): the same seed gives the same numbers. -/// Not for secrets. Clones share the sequence. +/// One step of `SplitMix64` (Steele, Lea, and Flood, 2014): a Weyl sequence with a mixing function. +fn split_mix(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// A repeatable source for tests: every [`rng`](Random::rng) is a [`SeededRng`] seeded from one +/// sequence that starts at the seed, so the same seed and the same calls give the same numbers on +/// every run. Not for secrets. Clones share the sequence. #[derive(Clone, Debug)] pub struct SeededRandom { state: Arc>, @@ -24,41 +37,86 @@ impl Default for SeededRandom { } impl Random for SeededRandom { - fn next_u64(&self) -> u64 { - // SplitMix64 (Steele, Lea, and Flood, 2014): a Weyl sequence with a mixing function. - let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); - *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = *state; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + type Rng = SeededRng; + + fn rng(&self) -> SeededRng { + SeededRng::new(split_mix(&mut self.state.lock().unwrap_or_else(PoisonError::into_inner))) + } +} + +/// A `SplitMix64` generator: small, fast, and the same on every platform. Not for secrets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SeededRng { + state: u64, +} + +impl SeededRng { + /// The generator whose state starts at `seed`. + pub fn new(seed: u64) -> Self { + Self { state: seed } + } +} + +impl TryRng for SeededRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok((split_mix(&mut self.state) >> 32) as u32) + } + + fn try_next_u64(&mut self) -> Result { + Ok(split_mix(&mut self.state)) + } + + fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> { + for chunk in dst.chunks_mut(8) { + let bytes = split_mix(&mut self.state).to_le_bytes(); + chunk.copy_from_slice(&bytes[..chunk.len()]); + } + Ok(()) + } +} + +impl SeedableRng for SeededRng { + type Seed = [u8; 8]; + + fn from_seed(seed: [u8; 8]) -> Self { + Self::new(u64::from_le_bytes(seed)) } } #[cfg(test)] mod tests { + use rand_core::Rng; + use super::*; #[test] - fn seeded_sequences_repeat() { - let a = SeededRandom::new(42); - let b = SeededRandom::new(42); - let first: Vec = (0..5).map(|_| a.next_u64()).collect(); - let second: Vec = (0..5).map(|_| b.next_u64()).collect(); - assert_eq!(first, second); - assert_ne!(first[0], first[1]); - assert_ne!(SeededRandom::new(1).next_u64(), SeededRandom::new(2).next_u64()); - // SplitMix64's first output for seed 0, as published. - assert_eq!(SeededRandom::new(0).next_u64(), 0xE220_A839_7B1D_CDAF); + fn split_mix_matches_the_published_sequence() { + // SplitMix64's first output from state 0, as published. + assert_eq!(SeededRng::new(0).next_u64(), 0xE220_A839_7B1D_CDAF); } #[test] - fn fill_covers_odd_lengths() { + fn seeded_sources_repeat() { + let draw = |random: &SeededRandom| -> Vec { + let mut rng = random.rng(); + (0..5).map(|_| rng.next_u64()).collect() + }; + let (a, b) = (SeededRandom::new(42), SeededRandom::new(42)); + assert_eq!(draw(&a), draw(&b)); + assert_ne!(draw(&a), draw(&a), "each rng() is a new generator"); + assert_ne!(SeededRandom::new(1).rng().next_u64(), SeededRandom::new(2).rng().next_u64()); + assert_eq!(SeededRandom::new(0).rng().next_u64(), 0xA706_DD2F_4D19_7E6F); + } + + #[test] + fn clones_share_the_sequence_and_bytes_cover_odd_lengths() { let random = SeededRandom::new(3); + let clone = random.clone(); + assert_ne!(random.rng(), clone.rng(), "clones share one sequence"); let mut bytes = [0u8; 13]; - random.fill(&mut bytes); + random.rng().fill_bytes(&mut bytes); assert!(bytes.iter().any(|&b| b != 0)); - let clone = random.clone(); - assert_ne!(random.next_u64(), clone.next_u64(), "clones share one sequence"); } } diff --git a/crates/loko/Cargo.toml b/crates/loko/Cargo.toml index 89fb84e..ff00075 100644 --- a/crates/loko/Cargo.toml +++ b/crates/loko/Cargo.toml @@ -18,8 +18,6 @@ getrandom = ["loko-random/getrandom"] tokio = ["getrandom", "loko-clock/tokio"] # `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown` with a JavaScript host). js = ["loko-clock/js", "loko-random/js", "loko-console/js"] -# `Random` for a `Mutex` around any `rand_core` generator. -rand = ["loko-random/rand"] # `loko::store`: the `Store` resource and `MemoryStore`. store = ["dep:loko-store"] # `loko::object_store`: a `Store` over an `object_store` client. diff --git a/crates/loko/README.md b/crates/loko/README.md index db98769..450efb2 100644 --- a/crates/loko/README.md +++ b/crates/loko/README.md @@ -7,15 +7,15 @@ that bundle one of each. ```rust use std::time::Duration; -use loko::{Clock, Random, TestPlatform}; +use loko::{Clock, Random, Rng, TestPlatform}; fn id(resources: &(impl Clock + Random)) -> String { - format!("{}-{:04x}", resources.now_ms(), resources.next_u64() & 0xffff) + format!("{}-{:04x}", resources.now_ms(), resources.rng().next_u64() & 0xffff) } let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program platform.clock.advance(Duration::from_secs(1)); -assert_eq!(id(&platform), "1000-cdaf"); +assert_eq!(id(&platform), "1000-7e6f"); ``` | Feature | Adds | @@ -23,7 +23,6 @@ assert_eq!(id(&platform), "1000-cdaf"); | `getrandom` (default) | `OsRandom` and `StdPlatform` (native and WASI) | | `tokio` | `TokioClock` and `TokioPlatform` (native) | | `js` | `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown`) | -| `rand` | `Random` for a `Mutex` around a `rand_core` generator | | `store` | `loko::store`: the `Store` resource and `MemoryStore` | | `object-store` | `loko::object_store`: a `Store` over an `object_store` client | diff --git a/crates/loko/src/lib.rs b/crates/loko/src/lib.rs index b35674c..c3daffe 100644 --- a/crates/loko/src/lib.rs +++ b/crates/loko/src/lib.rs @@ -18,16 +18,16 @@ //! ``` //! use std::time::Duration; //! -//! use loko::{Clock, Random, TestPlatform}; +//! use loko::{Clock, Random, Rng, TestPlatform}; //! //! /// An ID from the time and a random suffix. //! fn id(resources: &(impl Clock + Random)) -> String { -//! format!("{}-{:04x}", resources.now_ms(), resources.next_u64() & 0xffff) +//! format!("{}-{:04x}", resources.now_ms(), resources.rng().next_u64() & 0xffff) //! } //! //! let platform = TestPlatform::default(); //! platform.clock.advance(Duration::from_secs(1)); -//! assert_eq!(id(&platform), "1000-cdaf"); +//! assert_eq!(id(&platform), "1000-7e6f"); //! ``` //! //! A program bundles the resources it needs, its own among them: @@ -61,7 +61,7 @@ pub use loko_macros::{Provides, resource, wraps}; #[cfg(feature = "object-store")] pub use loko_object_store as object_store; pub use loko_random as random; -pub use loko_random::{HasRandom, Random}; +pub use loko_random::{CryptoRng, HasRandom, Random, Rng}; #[cfg(feature = "store")] pub use loko_store as store; #[cfg(feature = "store")] @@ -84,7 +84,8 @@ pub mod prelude { #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] pub use crate::TokioPlatform; pub use crate::{ - Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Provides, Random, TestPlatform, resource, wraps, + Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Provides, Random, Rng, TestPlatform, resource, + wraps, }; #[cfg(feature = "store")] pub use crate::{HasStore, Store}; diff --git a/crates/loko/src/platform.rs b/crates/loko/src/platform.rs index 6962e8d..60e81b2 100644 --- a/crates/loko/src/platform.rs +++ b/crates/loko/src/platform.rs @@ -123,13 +123,14 @@ mod tests { use std::time::Duration; use super::*; + use crate::Rng; #[test] fn a_test_platform_is_each_resource() { let platform = TestPlatform::new().at_ms(1_000).seeded(0).with_var("REGION", "us-east-2"); platform.clock.advance(Duration::from_millis(500)); assert_eq!(platform.now_ms(), 1_500); - assert_eq!(platform.next_u64(), 0xE220_A839_7B1D_CDAF); + assert_eq!(platform.rng().next_u64(), 0xA706_DD2F_4D19_7E6F); assert_eq!(platform.var("REGION").as_deref(), Some("us-east-2")); platform.print_line("hello").unwrap(); assert_eq!(platform.console.lines(), ["hello"]); @@ -140,7 +141,7 @@ mod tests { fn the_std_platform_reads_the_system() { let platform = StdPlatform::default(); assert!(platform.now_ms() > 1_700_000_000_000); - assert_ne!(platform.next_u64(), platform.next_u64()); + assert_ne!(platform.rng().next_u64(), platform.rng().next_u64()); assert_eq!(platform.var("LOKO_SURELY_UNSET"), None); } } diff --git a/crates/loko/tests/resources.rs b/crates/loko/tests/resources.rs index ed36958..c19c86f 100644 --- a/crates/loko/tests/resources.rs +++ b/crates/loko/tests/resources.rs @@ -48,7 +48,7 @@ fn own_resources_bundle_with_loko_resources() { platform.clock.advance(Duration::from_hours(13)); welcome(&app); assert_eq!(platform.console.lines(), ["Good morning, Ada", "Good afternoon, Ada"]); - assert_eq!(app.next_u64(), 0xE220_A839_7B1D_CDAF, "Random forwards to the platform"); + assert_eq!(app.rng().next_u64(), 0xA706_DD2F_4D19_7E6F, "Random forwards to the platform"); } /// Tuple structs bundle too. diff --git a/spec/0003-random.md b/spec/0003-random.md index eb5aae3..ad31f12 100644 --- a/spec/0003-random.md +++ b/spec/0003-random.md @@ -2,7 +2,7 @@ number: 3 title: Random status: done -updated: 2026-10-20 +updated: 2026-10-21 versions: [unreleased] related: [1, 7] issues: [] @@ -10,29 +10,35 @@ issues: [] # 3. Random -**Done** · updated 2026-10-20 · versions unreleased · milestone: [0.2](milestones/v0-2.md) +**Done** · updated 2026-10-21 · versions unreleased · milestone: [0.2](milestones/v0-2.md) ## Summary -Random numbers. Code that makes identifiers, jitter, or samples takes a `Random`, so tests get the same numbers on -every run. +A source of random number generators. Code that makes identifiers, jitter, or samples takes a `Random` and asks it for +a generator of its own, so tests get the same numbers on every run and drawing many numbers takes no locks. ## Design -- **`Random`:** `next_u64() -> u64` and `fill(&mut [u8])` (provided, from `next_u64`; implementations backed by a - byte source override it). -- **`SeededRandom`:** SplitMix64 from a seed (0 by default). Not for secrets. Clones share the sequence. -- **`OsRandom`** (feature `getrandom`, native and WASI): the operating system's generator. -- **`JsRandom`** (feature `js`, `wasm32-unknown-unknown`): `crypto.getRandomValues` in chunks of at most 65,536 - bytes, falling back to `Math.random` on a host without Web Crypto. -- **Feature `rand`:** `Mutex` is a `Random` for any `rand_core::Rng`, so a program's existing generator works as a - resource. +- **`Random`:** `type Rng: rand_core::Rng` and `rng(&self) -> Self::Rng`: a generator of the caller's own, drawn from + with `&mut` and no locks, usable with the whole `rand` ecosystem. Each implementation chooses the generator, and so + whether it's fit for secrets: code that makes secrets asks for `Random`. +- **`SeededRandom`:** one SplitMix64 sequence from a seed (0 by default), shared by clones; each `rng()` is a + `SeededRng` (SplitMix64) seeded from the sequence's next number, so the same seed and the same calls give the same + numbers on every platform. Not for secrets. `SeededRng` is also a `SeedableRng`. +- **`OsRandom`** (feature `getrandom`, native and WASI): `OsRng`, which reads the operating system's generator on + every draw. A `CryptoRng`. +- **`JsRandom`** (feature `js`, `wasm32-unknown-unknown`): `JsRng`, which calls `crypto.getRandomValues` in chunks of + at most 65,536 bytes. A `CryptoRng`; it panics on a host without Web Crypto rather than fall back to numbers that + aren't. +- **`Mutex`** is a `Random` for any `R: SeedableRng + Rng`, so a program's existing generator works as a resource; + `rng()` forks it (`SeedableRng::fork`). +- `rand_core`'s `Rng`, `CryptoRng`, and `SeedableRng` are re-exported, and `Rng` is in `loko::prelude`. ## Decisions | ID | Decision | Status | |---|---|---| -| D9 | `Random` is two methods on `&self`; generators with `&mut self` state go behind a `Mutex`, which the `rand` feature implements. | Accepted | +| D9 | `Random` hands out generators (`rng()`) rather than numbers: one lock at most per generator instead of one per number, `&mut` generators as the `rand` ecosystem expects, and the generator's type says whether it's fit for secrets. | Accepted | | D10 | `SeededRandom` is SplitMix64: small, fast, statistically good enough for tests, and the same on every platform. | Accepted | ## Open questions and issues @@ -42,3 +48,5 @@ None. ## History - 2026-10-20: built for 0.2. +- 2026-10-21: `Random` hands out generators (`rng()`, D9) instead of `next_u64` and `fill`; the `rand` feature is gone + (`rand_core` is always a dependency); `JsRandom` no longer falls back to `Math.random`. diff --git a/spec/README.md b/spec/README.md index 5107770..492c19d 100644 --- a/spec/README.md +++ b/spec/README.md @@ -35,7 +35,7 @@ loko's design. Start here, people and agents alike. |---:|---|---|---| | 1 | [Resources, Provides, and wrappers](0001-resources.md) | Done | 2026-10-21 | | 2 | [Clock](0002-clock.md) | Done | 2026-10-21 | -| 3 | [Random](0003-random.md) | Done | 2026-10-20 | +| 3 | [Random](0003-random.md) | Done | 2026-10-21 | | 4 | [Env](0004-env.md) | Done | 2026-10-20 | | 5 | [Console](0005-console.md) | Done | 2026-10-20 | | 6 | [Store](0006-store.md) | Done | 2026-10-21 | From f7d38cc6da2c54c5911c7a36ce7eb734e6a2a86e Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 03:00:03 -0400 Subject: [PATCH 11/19] README: implementing a loko resource yourself (a clock that runs faster) Co-Authored-By: Claude Opus 5.5 --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 368d4d0..eae9600 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,54 @@ async fn main() -> loko::store::Result<()> { } ``` -### 7. Your own resource +### 7. Your own implementation of a resource + +loko's resources are ordinary traits, so you can implement one yourself: a clock on a runtime loko doesn't ship, an +`Env` read from a config file, a `Store` over your own storage. Implement the required methods and the provided ones +(here `now_ms`) come with it; then it goes anywhere the trait is asked for, and into bundles with `#[provides(Clock)]`. + +```rust +use std::future::{Future, ready}; +use std::time::{Duration, Instant, SystemTime}; + +use loko::Clock; + +/// A clock that runs `speed` times faster than real time, for demos and simulations where an hour +/// of the program's time should pass in seconds. +struct FastClock { + started: (SystemTime, Instant), + speed: u32, +} + +impl FastClock { + fn new(speed: u32) -> Self { + Self { started: (SystemTime::now(), Instant::now()), speed } + } +} + +impl Clock for FastClock { + fn now(&self) -> SystemTime { + let (wall, steady) = self.started; + wall + steady.elapsed() * self.speed + } + + fn sleep(&self, duration: Duration) -> impl Future { + // Blocks the thread, as StdClock does; on tokio, return `tokio::time::sleep(duration / self.speed)`. + std::thread::sleep(duration / self.speed); + ready(()) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let clock = FastClock::new(1_000); + let before = clock.now_ms(); + clock.sleep(Duration::from_secs(2)).await; // two seconds of clock time, two milliseconds of real time + assert!(clock.now_ms() - before >= 2_000); +} +``` + +### 8. Your own resource `#[loko::resource]` makes any trait a resource, so a program's own dependencies (a mailer, a payment client, a feature flag service) work like loko's: a real implementation for the program, a test one that records, and a place in the @@ -276,7 +323,7 @@ async fn main() -> std::io::Result<()> { } ``` -### 8. State in your own resources +### 9. State in your own resources Every resource method takes `&self`, never `&mut self`. That's what lets one value be several resources at once (`greet(resources, resources)` in step 3), lets clones share state (a test keeps one `TestClock` and advances the one @@ -326,7 +373,7 @@ hands out generators instead of numbers: `resources.rng()` locks at most once, a returns takes no lock at all, however many numbers you draw. The system implementations (`StdClock`, `OsRandom`, `SystemEnv`, `StdConsole`) hold no state and take no lock. -### 9. Wrappers +### 10. Wrappers A wrapper holds one implementation of a resource and is one itself, changing some methods. `#[loko::wraps(field: Type)]` on its `impl` forwards every method it doesn't write to the field. @@ -385,7 +432,7 @@ async fn main() { A wrapper keeps the inner resource's associated types, and a provided method it doesn't write (such as `now_ms`) forwards to the inner one too, so write it when it depends on a method the wrapper changes. -### 10. In the browser +### 11. In the browser With feature `js` on `wasm32-unknown-unknown`, `JsPlatform` uses `Date.now`, `setTimeout`, `crypto.getRandomValues`, and the JavaScript console. The `retry` from step 2 runs unchanged: From ebbc1c78be6f5bae5f71c7c205fcf00116e88ee8 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 03:02:39 -0400 Subject: [PATCH 12/19] README: making your own platform (the system with a fixed random seed) Co-Authored-By: Claude Opus 5.5 --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eae9600..a9e1d26 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,55 @@ async fn main() { } ``` -### 8. Your own resource +### 8. Your own platform + +A platform is nothing more than a `#[derive(Provides)]` struct with a field for each resource, which is how loko's own +are built. Make one when your program runs somewhere loko's don't fit: a clock of your own (step 7), a different +generator, variables from a file. This one is the real system with a fixed random seed, for reproducing a run whose +random choices led to a bug. + +```rust +use loko::clock::StdClock; +use loko::console::StdConsole; +use loko::env::SystemEnv; +use loko::prelude::*; +use loko::random::SeededRandom; + +/// The system's clock, environment, and console, with random numbers from a fixed seed. +#[derive(Clone, Debug, Provides)] +pub struct ReplayPlatform { + #[provides(Clock)] + pub clock: StdClock, + #[provides(Random)] + pub random: SeededRandom, + #[provides(Env)] + pub env: SystemEnv, + #[provides(Console)] + pub console: StdConsole, +} + +impl ReplayPlatform { + /// The run of `seed`: the same random choices every time. + pub fn new(seed: u64) -> Self { + Self { clock: StdClock, random: SeededRandom::new(seed), env: SystemEnv, console: StdConsole } + } +} + +/// Picks one of `servers` at random. +fn pick<'a>(resources: &impl Random, servers: &[&'a str]) -> &'a str { + servers[(resources.rng().next_u64() % servers.len() as u64) as usize] +} + +// A program logs its seed, then a later run passes the same one to see the same choices. +let servers = ["a", "b", "c"]; +assert_eq!(pick(&ReplayPlatform::new(7), &servers), pick(&ReplayPlatform::new(7), &servers)); +``` + +A platform can hold more than the four, such as a `Store` or resources of your own; at that point it's a bundle +(step 5), and the two are the same thing. Give it public fields so tests can reach the parts. If other crates use it, +mark it `#[non_exhaustive]` and give it a constructor, so adding a resource later isn't a breaking change. + +### 9. Your own resource `#[loko::resource]` makes any trait a resource, so a program's own dependencies (a mailer, a payment client, a feature flag service) work like loko's: a real implementation for the program, a test one that records, and a place in the @@ -323,7 +371,7 @@ async fn main() -> std::io::Result<()> { } ``` -### 9. State in your own resources +### 10. State in your own resources Every resource method takes `&self`, never `&mut self`. That's what lets one value be several resources at once (`greet(resources, resources)` in step 3), lets clones share state (a test keeps one `TestClock` and advances the one @@ -373,7 +421,7 @@ hands out generators instead of numbers: `resources.rng()` locks at most once, a returns takes no lock at all, however many numbers you draw. The system implementations (`StdClock`, `OsRandom`, `SystemEnv`, `StdConsole`) hold no state and take no lock. -### 10. Wrappers +### 11. Wrappers A wrapper holds one implementation of a resource and is one itself, changing some methods. `#[loko::wraps(field: Type)]` on its `impl` forwards every method it doesn't write to the field. @@ -432,7 +480,7 @@ async fn main() { A wrapper keeps the inner resource's associated types, and a provided method it doesn't write (such as `now_ms`) forwards to the inner one too, so write it when it depends on a method the wrapper changes. -### 11. In the browser +### 12. In the browser With feature `js` on `wasm32-unknown-unknown`, `JsPlatform` uses `Date.now`, `setTimeout`, `crypto.getRandomValues`, and the JavaScript console. The `retry` from step 2 runs unchanged: From df80c0399a2e03fb2a6171ae7753cd8f64b35c4e Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 03:04:51 -0400 Subject: [PATCH 13/19] README: a fast clock as a wrapper around any clock Co-Authored-By: Claude Opus 5.5 --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/README.md b/README.md index a9e1d26..3e387fe 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ async fn main() -> loko::store::Result<()> { loko's resources are ordinary traits, so you can implement one yourself: a clock on a runtime loko doesn't ship, an `Env` read from a config file, a `Store` over your own storage. Implement the required methods and the provided ones (here `now_ms`) come with it; then it goes anywhere the trait is asked for, and into bundles with `#[provides(Clock)]`. +Step 11 makes the same clock as a wrapper around any other clock. ```rust use std::future::{Future, ready}; @@ -449,6 +450,59 @@ assert_eq!(platform.console.lines(), ["DONE"]); assert_eq!(platform.console.error_lines(), ["as it is"]); ``` +Step 7's fast clock works better as a wrapper: it speeds up whatever clock it holds, so on tokio or in the browser its +sleeps stay asynchronous instead of blocking the thread. + +```rust +use std::future::Future; +use std::time::{Duration, SystemTime}; + +use loko::clock::{TestClock, to_ms}; +use loko::prelude::*; + +/// Any clock, running `speed` times faster from the moment the wrapper is made. +struct Faster { + inner: C, + started: SystemTime, + speed: u32, +} + +impl Faster { + fn new(inner: C, speed: u32) -> Self { + let started = inner.now(); + Self { inner, started, speed } + } +} + +#[loko::wraps(inner: C)] +impl Clock for Faster { + fn now(&self) -> SystemTime { + let elapsed = self.inner.now().duration_since(self.started).unwrap_or_default(); + self.started + elapsed * self.speed + } + + // Written too: the inner clock's `now_ms` would leave out the speed-up. + fn now_ms(&self) -> i64 { + to_ms(Clock::now(self)) + } + + fn sleep(&self, duration: Duration) -> impl Future { + self.inner.sleep(duration / self.speed) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Over a test clock here; over TokioClock, StdClock, or JsClock in a program. + let real = TestClock::at_ms(0); + let fast = Faster::new(real.clone(), 60); + real.advance(Duration::from_secs(1)); + assert_eq!(fast.now_ms(), 60_000, "a second of the inner clock is a minute"); + fast.sleep(Duration::from_secs(60)).await; + assert_eq!((real.now_ms(), fast.now_ms()), (2_000, 120_000), "a minute's sleep is a second of the inner clock"); +} +``` + Wrappers stack, and loko has some ready-made: `OffsetClock` (a clock ahead or behind another, for clock-skew tests), `CountingStore`, `ReadOnlyStore`, and `PrefixedStore`. From 4f258adbf5c44c6c6d499ea9440ba1dda84e2ddc Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 03:24:33 -0400 Subject: [PATCH 14/19] =?UTF-8?q?Bundles:=20#[loko::bundle]=20with=20#[lok?= =?UTF-8?q?o::has(=E2=80=A6)]=20fields,=20instead=20of=20derive(Provides)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #[derive(Provides)] with #[provides(…)] on fields said "provides" twice for two different things. A bundle is now declared with #[loko::bundle] on the struct, saying what it is, and #[loko::has(X, …)] on each field, saying what it holds. It's an attribute macro so the field attribute can be the path loko::has (a derive's helpers must be bare names), which #[loko::bundle] reads and removes; loko::has itself is exported for its docs and errors when used elsewhere. Derives can go above or below it. Spec 1 records the change in D17. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- COMPARISON.md | 6 +-- PRIOR_ART.md | 2 +- README.md | 45 ++++++++++---------- crates/loko-macros/Cargo.toml | 2 +- crates/loko-macros/README.md | 12 +++--- crates/loko-macros/src/lib.rs | 75 ++++++++++++++++++++++------------ crates/loko/README.md | 2 +- crates/loko/src/lib.rs | 14 +++---- crates/loko/src/platform.rs | 48 ++++++++++++---------- crates/loko/tests/resources.rs | 36 +++++++++++----- spec/0001-resources.md | 21 +++++----- spec/0007-platforms.md | 4 +- spec/README.md | 2 +- spec/milestones/v0-2.md | 2 +- 16 files changed, 160 insertions(+), 115 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0dd789b..26acafb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Agent instructions loko: resources for Rust programs (`Clock`, `Random`, `Env`, `Console`, `Store`) passed to code as traits, with test, -system, and JavaScript implementations. One crate per resource (`crates/loko-`), the derive macros +system, and JavaScript implementations. One crate per resource (`crates/loko-`), the macros (`crates/loko-macros`), and the `loko` crate, which re-exports them and adds the platforms. ## Before changing code diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c56ef6..a866f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ unrelated 0.1.0 from 2022 (task scripting), by the same owner. `PrefixedStore` (passes on only URIs under a prefix). - `loko-object-store`: a `Store` over an `object_store` client, from a client or a URL. - `loko-macros`: `#[resource]`, which adds a `HasX` trait to a resource trait `X` and makes every `HasX` an `X`, and - `#[derive(Provides)]`, which makes a struct each resource its fields provide, and `#[wraps]`, which makes a wrapper + `#[loko::bundle]`, which makes a struct each resource its fields provide, and `#[wraps]`, which makes a wrapper that replaces some of a resource's methods and forwards the rest. - `loko`: the resources under one crate (`loko::clock`, …, `loko::store` with feature `store`), the platforms `TestPlatform`, `StdPlatform`, `TokioPlatform`, and `JsPlatform`, and a prelude. diff --git a/COMPARISON.md b/COMPARISON.md index 94a9e45..b0b173b 100644 --- a/COMPARISON.md +++ b/COMPARISON.md @@ -4,7 +4,7 @@ loko does two things: 1. **It ships resources.** `Clock`, `Random`, `Env`, `Console`, and `Store` come with an implementation for tests, one for the system, and one for JavaScript hosts, plus platforms that bundle them and wrappers that change them. -2. **It has a small macro layer.** `#[resource]`, `#[derive(Provides)]`, and `#[wraps]` let your own traits work the +2. **It has a small macro layer.** `#[resource]`, `#[loko::bundle]`, and `#[wraps]` let your own traits work the same way. Each crate below overlaps with one of those. The comparisons were checked against these versions (October 2026): @@ -41,7 +41,7 @@ These are the closest to loko's macros. Forwarding is all or nothing: overriding some methods is an open request (issues #13 and #67). **What loko adds:** -- `#[derive(Provides)]` is ambassador's idea. Credited in [PRIOR_ART.md](PRIOR_ART.md). +- `#[loko::bundle]` is ambassador's idea. Credited in [PRIOR_ART.md](PRIOR_ART.md). - `#[resource]` also generates a `HasX` trait. Code can bound on it, and get the held resource with `.x()`. - `#[wraps]` forwards everything you don't write, without listing signatures. That combines what delegate and ambassador each do half of. @@ -82,7 +82,7 @@ These build a graph of components and check at compile time that every dependenc **Use them alongside or instead** when a large application has many components that need each other at construction (fundle), or when a server needs per-request instances next to singletons and framework integration -(shaku). They combine with loko: fundle can build the struct that `#[derive(Provides)]` then exposes as resources, and +(shaku). They combine with loko: fundle can build the struct that `#[loko::bundle]` then exposes as resources, and a shaku component can hold a loko platform. ## Runtime containers: dill, dependency-injector, ferrous-di, and reinhardt-di diff --git a/PRIOR_ART.md b/PRIOR_ART.md index 552639e..d247aa1 100644 --- a/PRIOR_ART.md +++ b/PRIOR_ART.md @@ -20,7 +20,7 @@ never code: every line of loko is our own. How we credit is in [CONTRIBUTING.md] |---|---|---|---| | The time, randomness, the console, and the system as resources passed to code, each with a test implementation (a clock that moves when told) | [ZIO](https://github.com/zio/zio) (`Clock`, `Random`, `Console`, `System`, `TestClock`) | Apache-2.0 | The resource crates and `TestPlatform` | | An environment of resources described by "has a" traits (`HasX`), so code asks for the resources it needs and any environment holding them qualifies | [RIO](https://github.com/commercialhaskell/rio) (`HasLogFunc` and the "Has" type class pattern) | MIT | `#[resource]`'s `HasX` traits | -| Implementing a trait for a struct by forwarding to a field, chosen with a derive and a field attribute | [ambassador](https://github.com/hobofan/ambassador) | Apache-2.0 / MIT | `#[derive(Provides)]` and `#[wraps]` | +| Implementing a trait for a struct by forwarding to a field, chosen with a derive and a field attribute | [ambassador](https://github.com/hobofan/ambassador) | Apache-2.0 / MIT | `#[loko::bundle]` and `#[wraps]` | | Wrappers that are the trait they wrap, so they stack | [tower](https://github.com/tower-rs/tower) (`Layer`) | MIT | `#[wraps]`, `CountingStore`, `ReadOnlyStore`, `PrefixedStore`, `OffsetClock` | | SplitMix64: a 64-bit state advanced by a constant and mixed with two multiply-xorshift rounds | Steele, Lea, and Flood, "Fast Splittable Pseudorandom Number Generators" (OOPSLA 2014) | A published algorithm | `SeededRandom` | | A storage interface with no I/O library of its own, supplied by the application | [delta-kernel-rs](https://github.com/delta-io/delta-kernel-rs) (its `Engine` trait) | Apache-2.0 | `Store` | diff --git a/README.md b/README.md index 3e387fe..70eafbe 100644 --- a/README.md +++ b/README.md @@ -143,19 +143,19 @@ async fn main() -> loko::store::Result<()> { ### 5. Bundles -When code needs a store and the time together, put them in one struct with `#[derive(Provides)]`: each field marked -`#[provides(…)]` makes the struct that resource. Generic fields let the program and its tests fill the same struct +When code needs a store and the time together, put them in one struct with `#[loko::bundle]`: each field marked +`#[loko::has(…)]` makes the struct that resource. Generic fields let the program and its tests fill the same struct differently. ```rust use loko::prelude::*; use loko::store::MemoryStore; -#[derive(Provides)] +#[loko::bundle] struct App { - #[provides(Clock, Random, Env, Console)] + #[loko::has(Clock, Random, Env, Console)] platform: P, - #[provides(Store)] + #[loko::has(Store)] store: S, } @@ -176,8 +176,8 @@ async fn main() -> loko::store::Result<()> { } ``` -`#[provides(Clock)]` names `HasClock` next to `Clock`, so bring both in: `loko::prelude::*` does for loko's resources, -or write the path (`#[provides(loko::Clock)]`). +`#[loko::has(Clock)]` names `HasClock` next to `Clock`, so bring both in: `loko::prelude::*` does for loko's resources, +or write the path (`#[loko::has(loko::Clock)]`). ### 6. Keeping resources in a struct @@ -202,11 +202,11 @@ impl Uploader { } } -#[derive(Provides)] +#[loko::bundle] struct Test { - #[provides(Clock, Random)] + #[loko::has(Clock, Random)] platform: TestPlatform, - #[provides(Store)] + #[loko::has(Store)] store: MemoryStore, } @@ -225,7 +225,7 @@ async fn main() -> loko::store::Result<()> { loko's resources are ordinary traits, so you can implement one yourself: a clock on a runtime loko doesn't ship, an `Env` read from a config file, a `Store` over your own storage. Implement the required methods and the provided ones -(here `now_ms`) come with it; then it goes anywhere the trait is asked for, and into bundles with `#[provides(Clock)]`. +(here `now_ms`) come with it; then it goes anywhere the trait is asked for, and into bundles with `#[loko::has(Clock)]`. Step 11 makes the same clock as a wrapper around any other clock. ```rust @@ -271,7 +271,7 @@ async fn main() { ### 8. Your own platform -A platform is nothing more than a `#[derive(Provides)]` struct with a field for each resource, which is how loko's own +A platform is nothing more than a `#[loko::bundle]` struct with a field for each resource, which is how loko's own are built. Make one when your program runs somewhere loko's don't fit: a clock of your own (step 7), a different generator, variables from a file. This one is the real system with a fixed random seed, for reproducing a run whose random choices led to a bug. @@ -284,15 +284,16 @@ use loko::prelude::*; use loko::random::SeededRandom; /// The system's clock, environment, and console, with random numbers from a fixed seed. -#[derive(Clone, Debug, Provides)] +#[loko::bundle] +#[derive(Clone, Debug)] pub struct ReplayPlatform { - #[provides(Clock)] + #[loko::has(Clock)] pub clock: StdClock, - #[provides(Random)] + #[loko::has(Random)] pub random: SeededRandom, - #[provides(Env)] + #[loko::has(Env)] pub env: SystemEnv, - #[provides(Console)] + #[loko::has(Console)] pub console: StdConsole, } @@ -348,11 +349,11 @@ impl Mailer for TestMailer { } } -#[derive(Provides)] +#[loko::bundle] struct App { - #[provides(Clock, Random, Env, Console)] + #[loko::has(Clock, Random, Env, Console)] platform: TestPlatform, - #[provides(Mailer)] + #[loko::has(Mailer)] mailer: M, } @@ -592,7 +593,7 @@ each is for, when to use which, and what loko does differently. | Macro | What it does | |---|---| | `#[loko::resource]` | Makes a trait a resource: adds `HasX` and makes every `HasX` an `X` | -| `#[derive(Provides)]` | Makes a struct each resource its `#[provides(…)]` fields provide | +| `#[loko::bundle]` | Makes a struct each resource its `#[loko::has(…)]` fields provide | | `#[loko::wraps(field: Type)]` | Makes an `impl X for Wrapper` a wrapper: the methods written replace `X`'s, the rest forward | ### Crates @@ -602,7 +603,7 @@ each is for, when to use which, and what loko does differently. | [`loko`](crates/loko) | Everything below, the platforms, and a prelude, with a feature per optional part | | [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One resource each, with its implementations and wrappers | | [`loko-object-store`](crates/loko-object-store) | A `Store` over an `object_store` client: S3, GCS, Azure, local files, memory | -| [`loko-macros`](crates/loko-macros) | `#[resource]`, `#[derive(Provides)]`, and `#[wraps]` | +| [`loko-macros`](crates/loko-macros) | `#[resource]`, `#[loko::bundle]`, and `#[wraps]` | A library that needs one resource can depend on its crate alone. diff --git a/crates/loko-macros/Cargo.toml b/crates/loko-macros/Cargo.toml index 96e2e95..b8681b2 100644 --- a/crates/loko-macros/Cargo.toml +++ b/crates/loko-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-macros" -description = "loko's macros: #[resource] makes a trait a resource, #[derive(Provides)] wires resources into a struct" +description = "loko's macros: #[resource] makes a trait a resource, #[bundle] wires resources into a struct, #[wraps] makes wrappers" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-macros/README.md b/crates/loko-macros/README.md index 2b77dcf..4c1f6d4 100644 --- a/crates/loko-macros/README.md +++ b/crates/loko-macros/README.md @@ -5,9 +5,9 @@ - `#[resource]` on a trait `X` adds a trait `HasX` (a type that holds an `X`, with an accessor) and implements `X` for every `HasX`, forwarding each method, associated type, and constant. Methods take `&self`; the trait can't be generic. -- `#[derive(Provides)]` implements `HasX` for each `#[provides(X, …)]` on a struct's fields, so the struct is each of - those resources. It names `HasX` next to the path given: `#[provides(loko::Clock)]` uses `loko::HasClock`, and a bare - `#[provides(Clock)]` needs `HasClock` in scope (`loko::prelude::*` brings in both). +- `#[loko::bundle]` implements `HasX` for each `#[loko::has(X, …)]` on a struct's fields, so the struct is each of + those resources. It names `HasX` next to the path given: `#[loko::has(loko::Clock)]` uses `loko::HasClock`, and a bare + `#[loko::has(Clock)]` needs `HasClock` in scope (`loko::prelude::*` brings in both). - `#[wraps(field: Type)]` on `impl X for Wrapper` makes a wrapper: the methods written replace `X`'s, and the rest forward to `self.field`. A wrapper keeps the inner resource's associated types. @@ -19,11 +19,11 @@ trait Greeter { fn greet(&self, name: &str) -> String; } -#[derive(Provides)] +#[loko::bundle] struct App { - #[provides(Clock, Random)] + #[loko::has(Clock, Random)] platform: TestPlatform, - #[provides(Greeter)] + #[loko::has(Greeter)] greeter: G, } diff --git a/crates/loko-macros/src/lib.rs b/crates/loko-macros/src/lib.rs index 2bb40e9..0716835 100644 --- a/crates/loko-macros/src/lib.rs +++ b/crates/loko-macros/src/lib.rs @@ -3,12 +3,12 @@ //! - `#[resource]` on a trait `X` adds a trait `HasX` (a type that holds an `X`) and implements `X` //! for every `HasX`. Each method goes through a `HasX` method (`wrapped_`) that forwards //! to the held resource unless the implementation says otherwise. -//! - `#[derive(Provides)]` implements `HasX` for a struct's fields marked `#[provides(X, …)]`, so the -//! struct is every resource its fields provide. +//! - `#[bundle]` on a struct implements `HasX` for its fields marked `#[loko::has(X, …)]`, so the +//! struct is every resource its fields hold. //! - `#[wraps(field: Type)]` on an `impl X for Wrapper` implements `HasX` instead: the methods //! written replace the forwarding ones, and the rest forward to the field. //! -//! The `HasX` traits follow RIO's "Has" type classes, and choosing a field to forward to with a derive +//! The `HasX` traits follow RIO's "Has" type classes, and choosing a field to forward to with an attribute //! follows ambassador (`PRIOR_ART.md`). use proc_macro::TokenStream; @@ -19,8 +19,8 @@ use syn::punctuated::Punctuated; use syn::spanned::Spanned; use syn::visit_mut::VisitMut; use syn::{ - Data, DeriveInput, Error, FnArg, Ident, ImplItem, ItemImpl, ItemTrait, Member, Pat, PatIdent, Path, ReturnType, - Signature, Token, TraitItem, TraitItemFn, Type, TypePath, parse_macro_input, parse_quote, + Error, FnArg, Ident, ImplItem, ItemImpl, ItemTrait, Member, Pat, PatIdent, Path, ReturnType, Signature, Token, + TraitItem, TraitItemFn, Type, TypePath, parse_macro_input, parse_quote, }; /// Makes a trait a loko resource: adds `Has` and implements the trait for every type that @@ -204,7 +204,7 @@ fn expand_resource(item: &ItemTrait) -> syn::Result { } let doc = format!( - "A type that holds a [`{name}`], and so is one: `#[derive(Provides)]` implements this for a field, \ + "A type that holds a [`{name}`], and so is one: `#[loko::bundle]` implements this for a field, \ and `#[loko::wraps]` for a wrapper that changes some methods." ); let get_doc = format!("The [`{name}`] this holds."); @@ -260,35 +260,47 @@ fn forward_fn( Ok((quote! { #(#cfgs)* #[doc = #doc] #[doc(hidden)] #has_method }, quote! { #(#cfgs)* #impl_method })) } -/// Implements `Has` for each `#[provides(Resource, …)]` on a field, so the struct is each -/// of those resources, forwarding to the field. -#[proc_macro_derive(Provides, attributes(provides))] -pub fn derive_provides(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - match expand_provides(&input) { - Ok(tokens) => tokens.into(), - Err(e) => e.to_compile_error().into(), +/// Whether `attr` is `#[loko::has(…)]` (or `#[has(…)]`, or a re-export's path ending in `has`). +fn is_has(attr: &syn::Attribute) -> bool { + attr.path().segments.last().is_some_and(|s| s.ident == "has") +} + +/// Makes a struct a bundle: for each field marked `#[loko::has(Resource, …)]`, implements +/// `Has`, so the struct is each of those resources, forwarding to the field. +#[proc_macro_attribute] +pub fn bundle(args: TokenStream, input: TokenStream) -> TokenStream { + if !args.is_empty() { + return Error::new(Span::call_site(), "#[bundle] takes no arguments").to_compile_error().into(); + } + let mut item = parse_macro_input!(input as syn::ItemStruct); + match expand_bundle(&mut item) { + Ok(impls) => quote! { #item #impls }.into(), + Err(e) => { + let e = e.to_compile_error(); + quote! { #item #e }.into() + } } } -fn expand_provides(input: &DeriveInput) -> syn::Result { - let Data::Struct(data) = &input.data else { - return Err(Error::new(input.span(), "#[derive(Provides)] is for structs")); - }; - let name = &input.ident; - let (impl_generics, ty_generics, _) = input.generics.split_for_impl(); +/// The `Has` impls of `item`'s `#[has(…)]` fields, which it removes from the struct. +fn expand_bundle(item: &mut syn::ItemStruct) -> syn::Result { + let name = item.ident.clone(); + let generics = item.generics.clone(); + let (impl_generics, ty_generics, _) = generics.split_for_impl(); let mut impls = vec![]; - for (i, field) in data.fields.iter().enumerate() { + for (i, field) in item.fields.iter_mut().enumerate() { let index = syn::Index::from(i); let access = field.ident.as_ref().map_or_else(|| quote! { #index }, |ident| quote! { #ident }); - let ty = &field.ty; - for attr in field.attrs.iter().filter(|a| a.path().is_ident("provides")) { + let ty = field.ty.clone(); + let (has_attrs, others): (Vec<_>, Vec<_>) = field.attrs.drain(..).partition(is_has); + field.attrs = others; + for attr in has_attrs { let resources = attr.parse_args_with(Punctuated::::parse_terminated)?; for resource in resources { let (has, resource_name) = has_path(&resource)?; let get = accessor(&resource_name); - // A generic field type must be the resource it provides. - let mut generics = input.generics.clone(); + // A generic field type must be the resource it holds. + let mut generics = generics.clone(); generics.make_where_clause().predicates.push(parse_quote! { #ty: #resource }); let where_clause = &generics.where_clause; impls.push(quote! { @@ -305,11 +317,22 @@ fn expand_provides(input: &DeriveInput) -> syn::Result { } } if impls.is_empty() { - return Err(Error::new(input.span(), "no field has #[provides(…)]")); + return Err(Error::new(name.span(), "no field has #[loko::has(…)]")); } Ok(quote! { #(#impls)* }) } +/// Marks a field of a `#[loko::bundle]` struct with the resources it holds: +/// `#[loko::has(Clock, Random)]`. `#[loko::bundle]` reads and removes it; anywhere else it's an +/// error. +#[proc_macro_attribute] +pub fn has(_args: TokenStream, input: TokenStream) -> TokenStream { + let e = + Error::new(Span::call_site(), "#[loko::has(…)] goes on a field of a #[loko::bundle] struct").to_compile_error(); + let input = TokenStream2::from(input); + quote! { #e #input }.into() +} + /// `field: Type`, the arguments of `#[wraps]`. struct WrapsArgs { field: Member, diff --git a/crates/loko/README.md b/crates/loko/README.md index 450efb2..e3a8394 100644 --- a/crates/loko/README.md +++ b/crates/loko/README.md @@ -26,7 +26,7 @@ assert_eq!(id(&platform), "1000-7e6f"); | `store` | `loko::store`: the `Store` resource and `MemoryStore` | | `object-store` | `loko::object_store`: a `Store` over an `object_store` client | -`#[loko::resource]` makes your own traits resources and `#[derive(loko::Provides)]` bundles them with loko's. The +`#[loko::resource]` makes your own traits resources and `#[loko::bundle]` bundles them with loko's. The [repository](https://github.com/polythings/loko) has the details. Apache License 2.0. diff --git a/crates/loko/src/lib.rs b/crates/loko/src/lib.rs index c3daffe..74eb549 100644 --- a/crates/loko/src/lib.rs +++ b/crates/loko/src/lib.rs @@ -6,7 +6,7 @@ //! - Resources: [`clock`], [`random`], [`env`](mod@env), [`console`], and `store` (feature `store`), each a //! trait with test, native, and JavaScript implementations. The traits are also at the root. //! - Platforms: [`TestPlatform`], `StdPlatform`, `TokioPlatform`, and `JsPlatform` bundle one of each. -//! - [`resource`] makes a trait a resource, [`Provides`] makes a struct each resource its fields +//! - [`resource`] makes a trait a resource, [`bundle`] makes a struct each resource its fields //! provide, and [`wraps`] makes a wrapper that changes some of a resource's methods and forwards //! the rest. [`prelude`] brings in the resources with the `HasClock`-style traits these name. //! @@ -37,11 +37,11 @@ //! use loko::prelude::*; //! use loko::store::MemoryStore; //! -//! #[derive(Provides)] +//! #[bundle] //! struct App { -//! #[provides(Store)] +//! #[has(Store)] //! store: MemoryStore, -//! #[provides(Clock, Random)] +//! #[has(Clock, Random)] //! platform: TestPlatform, //! } //! @@ -57,7 +57,7 @@ pub use loko_console as console; pub use loko_console::{Console, HasConsole}; pub use loko_env as env; pub use loko_env::{Env, HasEnv}; -pub use loko_macros::{Provides, resource, wraps}; +pub use loko_macros::{bundle, has, resource, wraps}; #[cfg(feature = "object-store")] pub use loko_object_store as object_store; pub use loko_random as random; @@ -74,7 +74,7 @@ mod platform; #[doc = include_str!("../../../README.md")] pub struct ReadmeExamples; -/// The resources, the `Has` traits that `#[derive(Provides)]` and `#[wraps]` name next to them, the +/// The resources, the `Has` traits that `#[loko::bundle]` and `#[wraps]` name next to them, the /// macros, and the platforms. pub mod prelude { #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] @@ -84,7 +84,7 @@ pub mod prelude { #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] pub use crate::TokioPlatform; pub use crate::{ - Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Provides, Random, Rng, TestPlatform, resource, + Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Random, Rng, TestPlatform, bundle, has, resource, wraps, }; #[cfg(feature = "store")] diff --git a/crates/loko/src/platform.rs b/crates/loko/src/platform.rs index 60e81b2..8c4f313 100644 --- a/crates/loko/src/platform.rs +++ b/crates/loko/src/platform.rs @@ -1,29 +1,30 @@ //! Platforms: one of each resource, ready to pass. A platform is each resource it holds; a program -//! that needs more (a `Store`, its own resources) puts a platform in a `#[derive(Provides)]` struct. +//! that needs more (a `Store`, its own resources) puts a platform in a `#[loko::bundle]` struct. use crate::clock::{Clock, TestClock}; use crate::console::{Console, TestConsole}; use crate::env::{Env, MapEnv}; use crate::random::{Random, SeededRandom}; -use crate::{HasClock, HasConsole, HasEnv, HasRandom, Provides}; +use crate::{HasClock, HasConsole, HasEnv, HasRandom, bundle}; /// Resources for tests: a [`TestClock`] at the Unix epoch, a [`SeededRandom`] with seed 0, an empty /// [`MapEnv`], and a [`TestConsole`] that records lines. Clones share the clock, the generator, and /// the console; the fields are public so a test can set them up and look at them. -#[derive(Clone, Debug, Default, Provides)] +#[bundle] +#[derive(Clone, Debug, Default)] #[non_exhaustive] pub struct TestPlatform { /// The time, moved by the test. - #[provides(Clock)] + #[has(Clock)] pub clock: TestClock, /// Random numbers, the same on every run. - #[provides(Random)] + #[has(Random)] pub random: SeededRandom, /// Environment variables set by the test. - #[provides(Env)] + #[has(Env)] pub env: MapEnv, /// The lines printed. - #[provides(Console)] + #[has(Console)] pub console: TestConsole, } @@ -60,40 +61,42 @@ impl TestPlatform { /// [`OsRandom`](crate::random::OsRandom), [`SystemEnv`](crate::env::SystemEnv), and /// [`StdConsole`](crate::console::StdConsole). #[cfg(all(feature = "getrandom", not(all(target_arch = "wasm32", target_os = "unknown"))))] -#[derive(Clone, Copy, Debug, Default, Provides)] +#[bundle] +#[derive(Clone, Copy, Debug, Default)] #[non_exhaustive] pub struct StdPlatform { /// The system clock. - #[provides(Clock)] + #[has(Clock)] pub clock: crate::clock::StdClock, /// The operating system's generator. - #[provides(Random)] + #[has(Random)] pub random: crate::random::OsRandom, /// The process's environment variables. - #[provides(Env)] + #[has(Env)] pub env: crate::env::SystemEnv, /// Standard output and standard error. - #[provides(Console)] + #[has(Console)] pub console: crate::console::StdConsole, } /// The system's resources under tokio (feature `tokio`, native targets): [`StdPlatform`] with /// [`TokioClock`](crate::clock::TokioClock), whose `sleep` is tokio's timer. #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] -#[derive(Clone, Copy, Debug, Default, Provides)] +#[bundle] +#[derive(Clone, Copy, Debug, Default)] #[non_exhaustive] pub struct TokioPlatform { /// The system clock and tokio's timer. - #[provides(Clock)] + #[has(Clock)] pub clock: crate::clock::TokioClock, /// The operating system's generator. - #[provides(Random)] + #[has(Random)] pub random: crate::random::OsRandom, /// The process's environment variables. - #[provides(Env)] + #[has(Env)] pub env: crate::env::SystemEnv, /// Standard output and standard error. - #[provides(Console)] + #[has(Console)] pub console: crate::console::StdConsole, } @@ -101,20 +104,21 @@ pub struct TokioPlatform { /// [`JsRandom`](crate::random::JsRandom), a [`MapEnv`] (JavaScript hosts have no common environment; /// set the variables the program reads), and [`JsConsole`](crate::console::JsConsole). #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] -#[derive(Clone, Debug, Default, Provides)] +#[bundle] +#[derive(Clone, Debug, Default)] #[non_exhaustive] pub struct JsPlatform { /// `Date.now` and `setTimeout`. - #[provides(Clock)] + #[has(Clock)] pub clock: crate::clock::JsClock, /// `crypto.getRandomValues`. - #[provides(Random)] + #[has(Random)] pub random: crate::random::JsRandom, /// Variables set by the program. - #[provides(Env)] + #[has(Env)] pub env: MapEnv, /// `console.log` and `console.error`. - #[provides(Console)] + #[has(Console)] pub console: crate::console::JsConsole, } diff --git a/crates/loko/tests/resources.rs b/crates/loko/tests/resources.rs index c19c86f..2807cc9 100644 --- a/crates/loko/tests/resources.rs +++ b/crates/loko/tests/resources.rs @@ -25,11 +25,11 @@ impl Greeter for ClockGreeter { } /// A bundle of a platform and the greeter, generic over the greeter. -#[derive(Provides)] +#[loko::bundle] struct App { - #[provides(Clock, Random, Env, Console)] + #[loko::has(Clock, Random, Env, Console)] platform: TestPlatform, - #[provides(Greeter)] + #[loko::has(Greeter)] greeter: G, } @@ -52,8 +52,8 @@ fn own_resources_bundle_with_loko_resources() { } /// Tuple structs bundle too. -#[derive(Provides)] -struct Pair(#[provides(Clock)] TestPlatform, #[provides(Greeter)] ClockGreeter); +#[loko::bundle] +struct Pair(#[loko::has(Clock)] TestPlatform, #[loko::has(Greeter)] ClockGreeter); #[test] fn tuple_bundles_forward() { @@ -69,11 +69,12 @@ mod store { use super::*; - #[derive(Clone, Provides)] + #[loko::bundle] + #[derive(Clone)] struct Stored { - #[provides(Store)] + #[loko::has(Store)] store: MemoryStore, - #[provides(Clock)] + #[loko::has(Clock)] platform: TestPlatform, } @@ -175,10 +176,25 @@ impl Log for Plain { } } -#[derive(Provides)] -struct Logged(#[provides(Log)] Plain); +#[loko::bundle] +struct Logged(#[loko::has(Log)] Plain); #[test] fn generic_and_cfg_gated_methods_forward() { assert_eq!(Logged(Plain).line(42), "log: 42"); } + +/// A derive written above `#[loko::bundle]` works as well as one below it. +#[derive(Clone, Debug)] +#[loko::bundle] +struct DeriveFirst { + #[loko::has(Clock)] + platform: TestPlatform, +} + +#[test] +fn derives_go_above_or_below_the_bundle() { + let bundle = DeriveFirst { platform: TestPlatform::new().at_ms(5) }; + assert_eq!(bundle.clone().now_ms(), 5); + assert!(format!("{bundle:?}").contains("platform")); +} diff --git a/spec/0001-resources.md b/spec/0001-resources.md index bbd6623..5d9d8ec 100644 --- a/spec/0001-resources.md +++ b/spec/0001-resources.md @@ -1,6 +1,6 @@ --- number: 1 -title: Resources, Provides, and wrappers +title: Resources, bundles, and wrappers status: done updated: 2026-10-21 versions: [unreleased] @@ -8,7 +8,7 @@ related: [2, 3, 4, 5, 6, 7] issues: [1, 2, 4, 7] --- -# 1. Resources, Provides, and wrappers +# 1. Resources, bundles, and wrappers **Done** · updated 2026-10-21 · versions unreleased · milestone: [0.2](milestones/v0-2.md) @@ -16,7 +16,7 @@ issues: [1, 2, 4, 7] A resource is a trait for something code would otherwise reach for as a global: the time, random numbers, environment variables, the console, object storage. Code takes the resources it needs as `&impl Trait`, the program -passes the system's, and tests pass ones they control. `#[resource]`, `#[derive(Provides)]`, and `#[wraps]` let +passes the system's, and tests pass ones they control. `#[resource]`, `#[loko::bundle]`, and `#[wraps]` let anyone define new resources, bundle them with loko's, and wrap one implementation in another. ## Design @@ -29,11 +29,11 @@ anyone define new resources, bundle them with loko's, and wrap one implementatio a hidden method `wrapped_m` for each method `m` of `X` that forwards to the held resource by default, and - `impl X for T`, whose methods call the `wrapped_` ones, and whose associated types and constants are the held resource's. Provided methods forward too, so an implementation's overrides are kept. -- **`#[derive(Provides)]`** on a struct implements `HasX` for each `#[provides(X, …)]` on a field, with the field's +- **`#[loko::bundle]`** on a struct implements `HasX` for each `#[loko::has(X, …)]` on a field, with the field's type as the held resource (and `FieldType: X` as a bound, so generic fields work). Named and tuple fields both work. A field can provide several resources, such as a platform providing `Clock, Random`. -- **Paths:** the derive names `HasX` next to the path given: `#[provides(loko::Clock)]` uses `loko::HasClock`, and a - bare `#[provides(Clock)]` needs `HasClock` in scope. `loko::prelude` brings in each resource with its `HasX`. +- **Paths:** `#[loko::bundle]` names `HasX` next to the path given: `#[loko::has(loko::Clock)]` uses `loko::HasClock`, and a + bare `#[loko::has(Clock)]` needs `HasClock` in scope. `loko::prelude` brings in each resource with its `HasX`. - **`#[wraps(field: Type)]`** on `impl X for Wrapper` implements `HasX` instead, with `field` (a name or a tuple index) of type `Type` as the held resource. The methods written become the `wrapped_` methods, replacing the forwarding ones; `Self::Assoc` in their signatures means the held resource's. A wrapper keeps the held resource's @@ -60,11 +60,11 @@ anyone define new resources, bundle them with loko's, and wrap one implementatio |---|---|---| | D1 | A crate per resource, re-exported by `loko`, so a library takes only the resource it needs. | Accepted | | D2 | Futures aren't `Send`: resources can wrap JavaScript promises; multi-threaded executors are an open question (#2). | Accepted | -| D3 | `#[resource]` adds `HasX` and a blanket `impl X for T`; the derive implements `HasX` per field attribute. A resource implemented by hand and one held in a bundle look the same to callers. | Accepted | -| D4 | The derive names `HasX` next to the path given, and `loko::prelude` brings each resource in with its `HasX`, rather than generating macros named after the traits. | Accepted | +| D3 | `#[resource]` adds `HasX` and a blanket `impl X for T`; `#[loko::bundle]` implements `HasX` per field attribute. A resource implemented by hand and one held in a bundle look the same to callers. | Accepted | +| D4 | `#[loko::bundle]` names `HasX` next to the path given, and `loko::prelude` brings each resource in with its `HasX`, rather than generating macros named after the traits. | Accepted | | D5 | The crate is `loko`, at 0.2.0: the name's owner already published an unrelated 0.1.0 in 2022. | Accepted | | D16 | The things passed are called resources (`#[resource]`), not services: "service" means a request handler in Rust (tower) and a server elsewhere, and a resource is what Bevy calls its shared `Time`. | Accepted | -| D17 | The derive is `Provides`, with `#[provides(X, …)]` on fields: a field provides resources. | Accepted | +| D17 | Bundles are declared with `#[loko::bundle]` on the struct and `#[loko::has(X, …)]` on fields: the struct-level name says what the struct is, the field-level one what each field holds. An attribute macro (not a derive) so the field attribute can be the path `loko::has` and the three macros look alike. | Accepted | | D18 | Wrappers implement `HasX` through `#[wraps]`, overriding hidden `wrapped_` methods, so they write only what they change. The forwarding lives in `HasX`, whose signatures resolve where the trait is defined, rather than in generated macros that would resolve them at the wrapper. | Accepted | ## Open questions and issues @@ -77,4 +77,5 @@ anyone define new resources, bundle them with loko's, and wrap one implementatio ## History - 2026-10-20: built for 0.2, as services with `#[derive(Services)]`. -- 2026-10-21: services renamed resources (D16), the derive `Provides` (D17); `#[wraps]` added (D18). +- 2026-10-21: services renamed resources (D16); `#[derive(Services)]` became `#[loko::bundle]` with `#[loko::has]` fields (D17); + `#[wraps]` added (D18). diff --git a/spec/0007-platforms.md b/spec/0007-platforms.md index 050dd2e..6ef72e7 100644 --- a/spec/0007-platforms.md +++ b/spec/0007-platforms.md @@ -16,7 +16,7 @@ issues: [3] A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, ready to pass: the test resources, the system's, the system's under tokio, or a JavaScript host's. A program that needs more puts a platform in its own -`#[derive(Provides)]` bundle. +`#[loko::bundle]` bundle. ## Design @@ -27,7 +27,7 @@ system's, the system's under tokio, or a JavaScript host's. A program that needs | `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native (feature `tokio`) | | `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | `wasm32-unknown-unknown` (feature `js`) | -- Each platform is a struct with public fields, one per resource, built with `#[derive(Provides)]`, so it is each +- Each platform is a struct with public fields, one per resource, built with `#[loko::bundle]`, so it is each resource and a test can set up and inspect the parts (`platform.clock.advance(…)`, `platform.console.lines()`). - The structs are `non_exhaustive`, so a resource added later isn't a breaking change; they are built with `default()` (and `TestPlatform`'s `at_ms`, `seeded`, and `with_var`). diff --git a/spec/README.md b/spec/README.md index 492c19d..8812c7d 100644 --- a/spec/README.md +++ b/spec/README.md @@ -33,7 +33,7 @@ loko's design. Start here, people and agents alike. | # | Spec | Status | Updated | |---:|---|---|---| -| 1 | [Resources, Provides, and wrappers](0001-resources.md) | Done | 2026-10-21 | +| 1 | [Resources, bundles, and wrappers](0001-resources.md) | Done | 2026-10-21 | | 2 | [Clock](0002-clock.md) | Done | 2026-10-21 | | 3 | [Random](0003-random.md) | Done | 2026-10-21 | | 4 | [Env](0004-env.md) | Done | 2026-10-20 | diff --git a/spec/milestones/v0-2.md b/spec/milestones/v0-2.md index ddd02a2..77ef9fa 100644 --- a/spec/milestones/v0-2.md +++ b/spec/milestones/v0-2.md @@ -7,7 +7,7 @@ JavaScript implementations, the platforms, and the macros for new resources and | # | Spec | Status | |---:|---|---| -| 1 | [Resources, Provides, and wrappers](../0001-resources.md) | Done | +| 1 | [Resources, bundles, and wrappers](../0001-resources.md) | Done | | 2 | [Clock](../0002-clock.md) | Done | | 3 | [Random](../0003-random.md) | Done | | 4 | [Env](../0004-env.md) | Done | From 33bfbd045392b957f7310423d55a3813d7736673 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 04:17:28 -0400 Subject: [PATCH 15/19] Resource crates without macros or dependencies loko's own resources (Clock, Random, Env, Console, Store) write out what #[resource] generates (HasX, its wrapped_ methods, and the blanket impl), and their wrappers implement HasX by hand instead of using #[wraps]; the store's error implements Display and Error itself instead of with thiserror. So loko-clock, loko-env, loko-console, and loko-store depend on nothing, and loko-random only on rand_core: a library can take a Clock without syn. The macros are for defining new resources; #[loko::bundle] and #[loko::wraps] work with the written-out traits as with generated ones (the integration tests use both). Spec 1 records D19. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + CONTRIBUTING.md | 6 +- Cargo.lock | 10 --- Cargo.toml | 1 - README.md | 3 +- crates/loko-clock/Cargo.toml | 3 - crates/loko-clock/src/lib.rs | 50 ++++++++++++- crates/loko-clock/src/offset.rs | 13 ++-- crates/loko-console/Cargo.toml | 3 - crates/loko-console/src/lib.rs | 38 +++++++++- crates/loko-env/Cargo.toml | 3 - crates/loko-env/src/lib.rs | 38 +++++++++- crates/loko-random/Cargo.toml | 1 - crates/loko-random/src/lib.rs | 28 +++++++- crates/loko-store/Cargo.toml | 4 -- crates/loko-store/src/counting.rs | 25 ++++--- crates/loko-store/src/lib.rs | 19 +++-- crates/loko-store/src/prefixed.rs | 25 ++++--- crates/loko-store/src/read_only.rs | 17 +++-- crates/loko-store/src/store.rs | 112 ++++++++++++++++++++++++++++- spec/0001-resources.md | 2 + spec/README.md | 2 +- 22 files changed, 337 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a866f22..3409d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ unrelated 0.1.0 from 2022 (task scripting), by the same owner. `MemoryStore`, an error type, and the wrappers `CountingStore` (counts calls), `ReadOnlyStore` (refuses writes), and `PrefixedStore` (passes on only URIs under a prefix). - `loko-object-store`: a `Store` over an `object_store` client, from a client or a URL. +- The resource crates depend on nothing but `rand_core` (for `loko-random`); the macros are needed only to define + new resources. - `loko-macros`: `#[resource]`, which adds a `HasX` trait to a resource trait `X` and makes every `HasX` an `X`, and `#[loko::bundle]`, which makes a struct each resource its fields provide, and `#[wraps]`, which makes a wrapper that replaces some of a resource's methods and forwards the rest. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58d69f4..808f51c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,11 @@ we took. ## Rust -- **Resources:** a trait marked `#[loko_macros::resource]`, methods on `&self`, async methods returning `impl Future` +- **loko's own resources need no macros.** Their crates write out what `#[resource]` generates (`HasX`, its + `wrapped_` methods, and the blanket impl) and implement their wrappers' `HasX` by hand, so a library can depend on + `loko-clock` without `syn`. Keep that code in the macro's shape; the tests in `crates/loko/tests` use it with + `#[loko::bundle]` and `#[loko::wraps]`. +- **Resources:** a trait marked `#[loko_macros::resource]` (or written out, as above), methods on `&self`, async methods returning `impl Future` without `Send`. Implementations that hold state share it between clones (`Arc>`). - **State in resources:** methods take `&self`, so state that changes sits behind a `Mutex` or an atomic (counters). Lock only for the synchronous part of a call: never hold a `std::sync::Mutex` guard or a `RefCell` borrow across diff --git a/Cargo.lock b/Cargo.lock index 61fedfa..4d6b53a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,7 +419,6 @@ name = "loko-clock" version = "0.2.0" dependencies = [ "js-sys", - "loko-macros", "tokio", "wasm-bindgen", "wasm-bindgen-futures", @@ -430,16 +429,12 @@ name = "loko-console" version = "0.2.0" dependencies = [ "js-sys", - "loko-macros", "wasm-bindgen", ] [[package]] name = "loko-env" version = "0.2.0" -dependencies = [ - "loko-macros", -] [[package]] name = "loko-macros" @@ -468,7 +463,6 @@ version = "0.2.0" dependencies = [ "getrandom", "js-sys", - "loko-macros", "rand_core", "wasm-bindgen", ] @@ -476,10 +470,6 @@ dependencies = [ [[package]] name = "loko-store" version = "0.2.0" -dependencies = [ - "loko-macros", - "thiserror", -] [[package]] name = "nix" diff --git a/Cargo.toml b/Cargo.toml index b93dbdb..fe9294c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ loko-store = { path = "crates/loko-store", version = "0.2.0" } loko-object-store = { path = "crates/loko-object-store", version = "0.2.0" } loko-macros = { path = "crates/loko-macros", version = "0.2.0" } loko = { path = "crates/loko", version = "0.2.0" } -thiserror = "2" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } object_store = { version = "0.14", default-features = false } url = "2" diff --git a/README.md b/README.md index 70eafbe..6982b08 100644 --- a/README.md +++ b/README.md @@ -605,7 +605,8 @@ each is for, when to use which, and what loko does differently. | [`loko-object-store`](crates/loko-object-store) | A `Store` over an `object_store` client: S3, GCS, Azure, local files, memory | | [`loko-macros`](crates/loko-macros) | `#[resource]`, `#[loko::bundle]`, and `#[wraps]` | -A library that needs one resource can depend on its crate alone. +A library that needs one resource can depend on its crate alone. The resource crates have no dependencies of their +own (`loko-random` has `rand_core`), and no macros: only `loko` and code defining its own resources use `loko-macros`. ## Development diff --git a/crates/loko-clock/Cargo.toml b/crates/loko-clock/Cargo.toml index 6753186..baf410b 100644 --- a/crates/loko-clock/Cargo.toml +++ b/crates/loko-clock/Cargo.toml @@ -10,9 +10,6 @@ repository.workspace = true keywords.workspace = true categories.workspace = true -[dependencies] -loko-macros.workspace = true - [features] default = [] # `TokioClock`: the system clock and tokio's timer (native targets). diff --git a/crates/loko-clock/src/lib.rs b/crates/loko-clock/src/lib.rs index 0ea458f..7d6b1c0 100644 --- a/crates/loko-clock/src/lib.rs +++ b/crates/loko-clock/src/lib.rs @@ -42,7 +42,6 @@ pub use tokio_clock::TokioClock; /// The time and a timer. /// /// The future isn't required to be `Send`, so a clock can wrap JavaScript timers. -#[loko_macros::resource] pub trait Clock { /// The current time. fn now(&self) -> SystemTime; @@ -56,6 +55,55 @@ pub trait Clock { fn sleep(&self, duration: Duration) -> impl Future; } +/// A type that holds a [`Clock`], and so is one: `#[loko::bundle]` implements this for a field, +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// generates, written out so this crate needs no macros. +pub trait HasClock { + /// The resource held. + type Clock: Clock; + + /// The [`Clock`] this holds. + fn clock(&self) -> &Self::Clock; + + /// Forwards [`Clock::now`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_now(&self) -> SystemTime { + self.clock().now() + } + + /// Forwards [`Clock::now_ms`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_now_ms(&self) -> i64 { + self.clock().now_ms() + } + + /// Forwards [`Clock::sleep`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_sleep(&self, duration: Duration) -> impl Future { + self.clock().sleep(duration) + } +} + +impl Clock for H { + #[inline] + fn now(&self) -> SystemTime { + self.wrapped_now() + } + + #[inline] + fn now_ms(&self) -> i64 { + self.wrapped_now_ms() + } + + #[inline] + fn sleep(&self, duration: Duration) -> impl Future { + self.wrapped_sleep(duration) + } +} + /// Milliseconds from the Unix epoch to `time` (negative before it), saturating. pub fn to_ms(time: SystemTime) -> i64 { match time.duration_since(UNIX_EPOCH) { diff --git a/crates/loko-clock/src/offset.rs b/crates/loko-clock/src/offset.rs index 17ce7da..3249fe2 100644 --- a/crates/loko-clock/src/offset.rs +++ b/crates/loko-clock/src/offset.rs @@ -28,9 +28,14 @@ impl OffsetClock { } } -#[loko_macros::wraps(inner: C)] -impl Clock for OffsetClock { - fn now(&self) -> SystemTime { +impl HasClock for OffsetClock { + type Clock = C; + + fn clock(&self) -> &C { + &self.inner + } + + fn wrapped_now(&self) -> SystemTime { let now = self.inner.now(); let moved = if self.behind { now.checked_sub(self.offset) } else { now.checked_add(self.offset) }; // Past what `SystemTime` holds: stay at the other clock's time rather than panic. @@ -38,7 +43,7 @@ impl Clock for OffsetClock { } // The other clock's `now_ms` would leave out the offset. - fn now_ms(&self) -> i64 { + fn wrapped_now_ms(&self) -> i64 { to_ms(Clock::now(self)) } } diff --git a/crates/loko-console/Cargo.toml b/crates/loko-console/Cargo.toml index 6c6160f..16663ce 100644 --- a/crates/loko-console/Cargo.toml +++ b/crates/loko-console/Cargo.toml @@ -10,9 +10,6 @@ repository.workspace = true keywords.workspace = true categories.workspace = true -[dependencies] -loko-macros.workspace = true - [features] default = [] # `JsConsole`: `console.log` and `console.error` (`wasm32-unknown-unknown` with a JavaScript host). diff --git a/crates/loko-console/src/lib.rs b/crates/loko-console/src/lib.rs index 18386f8..d07d9ea 100644 --- a/crates/loko-console/src/lib.rs +++ b/crates/loko-console/src/lib.rs @@ -30,7 +30,6 @@ pub use system::StdConsole; pub use testing::TestConsole; /// Lines of text to standard output and standard error. -#[loko_macros::resource] pub trait Console { /// Writes `line` and a newline to standard output. fn print_line(&self, line: &str) -> io::Result<()>; @@ -38,3 +37,40 @@ pub trait Console { /// Writes `line` and a newline to standard error. fn print_error_line(&self, line: &str) -> io::Result<()>; } + +/// A type that holds a [`Console`], and so is one: `#[loko::bundle]` implements this for a field, +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// generates, written out so this crate needs no macros. +pub trait HasConsole { + /// The resource held. + type Console: Console; + + /// The [`Console`] this holds. + fn console(&self) -> &Self::Console; + + /// Forwards [`Console::print_line`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_print_line(&self, line: &str) -> io::Result<()> { + self.console().print_line(line) + } + + /// Forwards [`Console::print_error_line`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_print_error_line(&self, line: &str) -> io::Result<()> { + self.console().print_error_line(line) + } +} + +impl Console for H { + #[inline] + fn print_line(&self, line: &str) -> io::Result<()> { + self.wrapped_print_line(line) + } + + #[inline] + fn print_error_line(&self, line: &str) -> io::Result<()> { + self.wrapped_print_error_line(line) + } +} diff --git a/crates/loko-env/Cargo.toml b/crates/loko-env/Cargo.toml index 950eedf..89a7831 100644 --- a/crates/loko-env/Cargo.toml +++ b/crates/loko-env/Cargo.toml @@ -10,9 +10,6 @@ repository.workspace = true keywords.workspace = true categories.workspace = true -[dependencies] -loko-macros.workspace = true - [package.metadata.docs.rs] all-features = true diff --git a/crates/loko-env/src/lib.rs b/crates/loko-env/src/lib.rs index ebe01e3..4447dc0 100644 --- a/crates/loko-env/src/lib.rs +++ b/crates/loko-env/src/lib.rs @@ -22,7 +22,6 @@ pub use map::MapEnv; pub use system::SystemEnv; /// Environment variables. -#[loko_macros::resource] pub trait Env { /// The variable `name`, or `None` if it isn't set (or isn't valid Unicode). fn var(&self, name: &str) -> Option; @@ -30,3 +29,40 @@ pub trait Env { /// Every variable, as `(name, value)` pairs. fn vars(&self) -> Vec<(String, String)>; } + +/// A type that holds a [`Env`], and so is one: `#[loko::bundle]` implements this for a field, +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// generates, written out so this crate needs no macros. +pub trait HasEnv { + /// The resource held. + type Env: Env; + + /// The [`Env`] this holds. + fn env(&self) -> &Self::Env; + + /// Forwards [`Env::var`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_var(&self, name: &str) -> Option { + self.env().var(name) + } + + /// Forwards [`Env::vars`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_vars(&self) -> Vec<(String, String)> { + self.env().vars() + } +} + +impl Env for H { + #[inline] + fn var(&self, name: &str) -> Option { + self.wrapped_var(name) + } + + #[inline] + fn vars(&self) -> Vec<(String, String)> { + self.wrapped_vars() + } +} diff --git a/crates/loko-random/Cargo.toml b/crates/loko-random/Cargo.toml index 116cb6d..ca9dffd 100644 --- a/crates/loko-random/Cargo.toml +++ b/crates/loko-random/Cargo.toml @@ -18,7 +18,6 @@ getrandom = ["dep:getrandom"] js = ["dep:js-sys", "dep:wasm-bindgen"] [dependencies] -loko-macros.workspace = true rand_core.workspace = true [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] diff --git a/crates/loko-random/src/lib.rs b/crates/loko-random/src/lib.rs index 65f0296..69a022c 100644 --- a/crates/loko-random/src/lib.rs +++ b/crates/loko-random/src/lib.rs @@ -38,7 +38,6 @@ pub use system::{OsRandom, OsRng}; pub use testing::{SeededRandom, SeededRng}; /// A source of random number generators. -#[loko_macros::resource] pub trait Random { /// The generator [`rng`](Random::rng) hands out. type Rng: rand_core::Rng; @@ -47,3 +46,30 @@ pub trait Random { /// Whether it is fit for secrets depends on the implementation: ask for `Random`. fn rng(&self) -> Self::Rng; } + +/// A type that holds a [`Random`], and so is one: `#[loko::bundle]` implements this for a field, +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// generates, written out so this crate needs no macros. +pub trait HasRandom { + /// The resource held. + type Random: Random; + + /// The [`Random`] this holds. + fn random(&self) -> &Self::Random; + + /// Forwards [`Random::rng`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_rng(&self) -> ::Rng { + self.random().rng() + } +} + +impl Random for H { + type Rng = <::Random as Random>::Rng; + + #[inline] + fn rng(&self) -> Self::Rng { + self.wrapped_rng() + } +} diff --git a/crates/loko-store/Cargo.toml b/crates/loko-store/Cargo.toml index f343554..2ba76ca 100644 --- a/crates/loko-store/Cargo.toml +++ b/crates/loko-store/Cargo.toml @@ -10,10 +10,6 @@ repository.workspace = true keywords.workspace = true categories.workspace = true -[dependencies] -loko-macros.workspace = true -thiserror.workspace = true - [package.metadata.docs.rs] all-features = true diff --git a/crates/loko-store/src/counting.rs b/crates/loko-store/src/counting.rs index 05c3dac..35c3fc8 100644 --- a/crates/loko-store/src/counting.rs +++ b/crates/loko-store/src/counting.rs @@ -75,44 +75,49 @@ impl CountingStore { } } -#[loko_macros::wraps(inner: S)] -impl Store for CountingStore { - fn get(&self, uri: &str) -> impl Future>>> { +impl HasStore for CountingStore { + type Store = S; + + fn store(&self) -> &S { + &self.inner + } + + fn wrapped_get(&self, uri: &str) -> impl Future>>> { self.count(0); self.inner.get(uri) } - fn get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>> { + fn wrapped_get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>> { self.count(1); self.inner.get_range(uri, start, len) } - fn exists(&self, uri: &str) -> impl Future> { + fn wrapped_exists(&self, uri: &str) -> impl Future> { self.count(2); self.inner.exists(uri) } - fn put(&self, uri: &str, bytes: Vec) -> impl Future> { + fn wrapped_put(&self, uri: &str, bytes: Vec) -> impl Future> { self.count(3); self.inner.put(uri, bytes) } - fn put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future> { + fn wrapped_put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future> { self.count(4); self.inner.put_if_absent(uri, bytes) } - fn upload(&self, uri: &str) -> impl Future> { + fn wrapped_upload(&self, uri: &str) -> impl Future> { self.count(5); self.inner.upload(uri) } - fn list(&self, prefix: &str) -> impl Future>> { + fn wrapped_list(&self, prefix: &str) -> impl Future>> { self.count(6); self.inner.list(prefix) } - fn delete(&self, uris: &[String]) -> impl Future> { + fn wrapped_delete(&self, uris: &[String]) -> impl Future> { self.count(7); self.inner.delete(uris) } diff --git a/crates/loko-store/src/lib.rs b/crates/loko-store/src/lib.rs index 5bbb219..215f978 100644 --- a/crates/loko-store/src/lib.rs +++ b/crates/loko-store/src/lib.rs @@ -38,23 +38,32 @@ pub use read_only::ReadOnlyStore; pub use store::{HasStore, Listed, Store, Upload}; /// An error of a store. -#[derive(Debug, thiserror::Error)] +#[derive(Debug)] #[non_exhaustive] pub enum Error { /// The object isn't there. - #[error("not found: {0}")] NotFound(String), /// The request doesn't fit the store (a URI outside it, a bad path). - #[error("invalid: {0}")] Invalid(String), /// The store can't do this (a backend without conditional writes). - #[error("unsupported: {0}")] Unsupported(String), /// The store failed. - #[error("store: {0}")] Store(String), } +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(m) => write!(f, "not found: {m}"), + Self::Invalid(m) => write!(f, "invalid: {m}"), + Self::Unsupported(m) => write!(f, "unsupported: {m}"), + Self::Store(m) => write!(f, "store: {m}"), + } + } +} + +impl std::error::Error for Error {} + /// `Result` with [`Error`]. pub type Result = std::result::Result; diff --git a/crates/loko-store/src/prefixed.rs b/crates/loko-store/src/prefixed.rs index b18ac99..35d1166 100644 --- a/crates/loko-store/src/prefixed.rs +++ b/crates/loko-store/src/prefixed.rs @@ -38,44 +38,49 @@ impl PrefixedStore { } } -#[loko_macros::wraps(inner: S)] -impl Store for PrefixedStore { - async fn get(&self, uri: &str) -> Result>> { +impl HasStore for PrefixedStore { + type Store = S; + + fn store(&self) -> &S { + &self.inner + } + + async fn wrapped_get(&self, uri: &str) -> Result>> { self.check(uri)?; self.inner.get(uri).await } - async fn get_range(&self, uri: &str, start: u64, len: u64) -> Result> { + async fn wrapped_get_range(&self, uri: &str, start: u64, len: u64) -> Result> { self.check(uri)?; self.inner.get_range(uri, start, len).await } - async fn exists(&self, uri: &str) -> Result { + async fn wrapped_exists(&self, uri: &str) -> Result { self.check(uri)?; self.inner.exists(uri).await } - async fn put(&self, uri: &str, bytes: Vec) -> Result<()> { + async fn wrapped_put(&self, uri: &str, bytes: Vec) -> Result<()> { self.check(uri)?; self.inner.put(uri, bytes).await } - async fn put_if_absent(&self, uri: &str, bytes: Vec) -> Result { + async fn wrapped_put_if_absent(&self, uri: &str, bytes: Vec) -> Result { self.check(uri)?; self.inner.put_if_absent(uri, bytes).await } - async fn upload(&self, uri: &str) -> Result { + async fn wrapped_upload(&self, uri: &str) -> Result { self.check(uri)?; self.inner.upload(uri).await } - async fn list(&self, prefix: &str) -> Result> { + async fn wrapped_list(&self, prefix: &str) -> Result> { self.check(prefix)?; self.inner.list(prefix).await } - async fn delete(&self, uris: &[String]) -> Result<()> { + async fn wrapped_delete(&self, uris: &[String]) -> Result<()> { uris.iter().try_for_each(|uri| self.check(uri))?; self.inner.delete(uris).await } diff --git a/crates/loko-store/src/read_only.rs b/crates/loko-store/src/read_only.rs index 8d5f363..af4e62c 100644 --- a/crates/loko-store/src/read_only.rs +++ b/crates/loko-store/src/read_only.rs @@ -25,21 +25,26 @@ fn refused(uri: &str) -> Error { Error::Unsupported(format!("{uri}: the store is read-only")) } -#[loko_macros::wraps(inner: S)] -impl Store for ReadOnlyStore { - fn put(&self, uri: &str, _bytes: Vec) -> impl Future> { +impl HasStore for ReadOnlyStore { + type Store = S; + + fn store(&self) -> &S { + &self.inner + } + + fn wrapped_put(&self, uri: &str, _bytes: Vec) -> impl Future> { ready(Err(refused(uri))) } - fn put_if_absent(&self, uri: &str, _bytes: Vec) -> impl Future> { + fn wrapped_put_if_absent(&self, uri: &str, _bytes: Vec) -> impl Future> { ready(Err(refused(uri))) } - fn upload(&self, uri: &str) -> impl Future> { + fn wrapped_upload(&self, uri: &str) -> impl Future> { ready(Err(refused(uri))) } - fn delete(&self, uris: &[String]) -> impl Future> { + fn wrapped_delete(&self, uris: &[String]) -> impl Future> { ready(Err(refused(uris.first().map_or("", String::as_str)))) } } diff --git a/crates/loko-store/src/store.rs b/crates/loko-store/src/store.rs index b8395ec..02f9dd7 100644 --- a/crates/loko-store/src/store.rs +++ b/crates/loko-store/src/store.rs @@ -15,7 +15,6 @@ pub struct Listed { /// Paths are absolute URIs, as table metadata records them. /// /// The futures aren't required to be `Send`, so a store can wrap JavaScript promises. -#[loko_macros::resource] pub trait Store { /// A multipart upload in progress. type Upload: Upload; @@ -38,6 +37,117 @@ pub trait Store { fn delete(&self, uris: &[String]) -> impl Future>; } +/// A type that holds a [`Store`], and so is one: `#[loko::bundle]` implements this for a field, +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// generates, written out so this crate needs no macros. +pub trait HasStore { + /// The resource held. + type Store: Store; + + /// The [`Store`] this holds. + fn store(&self) -> &Self::Store; + + /// Forwards [`Store::get`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_get(&self, uri: &str) -> impl Future>>> { + self.store().get(uri) + } + + /// Forwards [`Store::get_range`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>> { + self.store().get_range(uri, start, len) + } + + /// Forwards [`Store::exists`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_exists(&self, uri: &str) -> impl Future> { + self.store().exists(uri) + } + + /// Forwards [`Store::put`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_put(&self, uri: &str, bytes: Vec) -> impl Future> { + self.store().put(uri, bytes) + } + + /// Forwards [`Store::put_if_absent`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future> { + self.store().put_if_absent(uri, bytes) + } + + /// Forwards [`Store::upload`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_upload(&self, uri: &str) -> impl Future::Upload>> { + self.store().upload(uri) + } + + /// Forwards [`Store::list`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_list(&self, prefix: &str) -> impl Future>> { + self.store().list(prefix) + } + + /// Forwards [`Store::delete`] to the held resource; a `#[loko::wraps]` implementation may replace it. + #[doc(hidden)] + #[inline] + fn wrapped_delete(&self, uris: &[String]) -> impl Future> { + self.store().delete(uris) + } +} + +impl Store for H { + type Upload = <::Store as Store>::Upload; + + #[inline] + fn get(&self, uri: &str) -> impl Future>>> { + self.wrapped_get(uri) + } + + #[inline] + fn get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>> { + self.wrapped_get_range(uri, start, len) + } + + #[inline] + fn exists(&self, uri: &str) -> impl Future> { + self.wrapped_exists(uri) + } + + #[inline] + fn put(&self, uri: &str, bytes: Vec) -> impl Future> { + self.wrapped_put(uri, bytes) + } + + #[inline] + fn put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future> { + self.wrapped_put_if_absent(uri, bytes) + } + + #[inline] + fn upload(&self, uri: &str) -> impl Future> { + self.wrapped_upload(uri) + } + + #[inline] + fn list(&self, prefix: &str) -> impl Future>> { + self.wrapped_list(prefix) + } + + #[inline] + fn delete(&self, uris: &[String]) -> impl Future> { + self.wrapped_delete(uris) + } +} + /// A multipart upload: parts in order, then [`Upload::complete`]. pub trait Upload { /// Sends the next part. Every part but the last must be at least the store's minimum (5 MiB on diff --git a/spec/0001-resources.md b/spec/0001-resources.md index 5d9d8ec..2782413 100644 --- a/spec/0001-resources.md +++ b/spec/0001-resources.md @@ -66,6 +66,7 @@ anyone define new resources, bundle them with loko's, and wrap one implementatio | D16 | The things passed are called resources (`#[resource]`), not services: "service" means a request handler in Rust (tower) and a server elsewhere, and a resource is what Bevy calls its shared `Time`. | Accepted | | D17 | Bundles are declared with `#[loko::bundle]` on the struct and `#[loko::has(X, …)]` on fields: the struct-level name says what the struct is, the field-level one what each field holds. An attribute macro (not a derive) so the field attribute can be the path `loko::has` and the three macros look alike. | Accepted | | D18 | Wrappers implement `HasX` through `#[wraps]`, overriding hidden `wrapped_` methods, so they write only what they change. The forwarding lives in `HasX`, whose signatures resolve where the trait is defined, rather than in generated macros that would resolve them at the wrapper. | Accepted | +| D19 | loko's own resource crates write out what `#[resource]` generates instead of using the macro, so they have no proc-macro dependency (and `loko-store` no `thiserror`): `loko-clock`, `loko-env`, and `loko-console` depend on nothing, `loko-random` only on `rand_core`. | Accepted | ## Open questions and issues @@ -79,3 +80,4 @@ anyone define new resources, bundle them with loko's, and wrap one implementatio - 2026-10-20: built for 0.2, as services with `#[derive(Services)]`. - 2026-10-21: services renamed resources (D16); `#[derive(Services)]` became `#[loko::bundle]` with `#[loko::has]` fields (D17); `#[wraps]` added (D18). +- 2026-10-21: loko's own resources written out by hand, without the macros (D19). diff --git a/spec/README.md b/spec/README.md index 8812c7d..b3801cb 100644 --- a/spec/README.md +++ b/spec/README.md @@ -29,7 +29,7 @@ loko's design. Start here, people and agents alike. ## Index -7 specs: 7 done. Decisions D1–D18. +7 specs: 7 done. Decisions D1–D19. | # | Spec | Status | Updated | |---:|---|---|---| From 2dd31890534b28d158af9b37600b694660601581 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 04:20:42 -0400 Subject: [PATCH 16/19] README: Sans-I/O, its two shapes and where loko fits Co-Authored-By: Claude Opus 5.5 --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.md b/README.md index 6982b08..b68b71d 100644 --- a/README.md +++ b/README.md @@ -552,6 +552,65 @@ pub async fn fetch_with_retries() -> Result { Futures aren't required to be `Send`, so a resource can wrap JavaScript promises, as `JsClock` does. +## Sans-I/O + +Libraries that do no I/O of their own ("sans-I/O") come in two shapes, and loko fits both: + +- **State machines driven by the caller.** Network protocols such as quinn-proto (QUIC), str0m (WebRTC), and smoltcp + (TCP/IP) take the time as an argument, return what to send and when to wake them next, and never sleep. The state + machine needs no resources; the program around it uses a `Clock` to wait for the deadline, and a test uses a + `TestClock` to run through hours of timers at once. +- **Libraries that call traits the application implements.** delta-kernel-rs's `Engine` and rustls's time and + randomness providers work this way: the library runs a chain of steps that depend on each other and calls back for + each read, write, or timestamp. That's the shape loko's resources serve: the library takes + `&(impl Store + Clock + Random)`, and the application chooses the implementations. + +Choose the first for a protocol that reacts to packets and timers, the second for a sequence of reads and writes where +each depends on the last. The first, with loko at the edge: + +```rust +use std::time::{Duration, SystemTime}; + +use loko::{Clock, TestPlatform}; + +/// Sends a heartbeat every 30 seconds. It knows nothing of clocks or sockets: the time comes in as an +/// argument, and the next deadline goes out. +struct Heartbeat { + next: SystemTime, +} + +impl Heartbeat { + fn deadline(&self) -> SystemTime { + self.next + } + + /// Whether to send a heartbeat at `now`. + fn on_time(&mut self, now: SystemTime) -> bool { + if now < self.next { + return false; + } + self.next = now + Duration::from_secs(30); + true + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + // The edge owns the waiting: TokioPlatform in a program, TestPlatform here. + let clock = TestPlatform::new(); + let mut heartbeat = Heartbeat { next: clock.now() }; + let mut sent = 0; + for _ in 0..3 { + let wait = heartbeat.deadline().duration_since(clock.now()).unwrap_or_default(); + clock.sleep(wait).await; + if heartbeat.on_time(clock.now()) { + sent += 1; + } + } + assert_eq!((sent, clock.now_ms()), (3, 60_000), "three heartbeats in a minute of test time"); +} +``` + ## Related crates [COMPARISON.md](COMPARISON.md) compares loko with forwarding macros (delegate, ambassador), dependency-injection crates From 6c79750109b7929e927a582e92570105da625714 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 04:25:21 -0400 Subject: [PATCH 17/19] Services again: #[loko::service] instead of #[loko::resource] "Resource" made the docs hard to read and is no library's word for this. "Service" is the word ZIO and most dependency-injection libraries use; tower's Service is a different thing (a request handler), and loko exports no Service trait. Spec 1 is 0001-services.md again, D16 records why, and the comparison says the two services differ. bundle, has, and wraps keep their names. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- CHANGELOG.md | 24 ++--- COMPARISON.md | 30 +++--- CONTRIBUTING.md | 14 +-- Cargo.toml | 2 +- PRIOR_ART.md | 4 +- README.md | 102 +++++++++--------- crates/loko-clock/Cargo.toml | 2 +- crates/loko-clock/README.md | 2 +- crates/loko-clock/src/lib.rs | 12 +-- crates/loko-console/Cargo.toml | 2 +- crates/loko-console/README.md | 2 +- crates/loko-console/src/lib.rs | 10 +- crates/loko-env/Cargo.toml | 2 +- crates/loko-env/README.md | 2 +- crates/loko-env/src/lib.rs | 10 +- crates/loko-macros/Cargo.toml | 2 +- crates/loko-macros/README.md | 8 +- crates/loko-macros/src/lib.rs | 90 ++++++++-------- crates/loko-random/Cargo.toml | 2 +- crates/loko-random/README.md | 2 +- crates/loko-random/src/lib.rs | 8 +- crates/loko-store/Cargo.toml | 2 +- crates/loko-store/README.md | 2 +- crates/loko-store/src/lib.rs | 2 +- crates/loko-store/src/store.rs | 20 ++-- crates/loko/Cargo.toml | 4 +- crates/loko/README.md | 10 +- crates/loko/src/lib.rs | 34 +++--- crates/loko/src/platform.rs | 14 +-- .../loko/tests/{resources.rs => services.rs} | 20 ++-- spec/{0001-resources.md => 0001-services.md} | 63 +++++------ spec/0002-clock.md | 2 +- spec/0003-random.md | 2 +- spec/0006-store.md | 2 +- spec/0007-platforms.md | 8 +- spec/README.md | 4 +- spec/milestones/v0-2.md | 6 +- 38 files changed, 268 insertions(+), 265 deletions(-) rename crates/loko/tests/{resources.rs => services.rs} (92%) rename spec/{0001-resources.md => 0001-services.md} (51%) diff --git a/AGENTS.md b/AGENTS.md index 26acafb..a082585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Agent instructions -loko: resources for Rust programs (`Clock`, `Random`, `Env`, `Console`, `Store`) passed to code as traits, with test, -system, and JavaScript implementations. One crate per resource (`crates/loko-`), the macros +loko: services for Rust programs (`Clock`, `Random`, `Env`, `Console`, `Store`) passed to code as traits, with test, +system, and JavaScript implementations. One crate per service (`crates/loko-`), the macros (`crates/loko-macros`), and the `loko` crate, which re-exports them and adds the platforms. ## Before changing code @@ -23,7 +23,7 @@ system, and JavaScript implementations. One crate per resource (`crates/loko-`. -- `loko-env`: the `Env` resource (`var`, `vars`), `SystemEnv`, and `MapEnv`. -- `loko-console`: the `Console` resource (`print_line`, `print_error_line`), `StdConsole`, `TestConsole`, and +- `loko-env`: the `Env` service (`var`, `vars`), `SystemEnv`, and `MapEnv`. +- `loko-console`: the `Console` service (`print_line`, `print_error_line`), `StdConsole`, `TestConsole`, and `JsConsole` (feature `js`). -- `loko-store`: the `Store` resource (objects by URI, with put-if-absent, multipart uploads, and prefix listing), +- `loko-store`: the `Store` service (objects by URI, with put-if-absent, multipart uploads, and prefix listing), `MemoryStore`, an error type, and the wrappers `CountingStore` (counts calls), `ReadOnlyStore` (refuses writes), and `PrefixedStore` (passes on only URIs under a prefix). - `loko-object-store`: a `Store` over an `object_store` client, from a client or a URL. -- The resource crates depend on nothing but `rand_core` (for `loko-random`); the macros are needed only to define - new resources. -- `loko-macros`: `#[resource]`, which adds a `HasX` trait to a resource trait `X` and makes every `HasX` an `X`, and - `#[loko::bundle]`, which makes a struct each resource its fields provide, and `#[wraps]`, which makes a wrapper - that replaces some of a resource's methods and forwards the rest. -- `loko`: the resources under one crate (`loko::clock`, …, `loko::store` with feature `store`), the platforms +- The service crates depend on nothing but `rand_core` (for `loko-random`); the macros are needed only to define + new services. +- `loko-macros`: `#[service]`, which adds a `HasX` trait to a service trait `X` and makes every `HasX` an `X`, and + `#[loko::bundle]`, which makes a struct each service its fields provide, and `#[wraps]`, which makes a wrapper + that replaces some of a service's methods and forwards the rest. +- `loko`: the services under one crate (`loko::clock`, …, `loko::store` with feature `store`), the platforms `TestPlatform`, `StdPlatform`, `TokioPlatform`, and `JsPlatform`, and a prelude. diff --git a/COMPARISON.md b/COMPARISON.md index b0b173b..98d7f0f 100644 --- a/COMPARISON.md +++ b/COMPARISON.md @@ -2,9 +2,9 @@ loko does two things: -1. **It ships resources.** `Clock`, `Random`, `Env`, `Console`, and `Store` come with an implementation for tests, one +1. **It ships services.** `Clock`, `Random`, `Env`, `Console`, and `Store` come with an implementation for tests, one for the system, and one for JavaScript hosts, plus platforms that bundle them and wrappers that change them. -2. **It has a small macro layer.** `#[resource]`, `#[loko::bundle]`, and `#[wraps]` let your own traits work the +2. **It has a small macro layer.** `#[service]`, `#[loko::bundle]`, and `#[wraps]` let your own traits work the same way. Each crate below overlaps with one of those. The comparisons were checked against these versions (October 2026): @@ -25,7 +25,7 @@ dependency-injector 2.1.0, ferrous-di 0.2.0, reinhardt-di 0.3.25, tower 0.5.3, a | [reinhardt-di](https://crates.io/crates/reinhardt-di) | FastAPI-style injection for the Reinhardt web framework | You build on Reinhardt | | [tower](https://crates.io/crates/tower) | Request-to-response services and middleware layers | You compose network clients and servers | | [cap-std](https://crates.io/crates/cap-std) | The standard library's file system and network through capability handles | Code must not reach files outside a directory it was given | -| loko | Resources with test, system, and JavaScript implementations, and the macros to add your own | Code should get the time, randomness, environment, console, or storage passed in, and run in tests, natively, and in the browser | +| loko | Services with test, system, and JavaScript implementations, and the macros to add your own | Code should get the time, randomness, environment, console, or storage passed in, and run in tests, natively, and in the browser | ## Forwarding: delegate and ambassador @@ -42,12 +42,12 @@ These are the closest to loko's macros. **What loko adds:** - `#[loko::bundle]` is ambassador's idea. Credited in [PRIOR_ART.md](PRIOR_ART.md). -- `#[resource]` also generates a `HasX` trait. Code can bound on it, and get the held resource with `.x()`. +- `#[service]` also generates a `HasX` trait. Code can bound on it, and get the held service with `.x()`. - `#[wraps]` forwards everything you don't write, without listing signatures. That combines what delegate and ambassador each do half of. **Use them instead** for forwarding inherent methods (delegate), for enums (ambassador), for traits you can't mark -(ambassador's remote traits), or anywhere you don't need loko's resources. +(ambassador's remote traits), or anywhere you don't need loko's services. ## Compile-time wiring: fundle, nject, shaku, and service-builder @@ -74,15 +74,15 @@ These build a graph of components and check at compile time that every dependenc is ordinary code. - **It decides what code may use, and with which implementation.** Code bounds on traits (`impl Store + Clock`), not on concrete types (fundle's `AsRef`). -- **No trait objects.** loko never needs `dyn`, so resources with `-> impl Future` methods (`Store`, `Clock::sleep`) +- **No trait objects.** loko never needs `dyn`, so services with `-> impl Future` methods (`Store`, `Clock::sleep`) work as they are. shaku would need them boxed through `async-trait`. -- **No `Send`, `Sync`, or `'static` requirement,** so JavaScript-backed resources work. -- **It ships the resources:** test clocks, seeded randomness, an in-memory store, consoles that record. These crates +- **No `Send`, `Sync`, or `'static` requirement,** so JavaScript-backed services work. +- **It ships the services:** test clocks, seeded randomness, an in-memory store, consoles that record. These crates wire your types; you bring the implementations, test ones included. **Use them alongside or instead** when a large application has many components that need each other at construction (fundle), or when a server needs per-request instances next to singletons and framework integration -(shaku). They combine with loko: fundle can build the struct that `#[loko::bundle]` then exposes as resources, and +(shaku). They combine with loko: fundle can build the struct that `#[loko::bundle]` then exposes as services, and a shaku component can hold a loko platform. ## Runtime containers: dill, dependency-injector, ferrous-di, and reinhardt-di @@ -100,7 +100,7 @@ These register implementations in a container and resolve them by type at run ti providers, request, singleton, and transient scopes, and test overrides. Injectables are `Send + Sync + 'static`, it uses `async-trait`, and its README says resolution runs only on native targets. -**Where loko differs:** nothing is resolved at run time, so a missing resource is a compile error, not a startup +**Where loko differs:** nothing is resolved at run time, so a missing service is a compile error, not a startup failure. There's no registry to configure, and calls are statically dispatched. loko also has no lifetimes or scopes, and no choosing implementations from configuration at run time. @@ -113,9 +113,11 @@ tower's `Service` is one generic trait, a request in and a future of a response another. Timeouts, retries, rate limits, and load balancing stack around any client or server. **Where loko differs:** +- **Same word, different thing.** A tower `Service` is a request handler. A loko service is something code is + given, such as a clock or a store, as in ZIO and the dependency-injection crates. loko exports no `Service` trait. - **Many specific traits instead of one generic one.** Wrapping is one feature among several, not the point of the library. -- **The same stacking idea, applied to resources.** loko's wrappers stack the way tower's layers do (credited in +- **The same stacking idea, applied to services.** loko's wrappers stack the way tower's layers do (credited in [PRIOR_ART.md](PRIOR_ART.md)), but there's no `Layer` trait yet ([#7](https://github.com/polythings/loko/issues/7)). - **Complementary:** a tower retry layer could take its backoff from a loko `Clock`, so tests could skip the waits. @@ -133,10 +135,10 @@ it was given, not by path, so it can't reach outside it. It's a foundation for W ## What loko does that the others don't -- **Ships the resources.** A test clock whose `sleep` moves time instead of waiting, seeded randomness, an environment +- **Ships the services.** A test clock whose `sleep` moves time instead of waiting, seeded randomness, an environment in a map, a console that records lines, and an in-memory store with conditional writes. Each has system and JavaScript counterparts and the same interface. The other crates wire up implementations; none ships these. -- **Async without boxing.** Resources return `impl Future`, and loko passes them as generics, never as `dyn`, so +- **Async without boxing.** Services return `impl Future`, and loko passes them as generics, never as `dyn`, so there's no `async-trait` and no heap allocation per call. - **No `Send`, `Sync`, or `'static` requirement.** Futures that wrap JavaScript promises aren't `Send`. The containers above require `Send + Sync`; shaku requires it unless `thread_safe` is turned off. @@ -152,4 +154,4 @@ it was given, not by path, so it can't reach outside it. It's a foundation for W - **No help building a graph in order,** and no web framework integrations. For those, pair loko with the crate that does them, or use that crate on its own. For a program with a handful of -resources, chosen once and swapped in tests and in the browser, loko and ordinary Rust are enough. +services, chosen once and swapped in tests and in the browser, loko and ordinary Rust are enough. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 808f51c..43c74f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,12 +8,12 @@ enforce the mechanical rules (see [Checks](#checks)). This guide covers what the 1. **Readable first.** Someone new should understand a function from its name, its signature, and one pass through the body. -2. **One crate per resource.** A resource's crate holds its trait and its implementations, and depends on no other - resource's crate, so a library can take just the resource it needs. The `loko` crate re-exports them all and holds +2. **One crate per service.** A service's crate holds its trait and its implementations, and depends on no other + service's crate, so a library can take just the service it needs. The `loko` crate re-exports them all and holds what combines them (the platforms). -3. **Small traits.** A resource has the few methods its callers need, and provided methods for what follows from them +3. **Small traits.** A service has the few methods its callers need, and provided methods for what follows from them (`now_ms` from `now`). Implementations are where the variety goes. -4. **Every target.** Each resource has a test implementation that runs anywhere, a system one where there is a system +4. **Every target.** Each service has a test implementation that runs anywhere, a system one where there is a system (native and WASI), and a JavaScript one for `wasm32-unknown-unknown` where it makes sense. 5. **One definition of each thing.** Before writing a helper, constant, or type, look for an existing one. 6. **Behaviour, spec, and tests change together.** A change users can see updates its spec, adds or updates tests, and @@ -38,13 +38,13 @@ we took. ## Rust -- **loko's own resources need no macros.** Their crates write out what `#[resource]` generates (`HasX`, its +- **loko's own services need no macros.** Their crates write out what `#[service]` generates (`HasX`, its `wrapped_` methods, and the blanket impl) and implement their wrappers' `HasX` by hand, so a library can depend on `loko-clock` without `syn`. Keep that code in the macro's shape; the tests in `crates/loko/tests` use it with `#[loko::bundle]` and `#[loko::wraps]`. -- **Resources:** a trait marked `#[loko_macros::resource]` (or written out, as above), methods on `&self`, async methods returning `impl Future` +- **Services:** a trait marked `#[loko_macros::service]` (or written out, as above), methods on `&self`, async methods returning `impl Future` without `Send`. Implementations that hold state share it between clones (`Arc>`). -- **State in resources:** methods take `&self`, so state that changes sits behind a `Mutex` or an atomic (counters). +- **State in services:** methods take `&self`, so state that changes sits behind a `Mutex` or an atomic (counters). Lock only for the synchronous part of a call: never hold a `std::sync::Mutex` guard or a `RefCell` borrow across `.await` (Clippy's `await_holding_lock` and `await_holding_refcell_ref` catch it). Clones share state. - **Errors:** what the platform gives (`io::Result` for the console), or the crate's own `non_exhaustive` error enum diff --git a/Cargo.toml b/Cargo.toml index fe9294c..590d518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ edition = "2024" rust-version = "1.99" license = "Apache-2.0" repository = "https://github.com/polythings/loko" -keywords = ["resources", "clock", "random", "testing", "wasm"] +keywords = ["services", "clock", "random", "testing", "wasm"] categories = ["development-tools::testing", "wasm", "asynchronous"] [workspace.dependencies] diff --git a/PRIOR_ART.md b/PRIOR_ART.md index d247aa1..89d1f34 100644 --- a/PRIOR_ART.md +++ b/PRIOR_ART.md @@ -18,8 +18,8 @@ never code: every line of loko is our own. How we credit is in [CONTRIBUTING.md] | Idea | From | License of the source | Where in loko | |---|---|---|---| -| The time, randomness, the console, and the system as resources passed to code, each with a test implementation (a clock that moves when told) | [ZIO](https://github.com/zio/zio) (`Clock`, `Random`, `Console`, `System`, `TestClock`) | Apache-2.0 | The resource crates and `TestPlatform` | -| An environment of resources described by "has a" traits (`HasX`), so code asks for the resources it needs and any environment holding them qualifies | [RIO](https://github.com/commercialhaskell/rio) (`HasLogFunc` and the "Has" type class pattern) | MIT | `#[resource]`'s `HasX` traits | +| The time, randomness, the console, and the system as services passed to code, each with a test implementation (a clock that moves when told) | [ZIO](https://github.com/zio/zio) (`Clock`, `Random`, `Console`, `System`, `TestClock`) | Apache-2.0 | The service crates and `TestPlatform` | +| An environment of services described by "has a" traits (`HasX`), so code asks for the services it needs and any environment holding them qualifies | [RIO](https://github.com/commercialhaskell/rio) (`HasLogFunc` and the "Has" type class pattern) | MIT | `#[service]`'s `HasX` traits | | Implementing a trait for a struct by forwarding to a field, chosen with a derive and a field attribute | [ambassador](https://github.com/hobofan/ambassador) | Apache-2.0 / MIT | `#[loko::bundle]` and `#[wraps]` | | Wrappers that are the trait they wrap, so they stack | [tower](https://github.com/tower-rs/tower) (`Layer`) | MIT | `#[wraps]`, `CountingStore`, `ReadOnlyStore`, `PrefixedStore`, `OffsetClock` | | SplitMix64: a 64-bit state advanced by a constant and mixed with two multiply-xorshift rounds | Steele, Lea, and Flood, "Fast Splittable Pseudorandom Number Generators" (OOPSLA 2014) | A published algorithm | `SeededRandom` | diff --git a/README.md b/README.md index b68b71d..e27d09f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # loko -Resources for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed +Services for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed to code as traits instead of reached for as globals. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the -program passes the system's resources, and tests pass resources they control. The same code runs natively, under WASI, +program passes the system's services, and tests pass services they control. The same code runs natively, under WASI, and in browsers and Workers. ```toml @@ -15,14 +15,14 @@ loko = { version = "0.2", features = ["tokio", "store"] } | `getrandom` (default) | `OsRandom` and `StdPlatform` (native and WASI) | | `tokio` | `TokioClock` and `TokioPlatform` (native) | | `js` | `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown`) | -| `store` | `loko::store`: the `Store` resource, `MemoryStore`, and the store wrappers | +| `store` | `loko::store`: the `Store` service, `MemoryStore`, and the store wrappers | | `object-store` | `loko::object_store`: a `Store` over an `object_store` client (S3, GCS, Azure, local files) | ## Guide -The examples build on each other, from one resource to bundles of your own resources and wrappers. +The examples build on each other, from one service to bundles of your own services and wrappers. -### 1. One resource +### 1. One service A function that needs the time takes a `Clock` instead of calling `SystemTime::now()`. The program passes the system clock; a test passes a clock it sets. @@ -58,16 +58,16 @@ use loko::{Clock, Random, Rng, TestPlatform}; /// Calls `attempt` up to five times, waiting one to two seconds between tries. async fn retry( - resources: &(impl Clock + Random), + services: &(impl Clock + Random), mut attempt: impl FnMut() -> Result, ) -> Result { - let mut rng = resources.rng(); + let mut rng = services.rng(); let mut tries = 1; loop { match attempt() { Err(_) if tries < 5 => { let jitter = rng.next_u64() % 1_000; - resources.sleep(Duration::from_millis(1_000 + jitter)).await; + services.sleep(Duration::from_millis(1_000 + jitter)).await; tries += 1; } result => return result, @@ -91,24 +91,24 @@ async fn main() { } ``` -### 3. Several resources +### 3. Several services -A function can take each resource as its own argument, or one value that is all of them. A platform is one of each +A function can take each service as its own argument, or one value that is all of them. A platform is one of each of `Clock`, `Random`, `Env`, and `Console`: `TestPlatform` for tests, `StdPlatform` or `TokioPlatform` for programs, and `JsPlatform` in the browser. ```rust use loko::{Console, Env, TestPlatform}; -/// Separate arguments: plain, and fine for two or three resources. +/// Separate arguments: plain, and fine for two or three services. fn greet(env: &impl Env, console: &impl Console) -> std::io::Result<()> { let name = env.var("USER").unwrap_or_else(|| "stranger".into()); console.print_line(&format!("Hello, {name}")) } /// One value: handier as the list grows, and easy to store. -fn greet_all(resources: &(impl Env + Console)) -> std::io::Result<()> { - greet(resources, resources) +fn greet_all(services: &(impl Env + Console)) -> std::io::Result<()> { + greet(services, services) } // A program would pass `StdPlatform::default()`. @@ -144,7 +144,7 @@ async fn main() -> loko::store::Result<()> { ### 5. Bundles When code needs a store and the time together, put them in one struct with `#[loko::bundle]`: each field marked -`#[loko::has(…)]` makes the struct that resource. Generic fields let the program and its tests fill the same struct +`#[loko::has(…)]` makes the struct that service. Generic fields let the program and its tests fill the same struct differently. ```rust @@ -176,12 +176,12 @@ async fn main() -> loko::store::Result<()> { } ``` -`#[loko::has(Clock)]` names `HasClock` next to `Clock`, so bring both in: `loko::prelude::*` does for loko's resources, +`#[loko::has(Clock)]` names `HasClock` next to `Clock`, so bring both in: `loko::prelude::*` does for loko's services, or write the path (`#[loko::has(loko::Clock)]`). -### 6. Keeping resources in a struct +### 6. Keeping services in a struct -A library type holds its resources as one generic field and asks for what each method needs. +A library type holds its services as one generic field and asks for what each method needs. ```rust use loko::prelude::*; @@ -189,13 +189,13 @@ use loko::store::MemoryStore; /// Uploads files under a prefix, named by time and a random suffix. struct Uploader { - resources: R, + services: R, prefix: String, } impl Uploader { async fn upload(&self, body: Vec) -> loko::store::Result { - let r = &self.resources; + let r = &self.services; let uri = format!("{}/{}-{:04x}", self.prefix, r.now_ms(), r.rng().next_u64() & 0xffff); r.put(&uri, body).await?; Ok(uri) @@ -213,7 +213,7 @@ struct Test { #[tokio::main(flavor = "current_thread")] async fn main() -> loko::store::Result<()> { let uploader = Uploader { - resources: Test { platform: TestPlatform::new(), store: MemoryStore::new() }, + services: Test { platform: TestPlatform::new(), store: MemoryStore::new() }, prefix: "memory://uploads".into(), }; assert_eq!(uploader.upload(b"x".to_vec()).await?, "memory://uploads/0-7e6f"); @@ -221,9 +221,9 @@ async fn main() -> loko::store::Result<()> { } ``` -### 7. Your own implementation of a resource +### 7. Your own implementation of a service -loko's resources are ordinary traits, so you can implement one yourself: a clock on a runtime loko doesn't ship, an +loko's services are ordinary traits, so you can implement one yourself: a clock on a runtime loko doesn't ship, an `Env` read from a config file, a `Store` over your own storage. Implement the required methods and the provided ones (here `now_ms`) come with it; then it goes anywhere the trait is asked for, and into bundles with `#[loko::has(Clock)]`. Step 11 makes the same clock as a wrapper around any other clock. @@ -271,7 +271,7 @@ async fn main() { ### 8. Your own platform -A platform is nothing more than a `#[loko::bundle]` struct with a field for each resource, which is how loko's own +A platform is nothing more than a `#[loko::bundle]` struct with a field for each service, which is how loko's own are built. Make one when your program runs somewhere loko's don't fit: a clock of your own (step 7), a different generator, variables from a file. This one is the real system with a fixed random seed, for reproducing a run whose random choices led to a bug. @@ -305,8 +305,8 @@ impl ReplayPlatform { } /// Picks one of `servers` at random. -fn pick<'a>(resources: &impl Random, servers: &[&'a str]) -> &'a str { - servers[(resources.rng().next_u64() % servers.len() as u64) as usize] +fn pick<'a>(services: &impl Random, servers: &[&'a str]) -> &'a str { + servers[(services.rng().next_u64() % servers.len() as u64) as usize] } // A program logs its seed, then a later run passes the same one to see the same choices. @@ -314,15 +314,15 @@ let servers = ["a", "b", "c"]; assert_eq!(pick(&ReplayPlatform::new(7), &servers), pick(&ReplayPlatform::new(7), &servers)); ``` -A platform can hold more than the four, such as a `Store` or resources of your own; at that point it's a bundle +A platform can hold more than the four, such as a `Store` or services of your own; at that point it's a bundle (step 5), and the two are the same thing. Give it public fields so tests can reach the parts. If other crates use it, -mark it `#[non_exhaustive]` and give it a constructor, so adding a resource later isn't a breaking change. +mark it `#[non_exhaustive]` and give it a constructor, so adding a service later isn't a breaking change. -### 9. Your own resource +### 9. Your own service -`#[loko::resource]` makes any trait a resource, so a program's own dependencies (a mailer, a payment client, a feature +`#[loko::service]` makes any trait a service, so a program's own dependencies (a mailer, a payment client, a feature flag service) work like loko's: a real implementation for the program, a test one that records, and a place in the -bundle. An implementation can use other resources. +bundle. An implementation can use other services. ```rust use std::future::{Future, ready}; @@ -330,7 +330,7 @@ use std::sync::{Arc, Mutex}; use loko::prelude::*; -#[loko::resource] +#[loko::service] trait Mailer { /// Sends `body` to `to`. fn send(&self, to: &str, body: &str) -> impl Future>; @@ -373,20 +373,20 @@ async fn main() -> std::io::Result<()> { } ``` -### 10. State in your own resources +### 10. State in your own services -Every resource method takes `&self`, never `&mut self`. That's what lets one value be several resources at once -(`greet(resources, resources)` in step 3), lets clones share state (a test keeps one `TestClock` and advances the one -the code holds), and lets resources be used across `.await`. A resource whose state changes keeps it behind a lock or +Every service method takes `&self`, never `&mut self`. That's what lets one value be several services at once +(`greet(services, services)` in step 3), lets clones share state (a test keeps one `TestClock` and advances the one +the code holds), and lets services be used across `.await`. A service whose state changes keeps it behind a lock or an atomic, as `TestMailer` above does with its `Mutex`. Three rules keep that safe: - **Lock only for the synchronous part.** Don't hold a `std::sync::Mutex` guard or a `RefCell` borrow across an - `.await`. While the task waits, another task can use the same resource and try to take the lock: a `Mutex` then + `.await`. While the task waits, another task can use the same service and try to take the lock: a `Mutex` then blocks that thread for good (on a single-threaded runtime or in the browser, the whole program stops), and a `RefCell` panics. Clippy warns about both (`await_holding_lock`, `await_holding_refcell_ref`). If state really must stay locked while waiting, use an async mutex such as `tokio::sync::Mutex`. - **Counters are atomics,** which need no lock. -- **Clones share state.** Cloning a resource that holds an `Arc` gives a second handle to the same state, not a copy. +- **Clones share state.** Cloning a service that holds an `Arc` gives a second handle to the same state, not a copy. ```rust use std::sync::Mutex; @@ -419,13 +419,13 @@ async fn main() { ``` A lock taken and released at once costs tens of nanoseconds, which matters only in hot loops. That's why `Random` -hands out generators instead of numbers: `resources.rng()` locks at most once, and drawing from the generator it +hands out generators instead of numbers: `services.rng()` locks at most once, and drawing from the generator it returns takes no lock at all, however many numbers you draw. The system implementations (`StdClock`, `OsRandom`, `SystemEnv`, `StdConsole`) hold no state and take no lock. ### 11. Wrappers -A wrapper holds one implementation of a resource and is one itself, changing some methods. `#[loko::wraps(field: +A wrapper holds one implementation of a service and is one itself, changing some methods. `#[loko::wraps(field: Type)]` on its `impl` forwards every method it doesn't write to the field. ```rust @@ -532,7 +532,7 @@ async fn main() { } ``` -A wrapper keeps the inner resource's associated types, and a provided method it doesn't write (such as `now_ms`) +A wrapper keeps the inner service's associated types, and a provided method it doesn't write (such as `now_ms`) forwards to the inner one too, so write it when it depends on a method the wrapper changes. ### 12. In the browser @@ -550,7 +550,7 @@ pub async fn fetch_with_retries() -> Result { } ``` -Futures aren't required to be `Send`, so a resource can wrap JavaScript promises, as `JsClock` does. +Futures aren't required to be `Send`, so a service can wrap JavaScript promises, as `JsClock` does. ## Sans-I/O @@ -558,11 +558,11 @@ Libraries that do no I/O of their own ("sans-I/O") come in two shapes, and loko - **State machines driven by the caller.** Network protocols such as quinn-proto (QUIC), str0m (WebRTC), and smoltcp (TCP/IP) take the time as an argument, return what to send and when to wake them next, and never sleep. The state - machine needs no resources; the program around it uses a `Clock` to wait for the deadline, and a test uses a + machine needs no services; the program around it uses a `Clock` to wait for the deadline, and a test uses a `TestClock` to run through hours of timers at once. - **Libraries that call traits the application implements.** delta-kernel-rs's `Engine` and rustls's time and randomness providers work this way: the library runs a chain of steps that depend on each other and calls back for - each read, write, or timestamp. That's the shape loko's resources serve: the library takes + each read, write, or timestamp. That's the shape loko's services serve: the library takes `&(impl Store + Clock + Random)`, and the application chooses the implementations. Choose the first for a protocol that reacts to packets and timers, the second for a sequence of reads and writes where @@ -619,9 +619,9 @@ each is for, when to use which, and what loko does differently. ## Reference -### Resources +### Services -| Resource | Methods | For tests | System | JavaScript (feature `js`) | +| Service | Methods | For tests | System | JavaScript (feature `js`) | |---|---|---|---|---| | [`Clock`](crates/loko-clock) | `now`, `now_ms`, `sleep` | `TestClock` | `StdClock`, `TokioClock` (feature `tokio`) | `JsClock` | | [`Random`](crates/loko-random) | `rng`, a generator of your own | `SeededRandom` | `OsRandom` (feature `getrandom`) | `JsRandom` | @@ -651,8 +651,8 @@ each is for, when to use which, and what loko does differently. | Macro | What it does | |---|---| -| `#[loko::resource]` | Makes a trait a resource: adds `HasX` and makes every `HasX` an `X` | -| `#[loko::bundle]` | Makes a struct each resource its `#[loko::has(…)]` fields provide | +| `#[loko::service]` | Makes a trait a service: adds `HasX` and makes every `HasX` an `X` | +| `#[loko::bundle]` | Makes a struct each service its `#[loko::has(…)]` fields provide | | `#[loko::wraps(field: Type)]` | Makes an `impl X for Wrapper` a wrapper: the methods written replace `X`'s, the rest forward | ### Crates @@ -660,12 +660,12 @@ each is for, when to use which, and what loko does differently. | Crate | What it holds | |---|---| | [`loko`](crates/loko) | Everything below, the platforms, and a prelude, with a feature per optional part | -| [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One resource each, with its implementations and wrappers | +| [`loko-clock`](crates/loko-clock), [`loko-random`](crates/loko-random), [`loko-env`](crates/loko-env), [`loko-console`](crates/loko-console), [`loko-store`](crates/loko-store) | One service each, with its implementations and wrappers | | [`loko-object-store`](crates/loko-object-store) | A `Store` over an `object_store` client: S3, GCS, Azure, local files, memory | -| [`loko-macros`](crates/loko-macros) | `#[resource]`, `#[loko::bundle]`, and `#[wraps]` | +| [`loko-macros`](crates/loko-macros) | `#[service]`, `#[loko::bundle]`, and `#[wraps]` | -A library that needs one resource can depend on its crate alone. The resource crates have no dependencies of their -own (`loko-random` has `rand_core`), and no macros: only `loko` and code defining its own resources use `loko-macros`. +A library that needs one service can depend on its crate alone. The service crates have no dependencies of their +own (`loko-random` has `rand_core`), and no macros: only `loko` and code defining its own services use `loko-macros`. ## Development diff --git a/crates/loko-clock/Cargo.toml b/crates/loko-clock/Cargo.toml index baf410b..a904384 100644 --- a/crates/loko-clock/Cargo.toml +++ b/crates/loko-clock/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-clock" -description = "loko's Clock resource: the time and a timer, with test, tokio, and JavaScript clocks" +description = "loko's Clock service: the time and a timer, with test, tokio, and JavaScript clocks" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-clock/README.md b/crates/loko-clock/README.md index 59da30a..7092a3f 100644 --- a/crates/loko-clock/README.md +++ b/crates/loko-clock/README.md @@ -1,6 +1,6 @@ # loko-clock -[loko](https://github.com/polythings/loko)'s `Clock` resource: the time and a timer, passed to code instead of read +[loko](https://github.com/polythings/loko)'s `Clock` service: the time and a timer, passed to code instead of read from the system, so tests control time and the same code runs natively, under WASI, and in browsers. - `Clock`: `now` (a `SystemTime`), `now_ms` (milliseconds since the Unix epoch), and `sleep(Duration)`. The future diff --git a/crates/loko-clock/src/lib.rs b/crates/loko-clock/src/lib.rs index 7d6b1c0..a7b6d85 100644 --- a/crates/loko-clock/src/lib.rs +++ b/crates/loko-clock/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Clock` resource: the time and a timer, passed to code instead of read from the system, so +//! loko's `Clock` service: the time and a timer, passed to code instead of read from the system, so //! tests control time and the same code runs natively and in WebAssembly. //! //! - [`Clock`]: `now`, `now_ms`, and `sleep`. @@ -56,30 +56,30 @@ pub trait Clock { } /// A type that holds a [`Clock`], and so is one: `#[loko::bundle]` implements this for a field, -/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::service]` /// generates, written out so this crate needs no macros. pub trait HasClock { - /// The resource held. + /// The service held. type Clock: Clock; /// The [`Clock`] this holds. fn clock(&self) -> &Self::Clock; - /// Forwards [`Clock::now`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Clock::now`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_now(&self) -> SystemTime { self.clock().now() } - /// Forwards [`Clock::now_ms`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Clock::now_ms`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_now_ms(&self) -> i64 { self.clock().now_ms() } - /// Forwards [`Clock::sleep`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Clock::sleep`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_sleep(&self, duration: Duration) -> impl Future { diff --git a/crates/loko-console/Cargo.toml b/crates/loko-console/Cargo.toml index 16663ce..872c5c2 100644 --- a/crates/loko-console/Cargo.toml +++ b/crates/loko-console/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-console" -description = "loko's Console resource: lines to standard output and error, captured in tests, or to the JavaScript console" +description = "loko's Console service: lines to standard output and error, captured in tests, or to the JavaScript console" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-console/README.md b/crates/loko-console/README.md index 2aec827..995962e 100644 --- a/crates/loko-console/README.md +++ b/crates/loko-console/README.md @@ -1,6 +1,6 @@ # loko-console -[loko](https://github.com/polythings/loko)'s `Console` resource: lines to standard output and standard error, passed to +[loko](https://github.com/polythings/loko)'s `Console` service: lines to standard output and standard error, passed to code instead of printed directly, so tests read what was printed and code in a browser logs to its console. - `Console`: `print_line` and `print_error_line`. diff --git a/crates/loko-console/src/lib.rs b/crates/loko-console/src/lib.rs index d07d9ea..24ee6ab 100644 --- a/crates/loko-console/src/lib.rs +++ b/crates/loko-console/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Console` resource: lines to standard output and standard error, passed to code instead of +//! loko's `Console` service: lines to standard output and standard error, passed to code instead of //! printed directly, so tests read what was printed and code in a browser logs to its console. //! //! - [`Console`]: `print_line` and `print_error_line`. @@ -39,23 +39,23 @@ pub trait Console { } /// A type that holds a [`Console`], and so is one: `#[loko::bundle]` implements this for a field, -/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::service]` /// generates, written out so this crate needs no macros. pub trait HasConsole { - /// The resource held. + /// The service held. type Console: Console; /// The [`Console`] this holds. fn console(&self) -> &Self::Console; - /// Forwards [`Console::print_line`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Console::print_line`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_print_line(&self, line: &str) -> io::Result<()> { self.console().print_line(line) } - /// Forwards [`Console::print_error_line`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Console::print_error_line`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_print_error_line(&self, line: &str) -> io::Result<()> { diff --git a/crates/loko-env/Cargo.toml b/crates/loko-env/Cargo.toml index 89a7831..8390cd6 100644 --- a/crates/loko-env/Cargo.toml +++ b/crates/loko-env/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-env" -description = "loko's Env resource: environment variables, from the process or from a map" +description = "loko's Env service: environment variables, from the process or from a map" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-env/README.md b/crates/loko-env/README.md index f8f3637..7a5a372 100644 --- a/crates/loko-env/README.md +++ b/crates/loko-env/README.md @@ -1,6 +1,6 @@ # loko-env -[loko](https://github.com/polythings/loko)'s `Env` resource: environment variables, passed to code instead of read from +[loko](https://github.com/polythings/loko)'s `Env` service: environment variables, passed to code instead of read from the process, so tests set their own and code in a browser (which has none) reads them from a map. - `Env`: `var` and `vars`. diff --git a/crates/loko-env/src/lib.rs b/crates/loko-env/src/lib.rs index 4447dc0..0455ccd 100644 --- a/crates/loko-env/src/lib.rs +++ b/crates/loko-env/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Env` resource: environment variables, passed to code instead of read from the process, so +//! loko's `Env` service: environment variables, passed to code instead of read from the process, so //! tests set their own and code in a browser (which has none) gets them from a map. //! //! - [`Env`]: `var` and `vars`. @@ -31,23 +31,23 @@ pub trait Env { } /// A type that holds a [`Env`], and so is one: `#[loko::bundle]` implements this for a field, -/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::service]` /// generates, written out so this crate needs no macros. pub trait HasEnv { - /// The resource held. + /// The service held. type Env: Env; /// The [`Env`] this holds. fn env(&self) -> &Self::Env; - /// Forwards [`Env::var`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Env::var`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_var(&self, name: &str) -> Option { self.env().var(name) } - /// Forwards [`Env::vars`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Env::vars`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_vars(&self) -> Vec<(String, String)> { diff --git a/crates/loko-macros/Cargo.toml b/crates/loko-macros/Cargo.toml index b8681b2..d618e27 100644 --- a/crates/loko-macros/Cargo.toml +++ b/crates/loko-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-macros" -description = "loko's macros: #[resource] makes a trait a resource, #[bundle] wires resources into a struct, #[wraps] makes wrappers" +description = "loko's macros: #[service] makes a trait a service, #[bundle] wires services into a struct, #[wraps] makes wrappers" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-macros/README.md b/crates/loko-macros/README.md index 4c1f6d4..405e546 100644 --- a/crates/loko-macros/README.md +++ b/crates/loko-macros/README.md @@ -2,19 +2,19 @@ [loko](https://github.com/polythings/loko)'s macros. Use them through the `loko` crate. -- `#[resource]` on a trait `X` adds a trait `HasX` (a type that holds an `X`, with an accessor) and implements `X` for +- `#[service]` on a trait `X` adds a trait `HasX` (a type that holds an `X`, with an accessor) and implements `X` for every `HasX`, forwarding each method, associated type, and constant. Methods take `&self`; the trait can't be generic. - `#[loko::bundle]` implements `HasX` for each `#[loko::has(X, …)]` on a struct's fields, so the struct is each of - those resources. It names `HasX` next to the path given: `#[loko::has(loko::Clock)]` uses `loko::HasClock`, and a bare + those services. It names `HasX` next to the path given: `#[loko::has(loko::Clock)]` uses `loko::HasClock`, and a bare `#[loko::has(Clock)]` needs `HasClock` in scope (`loko::prelude::*` brings in both). - `#[wraps(field: Type)]` on `impl X for Wrapper` makes a wrapper: the methods written replace `X`'s, and the rest - forward to `self.field`. A wrapper keeps the inner resource's associated types. + forward to `self.field`. A wrapper keeps the inner service's associated types. ```rust use loko::prelude::*; -#[loko::resource] +#[loko::service] trait Greeter { fn greet(&self, name: &str) -> String; } diff --git a/crates/loko-macros/src/lib.rs b/crates/loko-macros/src/lib.rs index 0716835..6898657 100644 --- a/crates/loko-macros/src/lib.rs +++ b/crates/loko-macros/src/lib.rs @@ -1,10 +1,10 @@ -//! loko's macros, used by its resource crates and re-exported by `loko`. +//! loko's macros, used by its service crates and re-exported by `loko`. //! -//! - `#[resource]` on a trait `X` adds a trait `HasX` (a type that holds an `X`) and implements `X` +//! - `#[service]` on a trait `X` adds a trait `HasX` (a type that holds an `X`) and implements `X` //! for every `HasX`. Each method goes through a `HasX` method (`wrapped_`) that forwards -//! to the held resource unless the implementation says otherwise. +//! to the held service unless the implementation says otherwise. //! - `#[bundle]` on a struct implements `HasX` for its fields marked `#[loko::has(X, …)]`, so the -//! struct is every resource its fields hold. +//! struct is every service its fields hold. //! - `#[wraps(field: Type)]` on an `impl X for Wrapper` implements `HasX` instead: the methods //! written replace the forwarding ones, and the rest forward to the field. //! @@ -23,17 +23,17 @@ use syn::{ TraitItem, TraitItemFn, Type, TypePath, parse_macro_input, parse_quote, }; -/// Makes a trait a loko resource: adds `Has` and implements the trait for every type that -/// has one. Methods must take `&self`, so one value can be several resources and clones can share +/// Makes a trait a loko service: adds `Has` and implements the trait for every type that +/// has one. Methods must take `&self`, so one value can be several services and clones can share /// state; an implementation whose state changes keeps it behind a lock or an atomic, never held /// across `.await`. The trait can't be generic. #[proc_macro_attribute] -pub fn resource(args: TokenStream, input: TokenStream) -> TokenStream { +pub fn service(args: TokenStream, input: TokenStream) -> TokenStream { if !args.is_empty() { - return Error::new(Span::call_site(), "#[resource] takes no arguments").to_compile_error().into(); + return Error::new(Span::call_site(), "#[service] takes no arguments").to_compile_error().into(); } let item = parse_macro_input!(input as ItemTrait); - match expand_resource(&item) { + match expand_service(&item) { Ok(extra) => quote! { #item #extra }.into(), Err(e) => { let e = e.to_compile_error(); @@ -42,7 +42,7 @@ pub fn resource(args: TokenStream, input: TokenStream) -> TokenStream { } } -/// The accessor of resource `name`: `Clock` → `clock`, `ObjectStore` → `object_store`, +/// The accessor of service `name`: `Clock` → `clock`, `ObjectStore` → `object_store`, /// `HTTPClient` → `http_client`. A word starts at a capital after a lowercase letter or digit, or at /// the last capital of a run followed by a lowercase letter. fn accessor(name: &Ident) -> Ident { @@ -61,15 +61,15 @@ fn accessor(name: &Ident) -> Ident { Ident::new(&snake, name.span()) } -/// The `HasX` method behind method `method` of resource `X`. +/// The `HasX` method behind method `method` of service `X`. fn wrapped(method: &Ident) -> Ident { format_ident!("wrapped_{}", method) } /// `Has`, at the same path: `loko::Clock` → `loko::HasClock`. -fn has_path(resource: &Path) -> syn::Result<(Path, Ident)> { - let name = resource.segments.last().ok_or_else(|| Error::new(resource.span(), "a resource path"))?.ident.clone(); - let mut has = resource.clone(); +fn has_path(service: &Path) -> syn::Result<(Path, Ident)> { + let name = service.segments.last().ok_or_else(|| Error::new(service.span(), "a service path"))?.ident.clone(); + let mut has = service.clone(); if let Some(last) = has.segments.last_mut() { last.ident = format_ident!("Has{}", name); last.arguments = syn::PathArguments::None; @@ -77,7 +77,7 @@ fn has_path(resource: &Path) -> syn::Result<(Path, Ident)> { Ok((has, name)) } -/// Rewrites `Self::Assoc` to the held resource's `Assoc`, for signatures moved into `HasX`, where +/// Rewrites `Self::Assoc` to the held service's `Assoc`, for signatures moved into `HasX`, where /// `Self` is the holder. `Self::X` (the `HasX` type itself) stays. struct HeldAssoc<'a> { has: &'a Path, @@ -114,7 +114,7 @@ impl syn::visit::Visit<'_> for BareSelf { } } -/// An error if `sig` uses `Self` as a type: the holder of a resource isn't the resource held, so a +/// An error if `sig` uses `Self` as a type: the holder of a service isn't the service held, so a /// forwarded `Self` would be the wrong type. fn reject_bare_self(sig: &Signature) -> syn::Result<()> { let mut found = BareSelf::default(); @@ -125,7 +125,7 @@ fn reject_bare_self(sig: &Signature) -> syn::Result<()> { match found.0 { Some(span) => Err(Error::new( span, - "loko resource methods can't take or return `Self`: a type holding the resource isn't the resource", + "loko service methods can't take or return `Self`: a type holding the service isn't the service", )), None => Ok(()), } @@ -135,7 +135,7 @@ fn reject_bare_self(sig: &Signature) -> syn::Result<()> { fn name_arguments(sig: &mut Signature) -> syn::Result> { match sig.inputs.first() { Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none() => {} - _ => return Err(Error::new(sig.span(), "loko resource methods take `&self`")), + _ => return Err(Error::new(sig.span(), "loko service methods take `&self`")), } let mut args = vec![]; for (i, input) in sig.inputs.iter_mut().skip(1).enumerate() { @@ -166,12 +166,12 @@ fn method(sig: &Signature, call: &TokenStream2) -> TokenStream2 { } } -fn expand_resource(item: &ItemTrait) -> syn::Result { +fn expand_service(item: &ItemTrait) -> syn::Result { if !item.generics.params.is_empty() { - return Err(Error::new(item.generics.span(), "a loko resource can't be generic")); + return Err(Error::new(item.generics.span(), "a loko service can't be generic")); } if item.unsafety.is_some() { - return Err(Error::new(item.span(), "a loko resource can't be an unsafe trait")); + return Err(Error::new(item.span(), "a loko service can't be an unsafe trait")); } let vis = &item.vis; let name = &item.ident; @@ -199,7 +199,7 @@ fn expand_resource(item: &ItemTrait) -> syn::Result { has_items.push(has_item); impl_items.push(impl_item); } - other => return Err(Error::new(other.span(), "loko resources can hold types, constants, and methods")), + other => return Err(Error::new(other.span(), "loko services can hold types, constants, and methods")), } } @@ -217,7 +217,7 @@ fn expand_resource(item: &ItemTrait) -> syn::Result { Ok(quote! { #[doc = #doc] #vis trait #has { - /// The resource held. + /// The service held. type #name: #name; #[doc = #get_doc] fn #get(&self) -> &Self::#name; @@ -231,8 +231,8 @@ fn expand_resource(item: &ItemTrait) -> syn::Result { }) } -/// For a method of the resource: its forwarding method in `HasX` (`wrapped_`, calling the -/// held resource), and its method in the blanket impl (calling the `HasX` one). +/// For a method of the service: its forwarding method in `HasX` (`wrapped_`, calling the +/// held service), and its method in the blanket impl (calling the `HasX` one). fn forward_fn( f: &TraitItemFn, has: &Path, @@ -252,7 +252,7 @@ fn forward_fn( HeldAssoc { has, name }.visit_signature_mut(&mut has_sig); let has_call = quote! { <::#name as #name>::#ident(#has::#get(self), #(#args),*) }; let doc = - format!("Forwards [`{name}::{ident}`] to the held resource; a `#[loko::wraps]` implementation may replace it."); + format!("Forwards [`{name}::{ident}`] to the held service; a `#[loko::wraps]` implementation may replace it."); let has_method = method(&has_sig, &has_call); let impl_call = quote! { <#holder as #has>::#wrapped_ident(self, #(#args),*) }; @@ -265,8 +265,8 @@ fn is_has(attr: &syn::Attribute) -> bool { attr.path().segments.last().is_some_and(|s| s.ident == "has") } -/// Makes a struct a bundle: for each field marked `#[loko::has(Resource, …)]`, implements -/// `Has`, so the struct is each of those resources, forwarding to the field. +/// Makes a struct a bundle: for each field marked `#[loko::has(Service, …)]`, implements +/// `Has`, so the struct is each of those services, forwarding to the field. #[proc_macro_attribute] pub fn bundle(args: TokenStream, input: TokenStream) -> TokenStream { if !args.is_empty() { @@ -295,18 +295,18 @@ fn expand_bundle(item: &mut syn::ItemStruct) -> syn::Result { let (has_attrs, others): (Vec<_>, Vec<_>) = field.attrs.drain(..).partition(is_has); field.attrs = others; for attr in has_attrs { - let resources = attr.parse_args_with(Punctuated::::parse_terminated)?; - for resource in resources { - let (has, resource_name) = has_path(&resource)?; - let get = accessor(&resource_name); - // A generic field type must be the resource it holds. + let services = attr.parse_args_with(Punctuated::::parse_terminated)?; + for service in services { + let (has, service_name) = has_path(&service)?; + let get = accessor(&service_name); + // A generic field type must be the service it holds. let mut generics = generics.clone(); - generics.make_where_clause().predicates.push(parse_quote! { #ty: #resource }); + generics.make_where_clause().predicates.push(parse_quote! { #ty: #service }); let where_clause = &generics.where_clause; impls.push(quote! { #[automatically_derived] impl #impl_generics #has for #name #ty_generics #where_clause { - type #resource_name = #ty; + type #service_name = #ty; #[inline] fn #get(&self) -> &#ty { &self.#access @@ -322,7 +322,7 @@ fn expand_bundle(item: &mut syn::ItemStruct) -> syn::Result { Ok(quote! { #(#impls)* }) } -/// Marks a field of a `#[loko::bundle]` struct with the resources it holds: +/// Marks a field of a `#[loko::bundle]` struct with the services it holds: /// `#[loko::has(Clock, Random)]`. `#[loko::bundle]` reads and removes it; anywhere else it's an /// error. #[proc_macro_attribute] @@ -347,9 +347,9 @@ impl Parse for WrapsArgs { } } -/// Makes `impl Resource for Wrapper` a wrapper around a field: `#[wraps(inner: S)]`. The methods -/// written replace the resource's; the others forward to `self.inner`. A wrapper keeps the inner -/// resource's associated types. +/// Makes `impl Service for Wrapper` a wrapper around a field: `#[wraps(inner: S)]`. The methods +/// written replace the service's; the others forward to `self.inner`. A wrapper keeps the inner +/// service's associated types. #[proc_macro_attribute] pub fn wraps(args: TokenStream, input: TokenStream) -> TokenStream { let args = parse_macro_input!(args as WrapsArgs); @@ -361,17 +361,17 @@ pub fn wraps(args: TokenStream, input: TokenStream) -> TokenStream { } fn expand_wraps(args: &WrapsArgs, item: &ItemImpl) -> syn::Result { - let Some((None, resource, _)) = &item.trait_ else { - return Err(Error::new(item.span(), "#[wraps] goes on an `impl Resource for Wrapper`")); + let Some((None, service, _)) = &item.trait_ else { + return Err(Error::new(item.span(), "#[wraps] goes on an `impl Service for Wrapper`")); }; - let (has, name) = has_path(resource)?; + let (has, name) = has_path(service)?; let get = accessor(&name); let mut methods = vec![]; for impl_item in &item.items { let ImplItem::Fn(f) = impl_item else { return Err(Error::new( impl_item.span(), - "a wrapper only replaces methods; it keeps the inner resource's types and constants", + "a wrapper only replaces methods; it keeps the inner service's types and constants", )); }; let mut f = f.clone(); @@ -410,7 +410,7 @@ mod tests { } #[test] - fn resources_parse() { + fn services_parse() { let parser = Punctuated::::parse_terminated; let paths = parser.parse_str("Clock, loko::Random").unwrap(); assert_eq!(paths.len(), 2); @@ -433,7 +433,7 @@ mod tests { } #[test] - fn self_types_become_the_held_resources() { + fn self_types_become_the_held_services() { let mut sig: Signature = parse_quote! { fn upload(&self) -> Result }; let has: Path = parse_quote! { HasStore }; HeldAssoc { has: &has, name: &format_ident!("Store") }.visit_signature_mut(&mut sig); diff --git a/crates/loko-random/Cargo.toml b/crates/loko-random/Cargo.toml index ca9dffd..362af68 100644 --- a/crates/loko-random/Cargo.toml +++ b/crates/loko-random/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-random" -description = "loko's Random resource: random numbers, with seeded, operating-system, and JavaScript sources" +description = "loko's Random service: random numbers, with seeded, operating-system, and JavaScript sources" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-random/README.md b/crates/loko-random/README.md index 2250373..b35a90c 100644 --- a/crates/loko-random/README.md +++ b/crates/loko-random/README.md @@ -1,6 +1,6 @@ # loko-random -[loko](https://github.com/polythings/loko)'s `Random` resource: a source of random number generators, passed to code +[loko](https://github.com/polythings/loko)'s `Random` service: a source of random number generators, passed to code instead of drawn from a global, so tests are repeatable and the same code runs natively, under WASI, and in browsers. - `Random::rng()` hands out a generator of the caller's own: a `rand_core::Rng`, drawn from with `&mut` and no locks, diff --git a/crates/loko-random/src/lib.rs b/crates/loko-random/src/lib.rs index 69a022c..b14ba63 100644 --- a/crates/loko-random/src/lib.rs +++ b/crates/loko-random/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Random` resource: a source of random number generators, passed to code instead of drawn +//! loko's `Random` service: a source of random number generators, passed to code instead of drawn //! from a global, so tests are repeatable and the same code runs natively and in WebAssembly. //! //! [`Random::rng`] hands out a generator of the caller's own, a [`rand_core::Rng`] drawn from with @@ -48,16 +48,16 @@ pub trait Random { } /// A type that holds a [`Random`], and so is one: `#[loko::bundle]` implements this for a field, -/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::service]` /// generates, written out so this crate needs no macros. pub trait HasRandom { - /// The resource held. + /// The service held. type Random: Random; /// The [`Random`] this holds. fn random(&self) -> &Self::Random; - /// Forwards [`Random::rng`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Random::rng`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_rng(&self) -> ::Rng { diff --git a/crates/loko-store/Cargo.toml b/crates/loko-store/Cargo.toml index 2ba76ca..6cd67e5 100644 --- a/crates/loko-store/Cargo.toml +++ b/crates/loko-store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko-store" -description = "loko's Store resource: objects by URI, with conditional writes, multipart uploads, and prefix listing; a store in memory" +description = "loko's Store service: objects by URI, with conditional writes, multipart uploads, and prefix listing; a store in memory" readme = "README.md" version.workspace = true edition.workspace = true diff --git a/crates/loko-store/README.md b/crates/loko-store/README.md index 07b0d6c..6e2dc92 100644 --- a/crates/loko-store/README.md +++ b/crates/loko-store/README.md @@ -1,6 +1,6 @@ # loko-store -[loko](https://github.com/polythings/loko)'s `Store` resource: objects by absolute URI, passed to code instead of +[loko](https://github.com/polythings/loko)'s `Store` service: objects by absolute URI, passed to code instead of reached through a particular client, so the same code works over S3, a local directory, memory, or a JavaScript binding. diff --git a/crates/loko-store/src/lib.rs b/crates/loko-store/src/lib.rs index 215f978..c1d41a4 100644 --- a/crates/loko-store/src/lib.rs +++ b/crates/loko-store/src/lib.rs @@ -1,4 +1,4 @@ -//! loko's `Store` resource: objects by URI, passed to code instead of reached through a particular +//! loko's `Store` service: objects by URI, passed to code instead of reached through a particular //! client, so the same code works over S3, a local directory, memory, or a JavaScript binding. //! //! - [`Store`]: get, ranged get, exists, put, put-if-absent (a conditional write), multipart upload, diff --git a/crates/loko-store/src/store.rs b/crates/loko-store/src/store.rs index 02f9dd7..75f09dd 100644 --- a/crates/loko-store/src/store.rs +++ b/crates/loko-store/src/store.rs @@ -38,65 +38,65 @@ pub trait Store { } /// A type that holds a [`Store`], and so is one: `#[loko::bundle]` implements this for a field, -/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::resource]` +/// and `#[loko::wraps]` for a wrapper that changes some methods. It's what `#[loko::service]` /// generates, written out so this crate needs no macros. pub trait HasStore { - /// The resource held. + /// The service held. type Store: Store; /// The [`Store`] this holds. fn store(&self) -> &Self::Store; - /// Forwards [`Store::get`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::get`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_get(&self, uri: &str) -> impl Future>>> { self.store().get(uri) } - /// Forwards [`Store::get_range`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::get_range`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_get_range(&self, uri: &str, start: u64, len: u64) -> impl Future>> { self.store().get_range(uri, start, len) } - /// Forwards [`Store::exists`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::exists`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_exists(&self, uri: &str) -> impl Future> { self.store().exists(uri) } - /// Forwards [`Store::put`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::put`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_put(&self, uri: &str, bytes: Vec) -> impl Future> { self.store().put(uri, bytes) } - /// Forwards [`Store::put_if_absent`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::put_if_absent`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_put_if_absent(&self, uri: &str, bytes: Vec) -> impl Future> { self.store().put_if_absent(uri, bytes) } - /// Forwards [`Store::upload`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::upload`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_upload(&self, uri: &str) -> impl Future::Upload>> { self.store().upload(uri) } - /// Forwards [`Store::list`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::list`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_list(&self, prefix: &str) -> impl Future>> { self.store().list(prefix) } - /// Forwards [`Store::delete`] to the held resource; a `#[loko::wraps]` implementation may replace it. + /// Forwards [`Store::delete`] to the held service; a `#[loko::wraps]` implementation may replace it. #[doc(hidden)] #[inline] fn wrapped_delete(&self, uris: &[String]) -> impl Future> { diff --git a/crates/loko/Cargo.toml b/crates/loko/Cargo.toml index ff00075..d11a3b4 100644 --- a/crates/loko/Cargo.toml +++ b/crates/loko/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loko" -description = "Resources passed to code instead of reached for: Clock, Random, Env, Console, and Store, with test, native, and JavaScript implementations" +description = "Services passed to code instead of reached for: Clock, Random, Env, Console, and Store, with test, native, and JavaScript implementations" readme = "README.md" version.workspace = true edition.workspace = true @@ -18,7 +18,7 @@ getrandom = ["loko-random/getrandom"] tokio = ["getrandom", "loko-clock/tokio"] # `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown` with a JavaScript host). js = ["loko-clock/js", "loko-random/js", "loko-console/js"] -# `loko::store`: the `Store` resource and `MemoryStore`. +# `loko::store`: the `Store` service and `MemoryStore`. store = ["dep:loko-store"] # `loko::object_store`: a `Store` over an `object_store` client. object-store = ["store", "dep:loko-object-store"] diff --git a/crates/loko/README.md b/crates/loko/README.md index e3a8394..19a1e9b 100644 --- a/crates/loko/README.md +++ b/crates/loko/README.md @@ -1,6 +1,6 @@ # loko -Resources for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed +Services for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed to code as traits instead of reached for as globals, with test, system, and JavaScript implementations, and platforms that bundle one of each. @@ -9,8 +9,8 @@ use std::time::Duration; use loko::{Clock, Random, Rng, TestPlatform}; -fn id(resources: &(impl Clock + Random)) -> String { - format!("{}-{:04x}", resources.now_ms(), resources.rng().next_u64() & 0xffff) +fn id(services: &(impl Clock + Random)) -> String { + format!("{}-{:04x}", services.now_ms(), services.rng().next_u64() & 0xffff) } let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program @@ -23,10 +23,10 @@ assert_eq!(id(&platform), "1000-7e6f"); | `getrandom` (default) | `OsRandom` and `StdPlatform` (native and WASI) | | `tokio` | `TokioClock` and `TokioPlatform` (native) | | `js` | `JsClock`, `JsRandom`, `JsConsole`, and `JsPlatform` (`wasm32-unknown-unknown`) | -| `store` | `loko::store`: the `Store` resource and `MemoryStore` | +| `store` | `loko::store`: the `Store` service and `MemoryStore` | | `object-store` | `loko::object_store`: a `Store` over an `object_store` client | -`#[loko::resource]` makes your own traits resources and `#[loko::bundle]` bundles them with loko's. The +`#[loko::service]` makes your own traits services and `#[loko::bundle]` bundles them with loko's. The [repository](https://github.com/polythings/loko) has the details. Apache License 2.0. diff --git a/crates/loko/src/lib.rs b/crates/loko/src/lib.rs index 74eb549..a0359b2 100644 --- a/crates/loko/src/lib.rs +++ b/crates/loko/src/lib.rs @@ -1,19 +1,19 @@ -//! Resources passed to code instead of reached for: the time, randomness, environment variables, +//! Services passed to code instead of reached for: the time, randomness, environment variables, //! the console, and object storage. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the //! program passes a platform, and tests pass a [`TestPlatform`] they control. The same code runs //! natively and in WebAssembly. //! -//! - Resources: [`clock`], [`random`], [`env`](mod@env), [`console`], and `store` (feature `store`), each a +//! - Services: [`clock`], [`random`], [`env`](mod@env), [`console`], and `store` (feature `store`), each a //! trait with test, native, and JavaScript implementations. The traits are also at the root. //! - Platforms: [`TestPlatform`], `StdPlatform`, `TokioPlatform`, and `JsPlatform` bundle one of each. -//! - [`resource`] makes a trait a resource, [`bundle`] makes a struct each resource its fields -//! provide, and [`wraps`] makes a wrapper that changes some of a resource's methods and forwards -//! the rest. [`prelude`] brings in the resources with the `HasClock`-style traits these name. +//! - [`service`] makes a trait a service, [`bundle`] makes a struct each service its fields +//! provide, and [`wraps`] makes a wrapper that changes some of a service's methods and forwards +//! the rest. [`prelude`] brings in the services with the `HasClock`-style traits these name. //! -//! Resource methods take `&self`, so one value can be several resources and clones can share state. -//! A resource whose state changes keeps it behind a lock or an atomic, locked only for the +//! Service methods take `&self`, so one value can be several services and clones can share state. +//! A service whose state changes keeps it behind a lock or an atomic, locked only for the //! synchronous part of a call: a `std::sync::Mutex` guard or `RefCell` borrow held across `.await` -//! deadlocks or panics when another task uses the resource meanwhile. +//! deadlocks or panics when another task uses the service meanwhile. //! //! ``` //! use std::time::Duration; @@ -21,8 +21,8 @@ //! use loko::{Clock, Random, Rng, TestPlatform}; //! //! /// An ID from the time and a random suffix. -//! fn id(resources: &(impl Clock + Random)) -> String { -//! format!("{}-{:04x}", resources.now_ms(), resources.rng().next_u64() & 0xffff) +//! fn id(services: &(impl Clock + Random)) -> String { +//! format!("{}-{:04x}", services.now_ms(), services.rng().next_u64() & 0xffff) //! } //! //! let platform = TestPlatform::default(); @@ -30,18 +30,18 @@ //! assert_eq!(id(&platform), "1000-7e6f"); //! ``` //! -//! A program bundles the resources it needs, its own among them: +//! A program bundles the services it needs, its own among them: //! //! ``` //! # #[cfg(feature = "store")] { //! use loko::prelude::*; //! use loko::store::MemoryStore; //! -//! #[bundle] +//! #[loko::bundle] //! struct App { -//! #[has(Store)] +//! #[loko::has(Store)] //! store: MemoryStore, -//! #[has(Clock, Random)] +//! #[loko::has(Clock, Random)] //! platform: TestPlatform, //! } //! @@ -57,7 +57,7 @@ pub use loko_console as console; pub use loko_console::{Console, HasConsole}; pub use loko_env as env; pub use loko_env::{Env, HasEnv}; -pub use loko_macros::{bundle, has, resource, wraps}; +pub use loko_macros::{bundle, has, service, wraps}; #[cfg(feature = "object-store")] pub use loko_object_store as object_store; pub use loko_random as random; @@ -74,7 +74,7 @@ mod platform; #[doc = include_str!("../../../README.md")] pub struct ReadmeExamples; -/// The resources, the `Has` traits that `#[loko::bundle]` and `#[wraps]` name next to them, the +/// The services, the `Has` traits that `#[loko::bundle]` and `#[wraps]` name next to them, the /// macros, and the platforms. pub mod prelude { #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] @@ -84,7 +84,7 @@ pub mod prelude { #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] pub use crate::TokioPlatform; pub use crate::{ - Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Random, Rng, TestPlatform, bundle, has, resource, + Clock, Console, Env, HasClock, HasConsole, HasEnv, HasRandom, Random, Rng, TestPlatform, bundle, has, service, wraps, }; #[cfg(feature = "store")] diff --git a/crates/loko/src/platform.rs b/crates/loko/src/platform.rs index 8c4f313..a5989e8 100644 --- a/crates/loko/src/platform.rs +++ b/crates/loko/src/platform.rs @@ -1,5 +1,5 @@ -//! Platforms: one of each resource, ready to pass. A platform is each resource it holds; a program -//! that needs more (a `Store`, its own resources) puts a platform in a `#[loko::bundle]` struct. +//! Platforms: one of each service, ready to pass. A platform is each service it holds; a program +//! that needs more (a `Store`, its own services) puts a platform in a `#[loko::bundle]` struct. use crate::clock::{Clock, TestClock}; use crate::console::{Console, TestConsole}; @@ -7,7 +7,7 @@ use crate::env::{Env, MapEnv}; use crate::random::{Random, SeededRandom}; use crate::{HasClock, HasConsole, HasEnv, HasRandom, bundle}; -/// Resources for tests: a [`TestClock`] at the Unix epoch, a [`SeededRandom`] with seed 0, an empty +/// Services for tests: a [`TestClock`] at the Unix epoch, a [`SeededRandom`] with seed 0, an empty /// [`MapEnv`], and a [`TestConsole`] that records lines. Clones share the clock, the generator, and /// the console; the fields are public so a test can set them up and look at them. #[bundle] @@ -56,7 +56,7 @@ impl TestPlatform { } } -/// The system's resources for programs without an async runtime (feature `getrandom`, native +/// The system's services for programs without an async runtime (feature `getrandom`, native /// targets and WASI): [`StdClock`](crate::clock::StdClock), whose `sleep` blocks the thread, /// [`OsRandom`](crate::random::OsRandom), [`SystemEnv`](crate::env::SystemEnv), and /// [`StdConsole`](crate::console::StdConsole). @@ -79,7 +79,7 @@ pub struct StdPlatform { pub console: crate::console::StdConsole, } -/// The system's resources under tokio (feature `tokio`, native targets): [`StdPlatform`] with +/// The system's services under tokio (feature `tokio`, native targets): [`StdPlatform`] with /// [`TokioClock`](crate::clock::TokioClock), whose `sleep` is tokio's timer. #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] #[bundle] @@ -100,7 +100,7 @@ pub struct TokioPlatform { pub console: crate::console::StdConsole, } -/// The JavaScript host's resources (feature `js`, `wasm32-unknown-unknown`): [`JsClock`](crate::clock::JsClock), +/// The JavaScript host's services (feature `js`, `wasm32-unknown-unknown`): [`JsClock`](crate::clock::JsClock), /// [`JsRandom`](crate::random::JsRandom), a [`MapEnv`] (JavaScript hosts have no common environment; /// set the variables the program reads), and [`JsConsole`](crate::console::JsConsole). #[cfg(all(feature = "js", all(target_arch = "wasm32", target_os = "unknown")))] @@ -130,7 +130,7 @@ mod tests { use crate::Rng; #[test] - fn a_test_platform_is_each_resource() { + fn a_test_platform_is_each_service() { let platform = TestPlatform::new().at_ms(1_000).seeded(0).with_var("REGION", "us-east-2"); platform.clock.advance(Duration::from_millis(500)); assert_eq!(platform.now_ms(), 1_500); diff --git a/crates/loko/tests/resources.rs b/crates/loko/tests/services.rs similarity index 92% rename from crates/loko/tests/resources.rs rename to crates/loko/tests/services.rs index 2807cc9..92ce21b 100644 --- a/crates/loko/tests/resources.rs +++ b/crates/loko/tests/services.rs @@ -1,18 +1,18 @@ -//! Resources and bundles defined outside loko work like loko's own. +//! Services and bundles defined outside loko work like loko's own. use std::time::Duration; use loko::clock::TestClock; use loko::prelude::*; -/// A resource of this crate's own. -#[loko::resource] +/// A service of this crate's own. +#[loko::service] trait Greeter { /// A greeting for `name`. fn greet(&self, name: &str) -> String; } -/// A greeter that needs other resources: the clock for the time of day. +/// A greeter that needs other services: the clock for the time of day. struct ClockGreeter { clock: C, } @@ -33,14 +33,14 @@ struct App { greeter: G, } -/// Code that needs a mix of loko's resources and this crate's. +/// Code that needs a mix of loko's services and this crate's. fn welcome(app: &(impl Greeter + Console + Env)) { let name = app.var("USER").unwrap_or_else(|| "stranger".into()); app.print_line(&app.greet(&name)).expect("a test console doesn't fail"); } #[test] -fn own_resources_bundle_with_loko_resources() { +fn own_services_bundle_with_loko_services() { let platform = TestPlatform::new().with_var("USER", "Ada"); let greeter = ClockGreeter { clock: platform.clock.clone() }; let app = App { platform: platform.clone(), greeter }; @@ -80,7 +80,7 @@ mod store { /// Async methods, an associated type, and default methods forward. #[test] - fn async_resources_forward() { + fn async_services_forward() { let app = Stored { store: MemoryStore::new(), platform: TestPlatform::new().at_ms(42) }; let run = async { let uri = format!("memory:///logs/{}", app.now_ms()); @@ -95,7 +95,7 @@ mod store { }; let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); let std::task::Poll::Ready((listed, got)) = std::pin::pin!(run).poll(&mut cx) else { - panic!("memory and test resources are ready at once"); + panic!("memory and test services are ready at once"); }; assert_eq!(listed, ["memory:///big", "memory:///logs/42"]); assert_eq!(got.as_deref(), Some(&b"a"[..])); @@ -156,9 +156,9 @@ fn store_wrappers_stack() { assert_eq!(memory.uris(), ["memory://wh/a/t"]); } -/// A resource with a generic method (whose `T` the generated code mustn't clash with) and a method +/// A service with a generic method (whose `T` the generated code mustn't clash with) and a method /// compiled out by `#[cfg]` (whose generated items must go with it). -#[loko::resource] +#[loko::service] trait Log { /// The line logged for `value`. fn line(&self, value: T) -> String; diff --git a/spec/0001-resources.md b/spec/0001-services.md similarity index 51% rename from spec/0001-resources.md rename to spec/0001-services.md index 2782413..eac833a 100644 --- a/spec/0001-resources.md +++ b/spec/0001-services.md @@ -1,6 +1,6 @@ --- number: 1 -title: Resources, bundles, and wrappers +title: Services, bundles, and wrappers status: done updated: 2026-10-21 versions: [unreleased] @@ -8,48 +8,48 @@ related: [2, 3, 4, 5, 6, 7] issues: [1, 2, 4, 7] --- -# 1. Resources, bundles, and wrappers +# 1. Services, bundles, and wrappers **Done** · updated 2026-10-21 · versions unreleased · milestone: [0.2](milestones/v0-2.md) ## Summary -A resource is a trait for something code would otherwise reach for as a global: the time, random numbers, -environment variables, the console, object storage. Code takes the resources it needs as `&impl Trait`, the program -passes the system's, and tests pass ones they control. `#[resource]`, `#[loko::bundle]`, and `#[wraps]` let -anyone define new resources, bundle them with loko's, and wrap one implementation in another. +A service is a trait for something code would otherwise reach for as a global: the time, random numbers, +environment variables, the console, object storage. Code takes the services it needs as `&impl Trait`, the program +passes the system's, and tests pass ones they control. `#[service]`, `#[loko::bundle]`, and `#[wraps]` let +anyone define new services, bundle them with loko's, and wrap one implementation in another. ## Design -- **A resource** is a trait marked `#[loko_macros::resource]` (`#[loko::resource]` through the `loko` crate). Its +- **A service** is a trait marked `#[loko_macros::service]` (`#[loko::service]` through the `loko` crate). Its methods take `&self`; async ones return `impl Future` with no `Send` bound, so an implementation can wrap JavaScript promises. The trait can't be generic; it can have associated types, constants, and provided methods. -- **`#[resource]` on `trait X`** adds: +- **`#[service]` on `trait X`** adds: - `trait HasX { type X: X; fn x(&self) -> &Self::X; … }` (the accessor is the trait's name in snake case), with - a hidden method `wrapped_m` for each method `m` of `X` that forwards to the held resource by default, and + a hidden method `wrapped_m` for each method `m` of `X` that forwards to the held service by default, and - `impl X for T`, whose methods call the `wrapped_` ones, and whose associated types and constants are the - held resource's. Provided methods forward too, so an implementation's overrides are kept. + held service's. Provided methods forward too, so an implementation's overrides are kept. - **`#[loko::bundle]`** on a struct implements `HasX` for each `#[loko::has(X, …)]` on a field, with the field's - type as the held resource (and `FieldType: X` as a bound, so generic fields work). Named and tuple fields both work. - A field can provide several resources, such as a platform providing `Clock, Random`. + type as the held service (and `FieldType: X` as a bound, so generic fields work). Named and tuple fields both work. + A field can provide several services, such as a platform providing `Clock, Random`. - **Paths:** `#[loko::bundle]` names `HasX` next to the path given: `#[loko::has(loko::Clock)]` uses `loko::HasClock`, and a - bare `#[loko::has(Clock)]` needs `HasClock` in scope. `loko::prelude` brings in each resource with its `HasX`. + bare `#[loko::has(Clock)]` needs `HasClock` in scope. `loko::prelude` brings in each service with its `HasX`. - **`#[wraps(field: Type)]`** on `impl X for Wrapper` implements `HasX` instead, with `field` (a name or a tuple - index) of type `Type` as the held resource. The methods written become the `wrapped_` methods, replacing the - forwarding ones; `Self::Assoc` in their signatures means the held resource's. A wrapper keeps the held resource's + index) of type `Type` as the held service. The methods written become the `wrapped_` methods, replacing the + forwarding ones; `Self::Assoc` in their signatures means the held service's. A wrapper keeps the held service's associated types and constants, and a provided method it doesn't write forwards to the held one, so a wrapper writes the provided methods that depend on what it changes (`OffsetClock` writes `now_ms` as well as `now`). Wrappers stack: `CountingStore>`. -- **`&self` and state:** every method takes `&self`, so one value can be several resources (`f(r, r)`), clones can share - state, and resources can be held across `.await`. An implementation whose state changes keeps it behind a lock or an +- **`&self` and state:** every method takes `&self`, so one value can be several services (`f(r, r)`), clones can share + state, and services can be held across `.await`. An implementation whose state changes keeps it behind a lock or an atomic, locked only for the synchronous part of a call; a lock or `RefCell` borrow held across `.await` deadlocks or - panics when another task uses the resource meanwhile. -- **Composition:** an implementation of one resource can hold others (a greeter that holds a clock), and a program's - bundle can mix loko's resources with its own. Code asks only for what it uses: `&(impl Store + Clock)`. -- **Crates:** one per resource (`loko-clock`, `loko-random`, `loko-env`, `loko-console`, `loko-store`), each with its + panics when another task uses the service meanwhile. +- **Composition:** an implementation of one service can hold others (a greeter that holds a clock), and a program's + bundle can mix loko's services with its own. Code asks only for what it uses: `&(impl Store + Clock)`. +- **Crates:** one per service (`loko-clock`, `loko-random`, `loko-env`, `loko-console`, `loko-store`), each with its trait and implementations; `loko-object-store`; `loko-macros`; and `loko`, which re-exports them (`loko::clock`, …; the traits also at the root), adds the platforms ([spec 7](0007-platforms.md)), and has a feature - per optional part. A library that needs one resource depends on its crate alone. + per optional part. A library that needs one service depends on its crate alone. - **Targets:** every crate builds for native targets, `wasm32-unknown-unknown`, and `wasm32-wasip2`. JavaScript implementations need feature `js` and `wasm32-unknown-unknown`; system ones are on every target with a system (native and WASI). @@ -58,26 +58,27 @@ anyone define new resources, bundle them with loko's, and wrap one implementatio | ID | Decision | Status | |---|---|---| -| D1 | A crate per resource, re-exported by `loko`, so a library takes only the resource it needs. | Accepted | -| D2 | Futures aren't `Send`: resources can wrap JavaScript promises; multi-threaded executors are an open question (#2). | Accepted | -| D3 | `#[resource]` adds `HasX` and a blanket `impl X for T`; `#[loko::bundle]` implements `HasX` per field attribute. A resource implemented by hand and one held in a bundle look the same to callers. | Accepted | -| D4 | `#[loko::bundle]` names `HasX` next to the path given, and `loko::prelude` brings each resource in with its `HasX`, rather than generating macros named after the traits. | Accepted | +| D1 | A crate per service, re-exported by `loko`, so a library takes only the service it needs. | Accepted | +| D2 | Futures aren't `Send`: services can wrap JavaScript promises; multi-threaded executors are an open question (#2). | Accepted | +| D3 | `#[service]` adds `HasX` and a blanket `impl X for T`; `#[loko::bundle]` implements `HasX` per field attribute. A service implemented by hand and one held in a bundle look the same to callers. | Accepted | +| D4 | `#[loko::bundle]` names `HasX` next to the path given, and `loko::prelude` brings each service in with its `HasX`, rather than generating macros named after the traits. | Accepted | | D5 | The crate is `loko`, at 0.2.0: the name's owner already published an unrelated 0.1.0 in 2022. | Accepted | -| D16 | The things passed are called resources (`#[resource]`), not services: "service" means a request handler in Rust (tower) and a server elsewhere, and a resource is what Bevy calls its shared `Time`. | Accepted | +| D16 | The things passed are called services (`#[service]`), the word ZIO and most dependency-injection libraries use (ferrous-di, dependency-injector, .NET, Angular). tower's `Service` is a different thing, a request handler; loko exports no `Service` trait, so the two don't meet in code. | Accepted | | D17 | Bundles are declared with `#[loko::bundle]` on the struct and `#[loko::has(X, …)]` on fields: the struct-level name says what the struct is, the field-level one what each field holds. An attribute macro (not a derive) so the field attribute can be the path `loko::has` and the three macros look alike. | Accepted | | D18 | Wrappers implement `HasX` through `#[wraps]`, overriding hidden `wrapped_` methods, so they write only what they change. The forwarding lives in `HasX`, whose signatures resolve where the trait is defined, rather than in generated macros that would resolve them at the wrapper. | Accepted | -| D19 | loko's own resource crates write out what `#[resource]` generates instead of using the macro, so they have no proc-macro dependency (and `loko-store` no `thiserror`): `loko-clock`, `loko-env`, and `loko-console` depend on nothing, `loko-random` only on `rand_core`. | Accepted | +| D19 | loko's own service crates write out what `#[service]` generates instead of using the macro, so they have no proc-macro dependency (and `loko-store` no `thiserror`): `loko-clock`, `loko-env`, and `loko-console` depend on nothing, `loko-random` only on `rand_core`. | Accepted | ## Open questions and issues - [#1](https://github.com/polythings/loko/issues/1) Publish 0.2.0 to crates.io - [#2](https://github.com/polythings/loko/issues/2) Send futures for multi-threaded executors -- [#4](https://github.com/polythings/loko/issues/4) Which resources come next? +- [#4](https://github.com/polythings/loko/issues/4) Which services come next? - [#7](https://github.com/polythings/loko/issues/7) Layers: stacking wrappers in reading order ## History - 2026-10-20: built for 0.2, as services with `#[derive(Services)]`. -- 2026-10-21: services renamed resources (D16); `#[derive(Services)]` became `#[loko::bundle]` with `#[loko::has]` fields (D17); +- 2026-10-21: `#[derive(Services)]` became `#[loko::bundle]` with `#[loko::has]` fields (D17); services were called + resources for a day, and are services again (D16); `#[wraps]` added (D18). -- 2026-10-21: loko's own resources written out by hand, without the macros (D19). +- 2026-10-21: loko's own services written out by hand, without the macros (D19). diff --git a/spec/0002-clock.md b/spec/0002-clock.md index fcd40e8..c0f7ba2 100644 --- a/spec/0002-clock.md +++ b/spec/0002-clock.md @@ -28,7 +28,7 @@ and skip the waits. - **`StdClock`:** `SystemTime::now`, and a `sleep` that blocks the thread before returning a ready future: for programs without an async runtime (native and WASI). - **`TokioClock`** (feature `tokio`, native): `SystemTime::now` and `tokio::time::sleep`. -- **`OffsetClock`:** a wrapper ([spec 1](0001-resources.md)): another clock, `ahead` or `behind` by a fixed +- **`OffsetClock`:** a wrapper ([spec 1](0001-services.md)): another clock, `ahead` or `behind` by a fixed `Duration`, for testing code against clock skew. It writes `now_ms` as well as `now`; sleeping is the other clock's. - **`to_ms(SystemTime) -> i64`:** what `now_ms` computes, for wrappers that change `now`. - **`JsClock`** (feature `js`, `wasm32-unknown-unknown`): `Date.now` and a promise around `setTimeout`, in steps of diff --git a/spec/0003-random.md b/spec/0003-random.md index ad31f12..4c6058c 100644 --- a/spec/0003-random.md +++ b/spec/0003-random.md @@ -30,7 +30,7 @@ a generator of its own, so tests get the same numbers on every run and drawing m - **`JsRandom`** (feature `js`, `wasm32-unknown-unknown`): `JsRng`, which calls `crypto.getRandomValues` in chunks of at most 65,536 bytes. A `CryptoRng`; it panics on a host without Web Crypto rather than fall back to numbers that aren't. -- **`Mutex`** is a `Random` for any `R: SeedableRng + Rng`, so a program's existing generator works as a resource; +- **`Mutex`** is a `Random` for any `R: SeedableRng + Rng`, so a program's existing generator works as a service; `rng()` forks it (`SeedableRng::fork`). - `rand_core`'s `Rng`, `CryptoRng`, and `SeedableRng` are re-exported, and `Rng` is in `loko::prelude`. diff --git a/spec/0006-store.md b/spec/0006-store.md index bf808b5..74ba592 100644 --- a/spec/0006-store.md +++ b/spec/0006-store.md @@ -26,7 +26,7 @@ over S3, a local directory, memory, or a JavaScript binding. - **`Error`:** `NotFound`, `Invalid`, `Unsupported`, and `Store` (the backend's error, as text); `non_exhaustive`. Libraries with their own error type convert from it. - **`MemoryStore`:** objects in memory, for tests. Clones share the objects. -- **Wrappers** ([spec 1](0001-resources.md)), each a `Store` around another; they stack: +- **Wrappers** ([spec 1](0001-services.md)), each a `Store` around another; they stack: - `CountingStore`: counts the calls of each kind it passes on (`counts()`, `reset()`); clones share the counts. - `ReadOnlyStore`: reads through; `put`, `put_if_absent`, `upload`, and `delete` return `Unsupported`. - `PrefixedStore`: passes on only URIs under its prefix (a `/` is added if missing), refusing the rest and any `.` diff --git a/spec/0007-platforms.md b/spec/0007-platforms.md index 6ef72e7..0587fb9 100644 --- a/spec/0007-platforms.md +++ b/spec/0007-platforms.md @@ -14,7 +14,7 @@ issues: [3] ## Summary -A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, ready to pass: the test resources, the +A platform is one of each of `Clock`, `Random`, `Env`, and `Console`, ready to pass: the test services, the system's, the system's under tokio, or a JavaScript host's. A program that needs more puts a platform in its own `#[loko::bundle]` bundle. @@ -27,9 +27,9 @@ system's, the system's under tokio, or a JavaScript host's. A program that needs | `TokioPlatform` | `TokioClock` | `OsRandom` | `SystemEnv` | `StdConsole` | Native (feature `tokio`) | | `JsPlatform` | `JsClock` | `JsRandom` | `MapEnv` | `JsConsole` | `wasm32-unknown-unknown` (feature `js`) | -- Each platform is a struct with public fields, one per resource, built with `#[loko::bundle]`, so it is each - resource and a test can set up and inspect the parts (`platform.clock.advance(…)`, `platform.console.lines()`). -- The structs are `non_exhaustive`, so a resource added later isn't a breaking change; they are built with `default()` +- Each platform is a struct with public fields, one per service, built with `#[loko::bundle]`, so it is each + service and a test can set up and inspect the parts (`platform.clock.advance(…)`, `platform.console.lines()`). +- The structs are `non_exhaustive`, so a service added later isn't a breaking change; they are built with `default()` (and `TestPlatform`'s `at_ms`, `seeded`, and `with_var`). - `Store` is not in a platform: which store a program uses is its own choice. diff --git a/spec/README.md b/spec/README.md index b3801cb..1a98047 100644 --- a/spec/README.md +++ b/spec/README.md @@ -18,7 +18,7 @@ loko's design. Start here, people and agents alike. ## Writing and changing a spec -- **When:** anything beyond a small fix gets a spec, or a change to one, agreed before it is built. A new resource is +- **When:** anything beyond a small fix gets a spec, or a change to one, agreed before it is built. A new service is a new spec. - **New spec:** copy [TEMPLATE.md](TEMPLATE.md) to `NNNN-short-name.md` with the next free number (numbers are never reused), and add it to a milestone and to the index below. @@ -33,7 +33,7 @@ loko's design. Start here, people and agents alike. | # | Spec | Status | Updated | |---:|---|---|---| -| 1 | [Resources, bundles, and wrappers](0001-resources.md) | Done | 2026-10-21 | +| 1 | [Services, bundles, and wrappers](0001-services.md) | Done | 2026-10-21 | | 2 | [Clock](0002-clock.md) | Done | 2026-10-21 | | 3 | [Random](0003-random.md) | Done | 2026-10-21 | | 4 | [Env](0004-env.md) | Done | 2026-10-20 | diff --git a/spec/milestones/v0-2.md b/spec/milestones/v0-2.md index 77ef9fa..89a64a2 100644 --- a/spec/milestones/v0-2.md +++ b/spec/milestones/v0-2.md @@ -2,12 +2,12 @@ **In progress** -The first release as a resources library: `Clock`, `Random`, `Env`, `Console`, and `Store` with test, system, and -JavaScript implementations, the platforms, and the macros for new resources and bundles. +The first release as a services library: `Clock`, `Random`, `Env`, `Console`, and `Store` with test, system, and +JavaScript implementations, the platforms, and the macros for new services and bundles. | # | Spec | Status | |---:|---|---| -| 1 | [Resources, bundles, and wrappers](../0001-resources.md) | Done | +| 1 | [Services, bundles, and wrappers](../0001-services.md) | Done | | 2 | [Clock](../0002-clock.md) | Done | | 3 | [Random](../0003-random.md) | Done | | 4 | [Env](../0004-env.md) | Done | From 40ec180a667b05bf725e75a2056f1e585443bc60 Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 04:28:33 -0400 Subject: [PATCH 18/19] README: simpler examples, and why loko first A "Why" section opens with one before-and-after case (a one-hour session timeout a test checks at once) and what loko gives you. The guide is ten short steps, each showing one benefit: waiting without waiting, random numbers that repeat, several services in one platform, swappable storage, bundles, your own service, your own implementation and platform, wrappers, services with state, and the browser. All examples still run as doctests. Co-Authored-By: Claude Opus 5.5 --- README.md | 608 +++++++++++++----------------------------- crates/loko/README.md | 17 +- 2 files changed, 194 insertions(+), 431 deletions(-) diff --git a/README.md b/README.md index e27d09f..e8a0ac6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,43 @@ # loko -Services for Rust programs: the time, random numbers, environment variables, the console, and object storage, passed -to code as traits instead of reached for as globals. Code takes `&impl Clock` (or `&(impl Clock + Random)`, …), the -program passes the system's services, and tests pass services they control. The same code runs natively, under WASI, -and in browsers and Workers. +Services for Rust programs: the clock, random numbers, environment variables, the console, and object storage, passed +to code as traits instead of reached for as globals. The program passes the real ones, tests pass ones they control, +and the same code runs natively, under WASI, and in the browser. + +## Why + +Code that calls `SystemTime::now()`, sleeps, or draws from a global random generator is hard to test: checking a +one-hour timeout takes an hour, and random retries behave differently on every run. With loko, the code takes the clock +as an argument, and the test decides what time it is: + +```rust +use std::time::{Duration, SystemTime}; + +use loko::Clock; +use loko::clock::TestClock; + +/// Whether a session that started at `started` has expired. +fn expired(clock: &impl Clock, started: SystemTime) -> bool { + clock.now() > started + Duration::from_secs(60 * 60) +} + +let clock = TestClock::at_ms(0); +let started = clock.now(); +assert!(!expired(&clock, started)); +clock.advance(Duration::from_secs(60 * 60 + 1)); // an hour passes, at once +assert!(expired(&clock, started)); +``` + +The program passes `StdClock`, `TokioClock`, or `JsClock` in the browser, and `expired` stays as it is. What you get: + +- **Tests that control time.** A test clock moves only when told, and sleeping on it returns at once, so retries, + timeouts, and polling loops run in tests without waiting. +- **Random numbers that repeat in tests,** and secure ones in programs, with signatures that say which is needed. +- **One codebase for native, WASI, and the browser.** Platforms bundle the right implementations for each, and nothing + requires `Send`, so services backed by JavaScript work. +- **Storage you can swap:** memory in tests, S3, GCS, Azure, or local files in production. +- **Tests that set environment variables and read what was printed,** without touching the process. +- **Your own services and wrappers,** with three small macros, in the same style. ```toml [dependencies] @@ -20,65 +54,30 @@ loko = { version = "0.2", features = ["tokio", "store"] } ## Guide -The examples build on each other, from one service to bundles of your own services and wrappers. - -### 1. One service - -A function that needs the time takes a `Clock` instead of calling `SystemTime::now()`. The program passes the system -clock; a test passes a clock it sets. - -```rust -use loko::Clock; -use loko::clock::{StdClock, TestClock}; - -/// A log line stamped with the time. -fn stamp(clock: &impl Clock, message: &str) -> String { - format!("[{}] {message}", clock.now_ms()) -} - -// A program: the system clock. -let line = stamp(&StdClock, "started"); -assert!(line.ends_with("] started")); - -// A test: a clock that moves only when told. -let clock = TestClock::at_ms(1_000); -assert_eq!(stamp(&clock, "started"), "[1000] started"); -``` - -### 2. Waiting without waiting +### 1. Waiting without waiting -`sleep` is a `Clock` method too. A test clock's sleep returns at once and moves the clock forward, so code that waits -(retries, timeouts, polling) runs in tests without waiting. Random numbers come from a generator that a `Random` -hands out (`rng()`), so the jitter is the same on every test run. +A retry loop that waits a second between tries. Its test takes no time, because a test platform's sleeps move its +clock instead of blocking. ```rust use std::time::Duration; -use loko::{Clock, Random, Rng, TestPlatform}; - -/// Calls `attempt` up to five times, waiting one to two seconds between tries. -async fn retry( - services: &(impl Clock + Random), - mut attempt: impl FnMut() -> Result, -) -> Result { - let mut rng = services.rng(); - let mut tries = 1; - loop { - match attempt() { - Err(_) if tries < 5 => { - let jitter = rng.next_u64() % 1_000; - services.sleep(Duration::from_millis(1_000 + jitter)).await; - tries += 1; - } - result => return result, +use loko::{Clock, TestPlatform}; + +/// Tries `attempt` up to three times, a second apart. +async fn retry(clock: &impl Clock, mut attempt: impl FnMut() -> Result) -> Result { + for _ in 1..3 { + if let Ok(value) = attempt() { + return Ok(value); } + clock.sleep(Duration::from_secs(1)).await; } + attempt() } #[tokio::main(flavor = "current_thread")] async fn main() { - // A program would pass `TokioPlatform::default()`. - let platform = TestPlatform::new(); + let platform = TestPlatform::new(); // TokioPlatform::default() in a program let mut calls = 0; let result = retry(&platform, || { calls += 1; @@ -86,66 +85,67 @@ async fn main() { }) .await; assert_eq!(result, Ok(3)); - // Two waits of one to two seconds each, on the test clock: the test itself took no time. - assert!((2_000..4_000).contains(&platform.now_ms())); + assert_eq!(platform.now_ms(), 2_000, "two seconds of test time, none of real time"); } ``` -### 3. Several services +### 2. Random numbers that repeat -A function can take each service as its own argument, or one value that is all of them. A platform is one of each -of `Clock`, `Random`, `Env`, and `Console`: `TestPlatform` for tests, `StdPlatform` or `TokioPlatform` for programs, -and `JsPlatform` in the browser. +A `Random` hands out generators (`rng()`). In tests they come from a seed, so every run makes the same choices. ```rust -use loko::{Console, Env, TestPlatform}; +use loko::{Random, Rng, TestPlatform}; -/// Separate arguments: plain, and fine for two or three services. -fn greet(env: &impl Env, console: &impl Console) -> std::io::Result<()> { - let name = env.var("USER").unwrap_or_else(|| "stranger".into()); - console.print_line(&format!("Hello, {name}")) +/// Picks one of `servers` at random. +fn pick<'a>(random: &impl Random, servers: &[&'a str]) -> &'a str { + servers[(random.rng().next_u64() % servers.len() as u64) as usize] } -/// One value: handier as the list grows, and easy to store. -fn greet_all(services: &(impl Env + Console)) -> std::io::Result<()> { - greet(services, services) +let servers = ["a", "b", "c"]; +assert_eq!(pick(&TestPlatform::new(), &servers), pick(&TestPlatform::new(), &servers)); +``` + +Code that makes secrets asks for a secure generator, `fn token(random: &impl Random)`, so a seeded +one can't be passed to it by mistake. Drawing many numbers from one `rng()` takes no locks. + +### 3. Several services, one platform + +A function can ask for several services at once. A platform is one of each of `Clock`, `Random`, `Env`, and `Console`: +`TestPlatform` for tests, `StdPlatform` or `TokioPlatform` for programs, `JsPlatform` in the browser. + +```rust +use loko::{Console, Env, TestPlatform}; + +fn greet(services: &(impl Env + Console)) { + let name = services.var("USER").unwrap_or_else(|| "stranger".into()); + services.print_line(&format!("Hello, {name}")).unwrap(); } -// A program would pass `StdPlatform::default()`. let platform = TestPlatform::new().with_var("USER", "Ada"); -greet_all(&platform).unwrap(); +greet(&platform); assert_eq!(platform.console.lines(), ["Hello, Ada"]); ``` ### 4. Storage -`Store` reads and writes objects by URI, with a conditional write (`put_if_absent`) for the cases where two writers -must not overwrite each other. Tests use `MemoryStore`; programs use `ObjectStoreAdapter` over S3, GCS, Azure, or local -files (feature `object-store`, with the backend enabled on your `object_store` dependency). +`Store` reads and writes objects by URI, including a write that succeeds only if nothing is there yet, for writers +that must not overwrite each other. ```rust use loko::store::{MemoryStore, Store}; -/// Writes a report unless one with that name exists. -async fn save(store: &impl Store, name: &str, body: &str) -> loko::store::Result { - store.put_if_absent(&format!("memory://reports/{name}"), body.as_bytes().to_vec()).await -} - #[tokio::main(flavor = "current_thread")] -async fn main() -> loko::store::Result<()> { - // A program: ObjectStoreAdapter::from_url("s3://bucket/reports", [("aws_region", "us-east-2")])? - let store = MemoryStore::new(); - assert!(save(&store, "a.txt", "first").await?); - assert!(!save(&store, "a.txt", "second").await?, "the first write wins"); - Ok(()) +async fn main() { + let store = MemoryStore::new(); // ObjectStoreAdapter::from_url("s3://bucket/…", options)? in production + assert!(store.put_if_absent("memory://a.txt", b"first".to_vec()).await.unwrap()); + assert!(!store.put_if_absent("memory://a.txt", b"second".to_vec()).await.unwrap(), "the first write wins"); } ``` ### 5. Bundles -When code needs a store and the time together, put them in one struct with `#[loko::bundle]`: each field marked -`#[loko::has(…)]` makes the struct that service. Generic fields let the program and its tests fill the same struct -differently. +When code needs a platform and a store together, put them in one struct: `#[loko::bundle]` makes it each service its +fields hold, as marked with `#[loko::has(…)]`. ```rust use loko::prelude::*; @@ -159,300 +159,123 @@ struct App { store: S, } -/// Saves a report under a name that sorts by time. -async fn save_report(app: &(impl Store + Clock), body: &str) -> loko::store::Result { +/// Saves a report named by the time. +async fn save_report(app: &(impl Store + Clock)) -> loko::store::Result { let uri = format!("memory://reports/{}.txt", app.now_ms()); - app.put(&uri, body.as_bytes().to_vec()).await?; + app.put(&uri, b"ok".to_vec()).await?; Ok(uri) } #[tokio::main(flavor = "current_thread")] -async fn main() -> loko::store::Result<()> { - // A program: App { platform: TokioPlatform::default(), store: ObjectStoreAdapter::from_url(…)? } +async fn main() { + // TokioPlatform and an S3 store in production. let app = App { platform: TestPlatform::new().at_ms(42), store: MemoryStore::new() }; - assert_eq!(save_report(&app, "all good").await?, "memory://reports/42.txt"); - assert_eq!(app.store.uris(), ["memory://reports/42.txt"]); - Ok(()) + assert_eq!(save_report(&app).await.unwrap(), "memory://reports/42.txt"); } ``` -`#[loko::has(Clock)]` names `HasClock` next to `Clock`, so bring both in: `loko::prelude::*` does for loko's services, -or write the path (`#[loko::has(loko::Clock)]`). +`#[loko::has(Clock)]` names `HasClock` next to `Clock`, so bring both in (`loko::prelude::*` does). A library type can +hold its services the same way, as one generic field: `struct Uploader { services: S }` with +`impl Uploader`. -### 6. Keeping services in a struct +### 6. Your own service -A library type holds its services as one generic field and asks for what each method needs. +`#[loko::service]` makes any trait a service, so your own dependencies (a mailer, a payment client) work like loko's: +a real implementation for the program, a test one that records, and a field in the bundle. ```rust +use std::sync::Mutex; + use loko::prelude::*; -use loko::store::MemoryStore; -/// Uploads files under a prefix, named by time and a random suffix. -struct Uploader { - services: R, - prefix: String, +#[loko::service] +trait Mailer { + fn send(&self, to: &str, body: &str); } -impl Uploader { - async fn upload(&self, body: Vec) -> loko::store::Result { - let r = &self.services; - let uri = format!("{}/{}-{:04x}", self.prefix, r.now_ms(), r.rng().next_u64() & 0xffff); - r.put(&uri, body).await?; - Ok(uri) +/// For tests: remembers what was sent. +#[derive(Default)] +struct TestMailer { + sent: Mutex>, +} + +impl Mailer for TestMailer { + fn send(&self, to: &str, body: &str) { + self.sent.lock().unwrap().push(format!("{to}: {body}")); } } +fn report_backup(app: &(impl Mailer + Env)) { + let to = app.var("ADMIN").unwrap_or_else(|| "root".into()); + app.send(&to, "backup finished"); +} + #[loko::bundle] -struct Test { - #[loko::has(Clock, Random)] +struct App { + #[loko::has(Env)] platform: TestPlatform, - #[loko::has(Store)] - store: MemoryStore, + #[loko::has(Mailer)] + mailer: TestMailer, } -#[tokio::main(flavor = "current_thread")] -async fn main() -> loko::store::Result<()> { - let uploader = Uploader { - services: Test { platform: TestPlatform::new(), store: MemoryStore::new() }, - prefix: "memory://uploads".into(), - }; - assert_eq!(uploader.upload(b"x".to_vec()).await?, "memory://uploads/0-7e6f"); - Ok(()) -} +let app = App { platform: TestPlatform::new().with_var("ADMIN", "ops"), mailer: TestMailer::default() }; +report_backup(&app); +assert_eq!(*app.mailer.sent.lock().unwrap(), ["ops: backup finished"]); ``` -### 7. Your own implementation of a service +### 7. Your own implementation and platform -loko's services are ordinary traits, so you can implement one yourself: a clock on a runtime loko doesn't ship, an -`Env` read from a config file, a `Store` over your own storage. Implement the required methods and the provided ones -(here `now_ms`) come with it; then it goes anywhere the trait is asked for, and into bundles with `#[loko::has(Clock)]`. -Step 11 makes the same clock as a wrapper around any other clock. +loko's services are ordinary traits, so you can implement one: write the required methods, and the provided ones +(`now_ms`) come with it. A platform is a bundle with one field per service, which is how loko's own are built. ```rust use std::future::{Future, ready}; -use std::time::{Duration, Instant, SystemTime}; - -use loko::Clock; +use std::time::{Duration, SystemTime}; -/// A clock that runs `speed` times faster than real time, for demos and simulations where an hour -/// of the program's time should pass in seconds. -struct FastClock { - started: (SystemTime, Instant), - speed: u32, -} +use loko::console::TestConsole; +use loko::env::MapEnv; +use loko::prelude::*; +use loko::random::SeededRandom; -impl FastClock { - fn new(speed: u32) -> Self { - Self { started: (SystemTime::now(), Instant::now()), speed } - } -} +/// A clock stopped at one moment, for output that must be the same on every run. +struct FrozenClock(SystemTime); -impl Clock for FastClock { +impl Clock for FrozenClock { fn now(&self) -> SystemTime { - let (wall, steady) = self.started; - wall + steady.elapsed() * self.speed + self.0 } - fn sleep(&self, duration: Duration) -> impl Future { - // Blocks the thread, as StdClock does; on tokio, return `tokio::time::sleep(duration / self.speed)`. - std::thread::sleep(duration / self.speed); + fn sleep(&self, _: Duration) -> impl Future { ready(()) } } -#[tokio::main(flavor = "current_thread")] -async fn main() { - let clock = FastClock::new(1_000); - let before = clock.now_ms(); - clock.sleep(Duration::from_secs(2)).await; // two seconds of clock time, two milliseconds of real time - assert!(clock.now_ms() - before >= 2_000); -} -``` - -### 8. Your own platform - -A platform is nothing more than a `#[loko::bundle]` struct with a field for each service, which is how loko's own -are built. Make one when your program runs somewhere loko's don't fit: a clock of your own (step 7), a different -generator, variables from a file. This one is the real system with a fixed random seed, for reproducing a run whose -random choices led to a bug. - -```rust -use loko::clock::StdClock; -use loko::console::StdConsole; -use loko::env::SystemEnv; -use loko::prelude::*; -use loko::random::SeededRandom; - -/// The system's clock, environment, and console, with random numbers from a fixed seed. +/// Everything fixed: for snapshot tests. #[loko::bundle] -#[derive(Clone, Debug)] -pub struct ReplayPlatform { +struct SnapshotPlatform { #[loko::has(Clock)] - pub clock: StdClock, + clock: FrozenClock, #[loko::has(Random)] - pub random: SeededRandom, + random: SeededRandom, #[loko::has(Env)] - pub env: SystemEnv, + env: MapEnv, #[loko::has(Console)] - pub console: StdConsole, -} - -impl ReplayPlatform { - /// The run of `seed`: the same random choices every time. - pub fn new(seed: u64) -> Self { - Self { clock: StdClock, random: SeededRandom::new(seed), env: SystemEnv, console: StdConsole } - } -} - -/// Picks one of `servers` at random. -fn pick<'a>(services: &impl Random, servers: &[&'a str]) -> &'a str { - servers[(services.rng().next_u64() % servers.len() as u64) as usize] -} - -// A program logs its seed, then a later run passes the same one to see the same choices. -let servers = ["a", "b", "c"]; -assert_eq!(pick(&ReplayPlatform::new(7), &servers), pick(&ReplayPlatform::new(7), &servers)); -``` - -A platform can hold more than the four, such as a `Store` or services of your own; at that point it's a bundle -(step 5), and the two are the same thing. Give it public fields so tests can reach the parts. If other crates use it, -mark it `#[non_exhaustive]` and give it a constructor, so adding a service later isn't a breaking change. - -### 9. Your own service - -`#[loko::service]` makes any trait a service, so a program's own dependencies (a mailer, a payment client, a feature -flag service) work like loko's: a real implementation for the program, a test one that records, and a place in the -bundle. An implementation can use other services. - -```rust -use std::future::{Future, ready}; -use std::sync::{Arc, Mutex}; - -use loko::prelude::*; - -#[loko::service] -trait Mailer { - /// Sends `body` to `to`. - fn send(&self, to: &str, body: &str) -> impl Future>; -} - -/// For tests: keeps what would have been sent. -#[derive(Clone, Default)] -struct TestMailer { - sent: Arc>>, -} - -impl Mailer for TestMailer { - fn send(&self, to: &str, body: &str) -> impl Future> { - self.sent.lock().unwrap().push((to.into(), body.into())); - ready(Ok(())) - } -} - -#[loko::bundle] -struct App { - #[loko::has(Clock, Random, Env, Console)] - platform: TestPlatform, - #[loko::has(Mailer)] - mailer: M, -} - -/// Tells the administrator a backup finished. -async fn report_backup(app: &(impl Mailer + Env + Clock)) -> std::io::Result<()> { - let to = app.var("ADMIN").unwrap_or_else(|| "root@localhost".into()); - app.send(&to, &format!("backup finished at {}", app.now_ms())).await + console: TestConsole, } -#[tokio::main(flavor = "current_thread")] -async fn main() -> std::io::Result<()> { - let mailer = TestMailer::default(); - let app = App { platform: TestPlatform::new().with_var("ADMIN", "ops@example.com"), mailer: mailer.clone() }; - report_backup(&app).await?; - assert_eq!(*mailer.sent.lock().unwrap(), [("ops@example.com".into(), "backup finished at 0".into())]); - Ok(()) -} +let platform = SnapshotPlatform { + clock: FrozenClock(loko::clock::from_ms(1_700_000_000_000).unwrap()), + random: SeededRandom::new(1), + env: MapEnv::new(), + console: TestConsole::new(), +}; +assert_eq!(platform.now_ms(), 1_700_000_000_000); ``` -### 10. State in your own services +### 8. Wrappers -Every service method takes `&self`, never `&mut self`. That's what lets one value be several services at once -(`greet(services, services)` in step 3), lets clones share state (a test keeps one `TestClock` and advances the one -the code holds), and lets services be used across `.await`. A service whose state changes keeps it behind a lock or -an atomic, as `TestMailer` above does with its `Mutex`. Three rules keep that safe: - -- **Lock only for the synchronous part.** Don't hold a `std::sync::Mutex` guard or a `RefCell` borrow across an - `.await`. While the task waits, another task can use the same service and try to take the lock: a `Mutex` then - blocks that thread for good (on a single-threaded runtime or in the browser, the whole program stops), and a - `RefCell` panics. Clippy warns about both (`await_holding_lock`, `await_holding_refcell_ref`). If state really must - stay locked while waiting, use an async mutex such as `tokio::sync::Mutex`. -- **Counters are atomics,** which need no lock. -- **Clones share state.** Cloning a service that holds an `Arc` gives a second handle to the same state, not a copy. - -```rust -use std::sync::Mutex; -use std::time::Duration; - -use loko::{Clock, TestPlatform}; - -/// Remembers when each job ran. -struct Jobs { - runs: Mutex>, -} - -impl Jobs { - async fn run(&self, clock: &impl Clock) { - // Wait first, holding nothing; then lock, change, and unlock in one statement. - clock.sleep(Duration::from_secs(1)).await; - self.runs.lock().unwrap().push(clock.now_ms()); - // Not this: `let mut runs = self.runs.lock().unwrap(); clock.sleep(…).await; runs.push(…);` - } -} - -#[tokio::main(flavor = "current_thread")] -async fn main() { - let jobs = Jobs { runs: Mutex::new(vec![]) }; - let platform = TestPlatform::new(); - jobs.run(&platform).await; - jobs.run(&platform).await; - assert_eq!(*jobs.runs.lock().unwrap(), [1_000, 2_000]); -} -``` - -A lock taken and released at once costs tens of nanoseconds, which matters only in hot loops. That's why `Random` -hands out generators instead of numbers: `services.rng()` locks at most once, and drawing from the generator it -returns takes no lock at all, however many numbers you draw. The system implementations (`StdClock`, `OsRandom`, -`SystemEnv`, `StdConsole`) hold no state and take no lock. - -### 11. Wrappers - -A wrapper holds one implementation of a service and is one itself, changing some methods. `#[loko::wraps(field: -Type)]` on its `impl` forwards every method it doesn't write to the field. - -```rust -use loko::prelude::*; - -/// Shouts standard output; standard error passes as it is. -struct Shouting { - inner: C, -} - -#[loko::wraps(inner: C)] -impl Console for Shouting { - fn print_line(&self, line: &str) -> std::io::Result<()> { - self.inner.print_line(&line.to_uppercase()) - } -} - -let platform = TestPlatform::new(); -let console = Shouting { inner: platform.console.clone() }; -console.print_line("done").unwrap(); -console.print_error_line("as it is").unwrap(); -assert_eq!(platform.console.lines(), ["DONE"]); -assert_eq!(platform.console.error_lines(), ["as it is"]); -``` - -Step 7's fast clock works better as a wrapper: it speeds up whatever clock it holds, so on tokio or in the browser its -sleeps stay asynchronous instead of blocking the thread. +A wrapper holds one implementation and is one itself, changing some methods. `#[loko::wraps(field: Type)]` forwards +every method it doesn't write to the field. This one speeds up any clock, test or real: ```rust use std::future::Future; @@ -461,20 +284,13 @@ use std::time::{Duration, SystemTime}; use loko::clock::{TestClock, to_ms}; use loko::prelude::*; -/// Any clock, running `speed` times faster from the moment the wrapper is made. +/// Any clock, running `speed` times faster from when the wrapper is made. struct Faster { inner: C, started: SystemTime, speed: u32, } -impl Faster { - fn new(inner: C, speed: u32) -> Self { - let started = inner.now(); - Self { inner, started, speed } - } -} - #[loko::wraps(inner: C)] impl Clock for Faster { fn now(&self) -> SystemTime { @@ -492,53 +308,44 @@ impl Clock for Faster { } } -#[tokio::main(flavor = "current_thread")] -async fn main() { - // Over a test clock here; over TokioClock, StdClock, or JsClock in a program. - let real = TestClock::at_ms(0); - let fast = Faster::new(real.clone(), 60); - real.advance(Duration::from_secs(1)); - assert_eq!(fast.now_ms(), 60_000, "a second of the inner clock is a minute"); - fast.sleep(Duration::from_secs(60)).await; - assert_eq!((real.now_ms(), fast.now_ms()), (2_000, 120_000), "a minute's sleep is a second of the inner clock"); -} +let real = TestClock::at_ms(0); +let fast = Faster { inner: real.clone(), started: real.now(), speed: 60 }; +real.advance(Duration::from_secs(1)); +assert_eq!(fast.now_ms(), 60_000, "a second of the inner clock is a minute"); ``` -Wrappers stack, and loko has some ready-made: `OffsetClock` (a clock ahead or behind another, for clock-skew tests), +loko has some ready-made, and they stack: `OffsetClock` (a clock ahead or behind another, for clock-skew tests), `CountingStore`, `ReadOnlyStore`, and `PrefixedStore`. ```rust -use std::time::Duration; - -use loko::Clock; -use loko::clock::{OffsetClock, TestClock}; use loko::store::{CountingStore, MemoryStore, PrefixedStore, ReadOnlyStore, Store}; #[tokio::main(flavor = "current_thread")] async fn main() { - // A store that counts its calls, never writes, and stays under one prefix. - let memory = MemoryStore::new(); - memory.put("memory://tenant-a/report", b"x".to_vec()).await.unwrap(); - let store = CountingStore::new(ReadOnlyStore::new(PrefixedStore::new(memory, "memory://tenant-a/"))); - assert!(store.get("memory://tenant-a/report").await.unwrap().is_some()); + // Counts its calls, never writes, and stays inside one tenant's data. + let store = CountingStore::new(ReadOnlyStore::new(PrefixedStore::new(MemoryStore::new(), "memory://tenant-a/"))); assert!(store.get("memory://tenant-b/report").await.is_err(), "outside the prefix"); - assert!(store.put("memory://tenant-a/new", vec![]).await.is_err(), "read-only"); - assert_eq!((store.counts().get, store.counts().put), (2, 1)); - - // A second machine whose clock runs five seconds behind. - let clock = TestClock::at_ms(10_000); - let late = OffsetClock::behind(clock.clone(), Duration::from_secs(5)); - assert_eq!(late.now_ms(), 5_000); + assert!(store.put("memory://tenant-a/report", vec![]).await.is_err(), "read-only"); + assert_eq!(store.counts().total(), 2); } ``` -A wrapper keeps the inner service's associated types, and a provided method it doesn't write (such as `now_ms`) -forwards to the inner one too, so write it when it depends on a method the wrapper changes. +### 9. Services with state + +Every service method takes `&self`, so one value can be several services (`greet(&platform)` above), clones share +state (a test advances the clock it handed to the code), and services can be used across `.await`. A service whose +state changes keeps it behind a lock or an atomic, as `TestMailer` does. Three rules: -### 12. In the browser +- **Lock only for the synchronous part.** Never hold a `std::sync::Mutex` guard or a `RefCell` borrow across `.await`: + another task using the service meanwhile would deadlock or panic. Clippy warns about both (`await_holding_lock`, + `await_holding_refcell_ref`). If state must stay locked while waiting, use an async mutex (`tokio::sync::Mutex`). +- **Counters are atomics,** which need no lock. +- **Clones share state.** Cloning a service that holds an `Arc` gives a second handle to the same state. + +### 10. In the browser With feature `js` on `wasm32-unknown-unknown`, `JsPlatform` uses `Date.now`, `setTimeout`, `crypto.getRandomValues`, -and the JavaScript console. The `retry` from step 2 runs unchanged: +and the JavaScript console. The `retry` from step 1 runs unchanged: ```rust,ignore use wasm_bindgen::prelude::*; @@ -550,66 +357,19 @@ pub async fn fetch_with_retries() -> Result { } ``` -Futures aren't required to be `Send`, so a service can wrap JavaScript promises, as `JsClock` does. - ## Sans-I/O -Libraries that do no I/O of their own ("sans-I/O") come in two shapes, and loko fits both: - -- **State machines driven by the caller.** Network protocols such as quinn-proto (QUIC), str0m (WebRTC), and smoltcp - (TCP/IP) take the time as an argument, return what to send and when to wake them next, and never sleep. The state - machine needs no services; the program around it uses a `Clock` to wait for the deadline, and a test uses a - `TestClock` to run through hours of timers at once. -- **Libraries that call traits the application implements.** delta-kernel-rs's `Engine` and rustls's time and - randomness providers work this way: the library runs a chain of steps that depend on each other and calls back for - each read, write, or timestamp. That's the shape loko's services serve: the library takes - `&(impl Store + Clock + Random)`, and the application chooses the implementations. - -Choose the first for a protocol that reacts to packets and timers, the second for a sequence of reads and writes where -each depends on the last. The first, with loko at the edge: - -```rust -use std::time::{Duration, SystemTime}; - -use loko::{Clock, TestPlatform}; +Libraries that do no I/O of their own come in two shapes, and loko fits both: -/// Sends a heartbeat every 30 seconds. It knows nothing of clocks or sockets: the time comes in as an -/// argument, and the next deadline goes out. -struct Heartbeat { - next: SystemTime, -} +- **State machines driven by the caller** (quinn-proto, str0m, smoltcp): the time comes in as an argument, the next + deadline goes out, and the library never sleeps. The program around it waits with a `Clock`, and its tests run + through hours of timers at once with a `TestClock`. +- **Libraries that call traits the application implements** (delta-kernel-rs's `Engine`, rustls's time and randomness + providers): the library runs a chain of dependent steps and calls back for each read, write, or timestamp. That's the + shape loko's services serve: the library takes `&(impl Store + Clock + Random)`, and the application chooses. -impl Heartbeat { - fn deadline(&self) -> SystemTime { - self.next - } - - /// Whether to send a heartbeat at `now`. - fn on_time(&mut self, now: SystemTime) -> bool { - if now < self.next { - return false; - } - self.next = now + Duration::from_secs(30); - true - } -} - -#[tokio::main(flavor = "current_thread")] -async fn main() { - // The edge owns the waiting: TokioPlatform in a program, TestPlatform here. - let clock = TestPlatform::new(); - let mut heartbeat = Heartbeat { next: clock.now() }; - let mut sent = 0; - for _ in 0..3 { - let wait = heartbeat.deadline().duration_since(clock.now()).unwrap_or_default(); - clock.sleep(wait).await; - if heartbeat.on_time(clock.now()) { - sent += 1; - } - } - assert_eq!((sent, clock.now_ms()), (3, 60_000), "three heartbeats in a minute of test time"); -} -``` +Choose the first for a protocol reacting to packets and timers, the second for reads and writes that depend on each +other. ## Related crates diff --git a/crates/loko/README.md b/crates/loko/README.md index 19a1e9b..f3b4dda 100644 --- a/crates/loko/README.md +++ b/crates/loko/README.md @@ -5,17 +5,20 @@ to code as traits instead of reached for as globals, with test, system, and Java that bundle one of each. ```rust -use std::time::Duration; +use std::time::{Duration, SystemTime}; -use loko::{Clock, Random, Rng, TestPlatform}; +use loko::Clock; +use loko::clock::TestClock; -fn id(services: &(impl Clock + Random)) -> String { - format!("{}-{:04x}", services.now_ms(), services.rng().next_u64() & 0xffff) +/// Whether a session that started at `started` has expired. +fn expired(clock: &impl Clock, started: SystemTime) -> bool { + clock.now() > started + Duration::from_secs(60 * 60) } -let platform = TestPlatform::default(); // TokioPlatform, StdPlatform, or JsPlatform in a program -platform.clock.advance(Duration::from_secs(1)); -assert_eq!(id(&platform), "1000-7e6f"); +let clock = TestClock::at_ms(0); // StdClock, TokioClock, or JsClock in a program +let started = clock.now(); +clock.advance(Duration::from_secs(60 * 60 + 1)); // an hour passes, at once +assert!(expired(&clock, started)); ``` | Feature | Adds | From a267c84f7c73eda12a8b19cb40b76cf565102a4d Mon Sep 17 00:00:00 2001 From: Oleksandr Litus Date: Wed, 21 Oct 2026 18:05:17 -0400 Subject: [PATCH 19/19] Review fixes: WebAssembly has no times before the epoch, JavaScript implementations named, Send scoped, tokio in the README From CodeRabbit on loko#5: - from_ms and TestClock::at_ms name both WebAssembly targets (WASI and wasm32-unknown-unknown) as having no times before the epoch. - COMPARISON says which services have JavaScript implementations (Clock, Random, Console; Env is a map and Store the application's binding there). - COMPARISON names the crates that require Send + Sync instead of all above. - The README's Cargo.toml snippet adds tokio, which the examples use. Co-Authored-By: Claude Opus 5.5 --- COMPARISON.md | 15 +++++++++------ README.md | 1 + crates/loko-clock/src/lib.rs | 3 ++- crates/loko-clock/src/testing.rs | 5 +++-- spec/0002-clock.md | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/COMPARISON.md b/COMPARISON.md index 98d7f0f..22111d8 100644 --- a/COMPARISON.md +++ b/COMPARISON.md @@ -2,8 +2,9 @@ loko does two things: -1. **It ships services.** `Clock`, `Random`, `Env`, `Console`, and `Store` come with an implementation for tests, one - for the system, and one for JavaScript hosts, plus platforms that bundle them and wrappers that change them. +1. **It ships services.** `Clock`, `Random`, `Env`, `Console`, and `Store`, each with an implementation for tests and + one for the system, and JavaScript ones for `Clock`, `Random`, and `Console` (in JavaScript, `Env` is a map and + `Store` the application's own binding), plus platforms that bundle them and wrappers that change them. 2. **It has a small macro layer.** `#[service]`, `#[loko::bundle]`, and `#[wraps]` let your own traits work the same way. @@ -136,12 +137,14 @@ it was given, not by path, so it can't reach outside it. It's a foundation for W ## What loko does that the others don't - **Ships the services.** A test clock whose `sleep` moves time instead of waiting, seeded randomness, an environment - in a map, a console that records lines, and an in-memory store with conditional writes. Each has system and - JavaScript counterparts and the same interface. The other crates wire up implementations; none ships these. + in a map, a console that records lines, and an in-memory store with conditional writes. Each has a system + counterpart with the same interface, and the clock, randomness, and console have JavaScript ones. The other crates + wire up implementations; none ships these. - **Async without boxing.** Services return `impl Future`, and loko passes them as generics, never as `dyn`, so there's no `async-trait` and no heap allocation per call. -- **No `Send`, `Sync`, or `'static` requirement.** Futures that wrap JavaScript promises aren't `Send`. The containers - above require `Send + Sync`; shaku requires it unless `thread_safe` is turned off. +- **No `Send`, `Sync`, or `'static` requirement.** Futures that wrap JavaScript promises aren't `Send`. dill, + dependency-injector, ferrous-di, and reinhardt-di require `Send + Sync`; shaku requires it unless `thread_safe` is + turned off. - **Checked on WebAssembly.** CI builds every crate for `wasm32-unknown-unknown` and `wasm32-wasip2`, and the JavaScript implementations use the host's clock, timers, `crypto.getRandomValues`, and console. - **Wrappers that write only what they change,** with `#[wraps]`, and ready-made ones that stack. diff --git a/README.md b/README.md index e8a0ac6..2ad082c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The program passes `StdClock`, `TokioClock`, or `JsClock` in the browser, and `e ```toml [dependencies] loko = { version = "0.2", features = ["tokio", "store"] } +tokio = { version = "1", features = ["macros", "rt"] } # for the examples' #[tokio::main] ``` | Feature | Adds | diff --git a/crates/loko-clock/src/lib.rs b/crates/loko-clock/src/lib.rs index a7b6d85..098990b 100644 --- a/crates/loko-clock/src/lib.rs +++ b/crates/loko-clock/src/lib.rs @@ -113,7 +113,8 @@ pub fn to_ms(time: SystemTime) -> i64 { } /// The time `ms` milliseconds after the Unix epoch (before it, if negative), or `None` where the -/// platform can't hold it (WASI has no times before the epoch). +/// platform can't hold it: WebAssembly (WASI and `wasm32-unknown-unknown`) has no times before the +/// epoch. pub fn from_ms(ms: i64) -> Option { let magnitude = Duration::from_millis(ms.unsigned_abs()); if ms >= 0 { UNIX_EPOCH.checked_add(magnitude) } else { UNIX_EPOCH.checked_sub(magnitude) } diff --git a/crates/loko-clock/src/testing.rs b/crates/loko-clock/src/testing.rs index 9fe65f9..6b28fec 100644 --- a/crates/loko-clock/src/testing.rs +++ b/crates/loko-clock/src/testing.rs @@ -21,7 +21,8 @@ impl TestClock { /// /// # Panics /// - /// If the platform can't hold that time ([`from_ms`]): before the epoch on WASI. + /// If the platform can't hold that time ([`from_ms`]): before the epoch on WebAssembly (WASI and + /// `wasm32-unknown-unknown`). pub fn at_ms(ms: i64) -> Self { Self::new(from_ms(ms).expect("a time this platform can hold")) } @@ -65,7 +66,7 @@ mod tests { let other = clock.clone(); other.advance(Duration::from_millis(250)); assert_eq!(clock.now_ms(), 5_250, "clones share the time"); - // Times before the epoch exist natively, not on WASI. + // Times before the epoch exist natively, not on WebAssembly. if let Some(before) = from_ms(-1_500) { clock.set(before); assert_eq!(clock.now_ms(), -1_500); diff --git a/spec/0002-clock.md b/spec/0002-clock.md index c0f7ba2..ae5b106 100644 --- a/spec/0002-clock.md +++ b/spec/0002-clock.md @@ -22,7 +22,7 @@ and skip the waits. - **`Clock`:** `now() -> SystemTime`, `now_ms() -> i64` (provided: milliseconds since the Unix epoch, negative before it, saturating), and `sleep(Duration) -> impl Future`. - **`from_ms(i64) -> Option`:** the inverse of `now_ms`, `None` where the platform can't hold the time - (before the epoch on WASI). + (before the epoch on WebAssembly: WASI and `wasm32-unknown-unknown`). - **`TestClock`:** starts where told (`new`, `at_ms`; the Unix epoch by default), moves on `advance` and `set`, and on `sleep`, which moves it forward by the sleep and returns at once. Clones share the time. - **`StdClock`:** `SystemTime::now`, and a `sleep` that blocks the thread before returning a ready future: for