core: JS reads what Rust writes, and gate both directions
One direction is not compatibility -- a Rust implementation that reads JS updates but writes something JS cannot read would have passed the previous task completely. verify-rust.mjs checks toDelta() as well as toString(), not just plain text: emit_update.rs applies a bold Text::format() over part of the inserted run, so the emitted update carries real formatting bytes, and a Rust side that dropped formatting fails the delta assertion (plain text alone can't tell the difference, the same gap Task 2 had to close). Verified by temporarily removing the format() call: the text assertion still passed but the delta assertion failed as expected, then reverted. wire-gate.sh also fails if regenerating fixtures changes them, which makes 'never edit a fixture to make a test pass' mechanical. build-wasm.sh now builds --lib only: emit_update is native-only tooling for the host side of wire-gate.sh, and building it for wasm32-unknown-unknown fails to link (it never defines __getrandom_v03_custom itself) since it was never meant to run there; the shipped wasm artifact is the library crate regardless.
4 files changed,
+100
−1
core/src/bin/emit_update.rs+29 −0fixtures/verify-rust.mjs+42 −0scripts/build-wasm.sh+6 −1scripts/wire-gate.sh+23 −0
diff --git a/core/src/bin/emit_update.rs b/core/src/bin/emit_update.rs| new file mode 100644 |
| index 0000000..f1c1f51 |
| --- /dev/null |
| +++ b/core/src/bin/emit_update.rs |
| @@ -0,0 +1,29 @@ |
| +//! Builds a document in Rust and writes a v1 update to stdout, so the JS |
| +//! side can verify it reads what Rust writes. This is the direction Task 3 |
| +//! cannot test: a Rust implementation that reads JS updates but writes |
| +//! something JS cannot read would have passed Task 3 completely. |
| +use std::io::Write; |
| +use yrs::types::Attrs; |
| +use yrs::{Doc, Options, ReadTxn, Text, Transact}; |
| + |
| +fn main() { |
| + // Both values fixed: never drawn from yrs (Global Constraints -- see |
| + // core/src/lib.rs and clippy.toml, which bans the alternatives that draw |
| + // entropy). |
| + let guid: yrs::Uuid = "emit-update-guid".into(); |
| + let doc = Doc::with_options(Options::with_guid_and_client_id(guid, 7001)); |
| + { |
| + let text = doc.get_or_insert_text("content"); |
| + let mut txn = doc.transact_mut(); |
| + text.insert(&mut txn, 0, "from rust 🦀 café"); |
| + // Format part of the inserted text so the emitted update carries |
| + // real formatting bytes, not just plain-text inserts. A plain-text |
| + // check alone cannot distinguish a correct implementation from one |
| + // that dropped formatting (see fixtures/verify-rust.mjs). |
| + let attrs: Attrs = Attrs::from([("bold".into(), true.into())]); |
| + text.format(&mut txn, 5, 4, attrs); |
| + } |
| + let txn = doc.transact(); |
| + let update = txn.encode_state_as_update_v1(&yrs::StateVector::default()); |
| + std::io::stdout().write_all(&update).unwrap(); |
| +} |
diff --git a/fixtures/verify-rust.mjs b/fixtures/verify-rust.mjs| new file mode 100644 |
| index 0000000..4a032c0 |
| --- /dev/null |
| +++ b/fixtures/verify-rust.mjs |
| @@ -0,0 +1,42 @@ |
| +// Reads a Rust-produced v1 update on stdin and asserts JS yjs decodes it to |
| +// the expected text AND the expected formatting. This is the direction |
| +// Task 3 cannot test. Plain text alone cannot distinguish a correct |
| +// implementation from one that dropped formatting, so this also checks |
| +// toDelta(), the way fixtures/generate.mjs's expected.json does. |
| +import * as Y from 'yjs' |
| +import assert from 'node:assert/strict' |
| + |
| +const EXPECTED_TEXT = 'from rust 🦀 café' |
| +const EXPECTED_DELTA = [ |
| + { insert: 'from ' }, |
| + { insert: 'rust', attributes: { bold: true } }, |
| + { insert: ' 🦀 café' } |
| +] |
| + |
| +const chunks = [] |
| +for await (const chunk of process.stdin) chunks.push(chunk) |
| +const update = new Uint8Array(Buffer.concat(chunks)) |
| + |
| +if (update.length === 0) { |
| + console.error('verify-rust: empty update on stdin') |
| + process.exit(1) |
| +} |
| + |
| +const doc = new Y.Doc() |
| +Y.applyUpdate(doc, update) |
| +const text = doc.getText('content') |
| +const actualText = text.toString() |
| +const actualDelta = text.toDelta() |
| + |
| +try { |
| + assert.strictEqual(actualText, EXPECTED_TEXT) |
| + assert.deepStrictEqual(actualDelta, EXPECTED_DELTA) |
| +} catch (err) { |
| + console.error('verify-rust: mismatch') |
| + console.error(` text: expected ${JSON.stringify(EXPECTED_TEXT)}, got ${JSON.stringify(actualText)}`) |
| + console.error(` delta: expected ${JSON.stringify(EXPECTED_DELTA)}, got ${JSON.stringify(actualDelta)}`) |
| + console.error(err.message) |
| + process.exit(1) |
| +} |
| + |
| +console.log(`verify-rust: ok (${update.length} bytes)`) |
diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh| index d2cf2e2..12dd863 100755 |
| --- a/scripts/build-wasm.sh |
| +++ b/scripts/build-wasm.sh |
| @@ -9,7 +9,12 @@ cd "$(dirname "$0")/.." |
| # for. A doc comment alone has been wrong, or gotten around, more than |
| # once; this is the actual enforcement. |
| cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods |
| -cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown --locked |
| +# --lib only: the wasm module shipped to wazero is the library crate. Native |
| +# tooling under src/bin (e.g. emit_update, used from the host side of |
| +# wire-gate.sh) is never part of that artifact and does not define the |
| +# __getrandom_v03_custom symbol itself, so building it for wasm32 fails to |
| +# link -- it was never meant to run there. |
| +cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown --locked --lib |
| out="core/target/wasm32-unknown-unknown/release/docs_core.wasm" |
| [ -f "$out" ] || { echo "build-wasm: no artifact at $out" >&2; exit 1; } |
| echo "$out ($(wc -c < "$out") bytes)" |
diff --git a/scripts/wire-gate.sh b/scripts/wire-gate.sh| new file mode 100755 |
| index 0000000..9ce81af |
| --- /dev/null |
| +++ b/scripts/wire-gate.sh |
| @@ -0,0 +1,23 @@ |
| +#!/usr/bin/env bash |
| +# The wire-compatibility gate, both directions. This is the CRDT half of the |
| +# spec's §0: two implementations, fixtures as the contract. |
| +set -euo pipefail |
| +cd "$(dirname "$0")/.." |
| + |
| +echo "--- regenerating fixtures from JS yjs" |
| +(cd fixtures && node generate.mjs) |
| + |
| +echo "--- fixtures must be unchanged (they are the contract, not an output)" |
| +if ! git diff --quiet -- fixtures/data; then |
| + echo "wire-gate: fixtures changed. If deliberate, commit them WITH the rule change." >&2 |
| + git diff --stat -- fixtures/data >&2 |
| + exit 1 |
| +fi |
| + |
| +echo "--- Rust reads what JS wrote" |
| +(cd core && cargo test --test wire_compat) |
| + |
| +echo "--- JS reads what Rust wrote" |
| +(cd core && cargo run --quiet --bin emit_update) | node fixtures/verify-rust.mjs |
| + |
| +echo "wire-gate: ok" |