From 398136d0885a48d3dfe786220230e11c0ce21f60 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sun, 11 Oct 2026 22:40:00 +0200 Subject: [PATCH 1/3] feat(bindings): prepare Gateway bindings outside the source tree --- README.md | 24 ++- docs/commands.md | 22 ++- src/bin/main.rs | 14 ++ src/cmd/bindings.rs | 360 +++++++++++++++++++++++++++++++++++++++++++ src/cmd/dev.rs | 1 + src/cmd/mod.rs | 8 + src/cmd/preflight.rs | 15 +- src/cmd/publish.rs | 69 ++++----- src/proc/fake.rs | 18 ++- 9 files changed, 488 insertions(+), 43 deletions(-) create mode 100644 src/cmd/bindings.rs diff --git a/README.md b/README.md index c90a0c3..e603724 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ repeat. Commands run against the selected Core checkout. ```text lyracore help [--all] lyracore doctor +lyracore bindings [--check] lyracore preflight lyracore publish [DATABASE ...] [--skip-preflight] lyracore dev up [--single] [--lan ] @@ -250,6 +251,25 @@ processes the CLI started, `logs/` holds their output, and `config.json` holds t When the CLI mints a local coordinator identity, `coordinator-token` stores its credential with mode `0600`. +## `bindings` + +Run `./lyracore bindings` before a Gateway Cargo build or editor check on a fresh Core checkout. +It generates private Rust bindings from the local Module with `debug_reducers`, using the pinned +Rust and SpacetimeDB tools. Schema generation and CLI publishing take Rust flags from the +checkout's Cargo configuration and ignore ambient Rust-flag overrides. It does not contact a Realm. `dev up` prepares them before its Gateway +build, and the publish upgrade check reads the prepared schema. + +Files live under `.lyracore/generated/gateway/`, outside the source tree. Repeated preparation +reuses output while source and generated-file hashes match. Module and workspace sources, installed +Package sources, the Runtime Script event catalogue, Cargo configuration and lockfile, and tool +pins determine freshness. A failed generation leaves the previous complete output in place. + +`./lyracore bindings --check` checks existing output without running Cargo or SpacetimeDB and +prints JSON with `directory` and `watches`. Core's Gateway build script uses that contract through +the installed pinned CLI. Missing or stale bindings stop the build with preparation instructions. +Deleting `.lyracore/generated/gateway/` clears the generated cache, including old directories kept +for compilers that were already running. + ## `preflight` — the offline deploy gate ```bash @@ -313,8 +333,8 @@ Explicit database **NAMES** are published exactly as given: * `-c` / `--delete-data` — the destructive wipe — is **refused**, as is any other flag-shaped argument, with exit 2 and before a single `spacetime` process starts. Nothing is forwarded. -`--skip-preflight` is the only recognised flag, and it says on stdout that nothing validated the -schema. Publishing several databases in one command is what makes "every shard" checkable instead of +`--skip-preflight` skips the preflight checks. Binding preparation and Loot Roll upgrade checks +still run. Publishing several databases in one command is what makes "every shard" checkable instead of remembered: a schema change needs all of them, and the gateway reports a shard left behind only as "realm-core unreachable — LOGONS WILL BE REFUSED". diff --git a/docs/commands.md b/docs/commands.md index dbc7057..6c3e873 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -47,6 +47,7 @@ was private, which meant a fresh clone's very first command failed for anyone bu ```text lyracore doctor +lyracore bindings [--check] lyracore preflight lyracore publish [DATABASE ...] [--skip-preflight] lyracore dev up [--lan ] @@ -850,6 +851,25 @@ directory. Neither repo carries a zip library, so a machine without the binary g the reason, and keeps the directory that was already built. A `.zip` this command did not write is refused rather than overwritten. +## `bindings` + +Run `./lyracore bindings` before a Gateway Cargo build or editor check on a fresh Core checkout. +It generates private Rust bindings from the local Module with `debug_reducers`, using the pinned +Rust and SpacetimeDB tools. Schema generation and CLI publishing take Rust flags from the +checkout's Cargo configuration and ignore ambient Rust-flag overrides. It does not contact a Realm. `dev up` prepares them before its Gateway +build, and the publish upgrade check reads the prepared schema. + +Files live under `.lyracore/generated/gateway/`, outside the source tree. Repeated preparation +reuses output while source and generated-file hashes match. Module and workspace sources, installed +Package sources, the Runtime Script event catalogue, Cargo configuration and lockfile, and tool +pins determine freshness. A failed generation leaves the previous complete output in place. + +`./lyracore bindings --check` checks existing output without running Cargo or SpacetimeDB and +prints JSON with `directory` and `watches`. Core's Gateway build script uses that contract through +the installed pinned CLI. Missing or stale bindings stop the build with preparation instructions. +Deleting `.lyracore/generated/gateway/` clears the generated cache, including old directories kept +for compilers that were already running. + ## `preflight` — the offline deploy gate ```bash @@ -895,7 +915,7 @@ Its arguments are database **NAMES**. **Every flag-shaped argument is refused** any process starts — `-c`, `--delete-data`, `--clear-database` and `--clear` with a message naming it as the destructive wipe, anything else with the general refusal. Nothing is forwarded to `spacetime publish` that this CLI did not put there itself. The only recognised option is -`--skip-preflight`, which announces on stdout that nothing validated the schema. +`--skip-preflight`. Binding preparation and Loot Roll upgrade checks still run. This is the same contract the maintainers' internal deploy script carries — each of those cases is a unit test in the CLI too. [`docs/danger-zones.md`](https://github.com/LyraCoreProject/LyraCore/blob/main/docs/danger-zones.md) §1 remains authoritative. diff --git a/src/bin/main.rs b/src/bin/main.rs index 8e84bde..3788dfc 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -49,6 +49,20 @@ fn run(args: &[String]) -> Result { let blocking = cmd::doctor::report(&checks); Ok(if blocking { EXIT_FAILURE } else { EXIT_OK }) } + Command::Bindings { check } => { + let project = ProjectLayout::discover()?; + let prepared = if check { + cmd::bindings::check(&project)? + } else { + cmd::bindings::ensure(&project, &runner)? + }; + println!( + "{}", + serde_json::to_string(&prepared) + .map_err(|e| lyracore_cli::Error::Process(e.to_string()))? + ); + Ok(EXIT_OK) + } Command::Preflight => { cmd::preflight::run(&ProjectLayout::discover()?, &runner).map(|_| EXIT_OK) } diff --git a/src/cmd/bindings.rs b/src/cmd/bindings.rs new file mode 100644 index 0000000..8b08a67 --- /dev/null +++ b/src/cmd/bindings.rs @@ -0,0 +1,360 @@ +//! Prepare the Gateway's private Rust bindings without contacting a Realm. + +use super::preflight::{pinned_rust, pinned_spacetimedb, schema_command}; +use crate::cmd::doctor::parse_version; +use crate::proc::{CommandSpec, ProcessRunner}; +use crate::project::ProjectLayout; +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const CACHE: &str = ".lyracore/generated/gateway"; +const RECEIPT: &str = "current.json"; + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +struct Inputs { + files: BTreeMap, + watches: Vec, +} + +#[derive(Serialize, Deserialize)] +struct Receipt { + inputs: Inputs, + directory: PathBuf, + output: BTreeMap, +} + +/// The Cargo build script reads this JSON after a successful `bindings --check`. +#[derive(Debug, Serialize)] +pub struct Prepared { + pub directory: PathBuf, + pub watches: Vec, +} + +fn stale(reason: impl std::fmt::Display) -> Error { + Error::Process(format!( + "Gateway bindings need preparation ({reason}). Run `./lyracore bindings`." + )) +} + +fn collect( + path: &Path, + files: &mut BTreeMap, + ancestors: &mut Vec, +) -> Result<()> { + let resolved = fs::canonicalize(path)?; + if path.is_dir() { + if ancestors.contains(&resolved) { + return Err(stale(format!( + "source directory cycle at {}", + path.display() + ))); + } + ancestors.push(resolved); + for entry in fs::read_dir(path)? { + let entry = entry?; + if matches!( + entry.file_name().to_str(), + Some("target" | ".git" | "node_modules") + ) { + continue; + } + collect(&entry.path(), files, ancestors)?; + } + ancestors.pop(); + } else { + files.insert( + path.to_path_buf(), + blake3::hash(&fs::read(path)?).to_hex().to_string(), + ); + } + Ok(()) +} + +fn inputs(project: &ProjectLayout) -> Result { + let mut watches: Vec = [ + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + ".lyracore-cli-rev", + ".cargo", + "module", + "crates", + "packages", + "datascripts/runtime-scripts/events.json", + ] + .iter() + .map(|path| project.root.join(path)) + .collect(); + let mut files = BTreeMap::new(); + // Package content outside src/ does not enter the Module wasm. Watch the directory itself + // for installations and removals, and each source tree for edits through Package symlinks. + for path in watches.clone() { + if path.ends_with("packages") { + if path.is_dir() { + for entry in fs::read_dir(&path)? { + let package = entry?.path(); + if !package.is_dir() { + continue; + } + let source = package.join("src"); + watches.push(source.clone()); + if source.exists() { + collect(&source, &mut files, &mut Vec::new())?; + } + } + } + } else if path.exists() { + collect(&path, &mut files, &mut Vec::new())?; + } + } + watches.sort(); + Ok(Inputs { files, watches }) +} + +fn read_receipt(project: &ProjectLayout) -> Result { + let bytes = fs::read(project.root.join(CACHE).join(RECEIPT)).map_err(stale)?; + serde_json::from_slice(&bytes).map_err(stale) +} + +/// Read-only. It needs neither Cargo nor the SpacetimeDB executable. +pub fn check(project: &ProjectLayout) -> Result { + let receipt = read_receipt(project)?; + if receipt.inputs != inputs(project)? { + return Err(stale("Module inputs changed")); + } + let mut output = BTreeMap::new(); + collect(&receipt.directory, &mut output, &mut Vec::new()).map_err(stale)?; + if output != receipt.output || !receipt.directory.join("mod.rs").is_file() { + return Err(stale("generated files changed or are missing")); + } + let mut watches = receipt.inputs.watches; + watches.extend(receipt.inputs.files.into_keys()); + watches.push(project.root.join(CACHE).join(RECEIPT)); + watches.push(receipt.directory.clone()); + Ok(Prepared { + directory: receipt.directory, + watches, + }) +} + +fn versions(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { + let rust = pinned_rust(&fs::read_to_string(project.rust_toolchain_file())?) + .ok_or_else(|| stale("cannot read the Rust pin"))?; + let spacetime = pinned_spacetimedb(&fs::read_to_string(project.module_manifest())?) + .ok_or_else(|| stale("cannot read the SpacetimeDB pin"))?; + for (program, wanted) in [("rustc", rust), ("spacetime", spacetime)] { + let banner = runner.run_capturing_stderr( + &CommandSpec::new(program) + .arg("--version") + .cwd(&project.root), + )?; + if parse_version(&banner) != Some(wanted) { + return Err(stale(format!("{program} must be {wanted}"))); + } + } + Ok(()) +} + +/// Reuse current output or generate a complete replacement. Existing readers keep their immutable +/// directory; a failed generation never replaces the receipt. No command here publishes a Module. +pub fn ensure(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result { + let cache = project.root.join(CACHE); + fs::create_dir_all(&cache)?; + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(cache.join("lock"))?; + rustix::fs::flock(&lock, rustix::fs::FlockOperation::LockExclusive) + .map_err(std::io::Error::from)?; + if let Ok(prepared) = check(project) { + return Ok(prepared); + } + versions(project, runner)?; + let before = inputs(project)?; + let staging = tempfile::Builder::new() + .prefix("bindings-") + .tempdir_in(&cache)?; + eprintln!("lyracore: generating Gateway bindings..."); + runner.run_and_wait(&schema_command(project, staging.path()).arg("--include-private"))?; + if !staging.path().join("mod.rs").is_file() { + return Err(stale("generator did not write mod.rs")); + } + if before != inputs(project)? { + return Err(stale("Module inputs changed during generation")); + } + let mut output = BTreeMap::new(); + collect(staging.path(), &mut output, &mut Vec::new())?; + let receipt = Receipt { + inputs: before, + directory: staging.path().to_path_buf(), + output, + }; + let mut current = tempfile::NamedTempFile::new_in(&cache)?; + current.write_all(&serde_json::to_vec(&receipt).map_err(stale)?)?; + // Keep the directory before publishing its path. Old directories remain usable by a compiler + // that started before this generation; removing .lyracore/generated/ clears the cache. + let _directory = staging.keep(); + current.persist(cache.join(RECEIPT)).map_err(stale)?; + check(project) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proc::fake::{FakeStack, FAKE_RUST_VERSION, FAKE_SPACETIME_VERSION}; + + fn fixture(root: &Path) -> ProjectLayout { + fs::create_dir_all(root.join("module/src")).unwrap(); + fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap(); + fs::write( + root.join("rust-toolchain.toml"), + format!("channel = \"{FAKE_RUST_VERSION}\"\n"), + ) + .unwrap(); + fs::write( + root.join("module/Cargo.toml"), + format!("spacetimedb = {{ version = \"={FAKE_SPACETIME_VERSION}\" }}\n"), + ) + .unwrap(); + fs::write(root.join("module/src/lib.rs"), "// Module\n").unwrap(); + ProjectLayout::from_root(root).unwrap() + } + + #[test] + fn a_clean_checkout_gets_private_bindings_and_reuses_them_without_tools() { + let tmp = tempfile::tempdir().unwrap(); + let project = fixture(tmp.path()); + assert!(check(&project) + .unwrap_err() + .to_string() + .contains("./lyracore bindings")); + let stack = FakeStack::new(); + let prepared = ensure(&project, &stack.runner()).unwrap(); + assert!(prepared.directory.join("mod.rs").is_file()); + let calls = stack.rendered(); + let generate = calls + .iter() + .find(|call| call.starts_with("spacetime generate")) + .unwrap(); + assert!(generate.contains("--include-private")); + assert!(generate.contains("--features=debug_reducers")); + assert!(generate.contains("--no-config")); + let generation = schema_command(&project, tmp.path()); + let publish = crate::cmd::publish::publish_command(&project, "fixture").unwrap(); + for variable in [ + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS", + ] { + assert!(generation.removes_env(variable)); + assert!(publish.removes_env(variable)); + } + + let no_tools = FakeStack::new() + .fail_on("spacetime", "unavailable") + .fail_on("rustc", "unavailable"); + assert_eq!( + ensure(&project, &no_tools.runner()).unwrap().directory, + prepared.directory + ); + assert!(no_tools.rendered().is_empty()); + } + + #[test] + fn changed_added_and_removed_inputs_require_preparation() { + let tmp = tempfile::tempdir().unwrap(); + let project = fixture(tmp.path()); + let stack = FakeStack::new(); + for path in [ + "module/src/lib.rs", + "crates/new/src/lib.rs", + "packages/example/src/mod.rs", + ".lyracore-cli-rev", + "Cargo.lock", + "rust-toolchain.toml", + ] { + ensure(&project, &stack.runner()).unwrap(); + let path = tmp.path().join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let previous = fs::read(&path).unwrap_or_default(); + let mut changed = previous.clone(); + changed.extend_from_slice(b"\n// changed\n"); + fs::write(&path, changed).unwrap(); + assert!(check(&project).is_err(), "{}", path.display()); + fs::write(&path, previous).unwrap(); + } + ensure(&project, &stack.runner()).unwrap(); + fs::remove_file(tmp.path().join("packages/example/src/mod.rs")).unwrap(); + assert!(check(&project).is_err()); + } + + #[test] + fn a_failed_replacement_keeps_the_previous_complete_output() { + let tmp = tempfile::tempdir().unwrap(); + let project = fixture(tmp.path()); + let first = ensure(&project, &FakeStack::new().runner()).unwrap(); + let receipt = fs::read(tmp.path().join(CACHE).join(RECEIPT)).unwrap(); + fs::write(tmp.path().join("module/src/new.rs"), "// new table").unwrap(); + let failed = FakeStack::new().fail_on("spacetime generate", "invalid schema"); + assert!(ensure(&project, &failed.runner()).is_err()); + assert_eq!( + fs::read(tmp.path().join(CACHE).join(RECEIPT)).unwrap(), + receipt + ); + assert!(first.directory.join("mod.rs").is_file()); + assert!(check(&project).is_err()); + let replacement = ensure(&project, &FakeStack::new().runner()).unwrap(); + assert_ne!(first.directory, replacement.directory); + assert!(first.directory.join("mod.rs").is_file()); + } + + #[test] + fn modified_or_deleted_output_cannot_pass_the_check() { + let tmp = tempfile::tempdir().unwrap(); + let project = fixture(tmp.path()); + let prepared = ensure(&project, &FakeStack::new().runner()).unwrap(); + fs::write(prepared.directory.join("character_type.rs"), "broken").unwrap(); + assert!(check(&project).is_err()); + let prepared = ensure(&project, &FakeStack::new().runner()).unwrap(); + fs::remove_file(prepared.directory.join("character_type.rs")).unwrap(); + assert!(check(&project).is_err()); + } + + #[cfg(unix)] + #[test] + fn edits_and_retargeting_of_linked_packages_invalidate_bindings() { + let tmp = tempfile::tempdir().unwrap(); + let package = tempfile::tempdir().unwrap(); + let project = fixture(tmp.path()); + fs::create_dir_all(tmp.path().join("packages")).unwrap(); + fs::create_dir_all(package.path().join("src")).unwrap(); + fs::write(package.path().join("src/mod.rs"), "// package").unwrap(); + let link = tmp.path().join("packages/example"); + std::os::unix::fs::symlink(package.path(), &link).unwrap(); + ensure(&project, &FakeStack::new().runner()).unwrap(); + fs::write(package.path().join("src/mod.rs"), "// changed package").unwrap(); + assert!(check(&project).is_err()); + ensure(&project, &FakeStack::new().runner()).unwrap(); + fs::remove_file(link).unwrap(); + assert!(check(&project).is_err()); + } + + #[test] + fn wrong_generator_version_fails_before_generation() { + let tmp = tempfile::tempdir().unwrap(); + let project = fixture(tmp.path()); + let stack = + FakeStack::new().with_stdout("spacetime --version", "spacetimedb tool version 0.1.0"); + assert!(ensure(&project, &stack.runner()).is_err()); + assert!(!stack + .rendered() + .iter() + .any(|call| call.starts_with("spacetime generate"))); + } +} diff --git a/src/cmd/dev.rs b/src/cmd/dev.rs index f922e7e..a504f39 100644 --- a/src/cmd/dev.rs +++ b/src/cmd/dev.rs @@ -317,6 +317,7 @@ impl DevManager { } fn build_gateway(&self, runner: &dyn ProcessRunner) -> Result<()> { + crate::cmd::bindings::ensure(&self.project, runner)?; println!("· building the gateway..."); runner.run_and_wait( &CommandSpec::new("cargo") diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 960a4ce..9824098 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod bindings; pub mod character; pub mod client; pub mod config; @@ -43,6 +44,7 @@ lyracore — local LyraCore development USAGE: lyracore doctor check whether your machine is ready for `dev up` + lyracore bindings [--check] prepare Gateway Rust bindings, or check freshness as JSON lyracore preflight the offline deploy gate: build, schema, filters lyracore publish [DATABASE ...] preflight, then publish the module. With no names, publishes every database of the active @@ -205,6 +207,9 @@ prompt. It is never passed as a command-line argument."; pub enum Command { Doctor, Preflight, + Bindings { + check: bool, + }, Publish { /// The database names typed on the command line. Empty means none were — `publish::run` /// resolves that against the recorded topology once it has discovered the project. @@ -308,6 +313,9 @@ impl Command { ))), ["doctor"] => Ok(Command::Doctor), + ["bindings"] => Ok(Command::Bindings { check: false }), + ["bindings", "--check"] => Ok(Command::Bindings { check: true }), + ["bindings", ..] => Err(Error::Usage("usage: lyracore bindings [--check]".into())), ["preflight"] => Ok(Command::Preflight), ["preflight", other, ..] => Err(Error::Usage(format!( "`preflight` takes no arguments (got '{other}')" diff --git a/src/cmd/preflight.rs b/src/cmd/preflight.rs index a0b5f55..4b013b4 100644 --- a/src/cmd/preflight.rs +++ b/src/cmd/preflight.rs @@ -222,7 +222,7 @@ fn check_deploy_build( pub fn deploy_check_command(project: &ProjectLayout) -> CommandSpec { // `--manifest-path` rather than a bare `-p`: `lyracore` runs from any subdirectory of a // checkout, and cargo would otherwise resolve the workspace from the caller's cwd. - CommandSpec::new("cargo") + module_command(project, "cargo") .arg("check") .arg("-q") .arg("--manifest-path") @@ -249,6 +249,17 @@ pub fn schema_command(project: &ProjectLayout, out_dir: &Path) -> CommandSpec { schema_command_for_language(project, out_dir, "rust") } +/// Schema extraction and publishing use the checkout's Cargo configuration. Ambient Rust flags +/// must not select a different schema while the prepared bindings still match the source hashes. +pub(crate) fn module_command(project: &ProjectLayout, program: &str) -> CommandSpec { + CommandSpec::new(program) + .cwd(&project.root) + .env_remove("RUSTFLAGS") + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .env_remove("CARGO_BUILD_RUSTFLAGS") + .env_remove("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS") +} + /// Build the one schema-extraction command shared by preflight and Package Datascript builds. /// /// The language and destination differ, but the module path, deploy feature set and @@ -258,7 +269,7 @@ pub(crate) fn schema_command_for_language( out_dir: &Path, language: &str, ) -> CommandSpec { - CommandSpec::new("spacetime") + module_command(project, "spacetime") .arg("generate") .arg("--lang") .arg(language) diff --git a/src/cmd/publish.rs b/src/cmd/publish.rs index 02221c2..f5c7196 100644 --- a/src/cmd/publish.rs +++ b/src/cmd/publish.rs @@ -38,8 +38,6 @@ const DESTRUCTIVE: [&str; 4] = ["-c", "--clear-database", "--delete-data", "--cl const VERB: &str = "publish"; const LOOT_RECEIPT_TABLE: &str = "game_loot_roll_promotion_receipt"; -const LOOT_RECEIPT_BINDING: &str = - "gateway/src/stdb/bindings/game_loot_roll_promotion_receipt_table.rs"; const LOOT_DRAIN_QUERIES: [(&str, &str); 3] = [ ( "active or staging Loot Rolls", @@ -138,7 +136,7 @@ fn database_list(databases: &[String]) -> String { /// The ONE correct publish invocation. pub fn publish_command(project: &ProjectLayout, database: &str) -> Result { validate_database(VERB, database)?; - Ok(CommandSpec::new("spacetime") + Ok(preflight::module_command(project, "spacetime") .arg("publish") .arg("-s") .arg(ProjectLayout::STDB_SERVER) @@ -162,20 +160,16 @@ fn describe_command(database: &str) -> CommandSpec { .arg(database) } -fn checkout_has_loot_receipt_binding(project: &ProjectLayout) -> Result { - let binding = project.root.join(LOOT_RECEIPT_BINDING); - match std::fs::metadata(&binding) { - Ok(metadata) if metadata.is_file() => Ok(true), - Ok(_) => Err(Error::Process(format!( - "loot-roll upgrade marker is not a file: {}. Nothing was published.", - binding.display() - ))), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(Error::Process(format!( - "could not inspect loot-roll upgrade marker {}: {error}. Nothing was published.", - binding.display() - ))), +fn checkout_has_loot_receipt(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result { + let prepared = crate::cmd::bindings::ensure(project, runner)?; + let (schema, errors) = crate::rls::generated_schema(&prepared.directory); + if !errors.is_empty() { + return Err(Error::Process(format!( + "could not read the prepared Module schema: {}. Nothing was published.", + errors.join("; ") + ))); } + Ok(schema.contains_key(LOOT_RECEIPT_TABLE)) } fn deployed_has_loot_receipt(database: &str, output: &str) -> Result { @@ -247,7 +241,7 @@ pub(crate) fn require_loot_roll_upgrade_ready( runner: &dyn ProcessRunner, databases: &[String], ) -> Result<()> { - if !checkout_has_loot_receipt_binding(project)? { + if !checkout_has_loot_receipt(project, runner)? { return Ok(()); } @@ -307,8 +301,7 @@ pub fn run( if skip_preflight { println!( - "· skipping preflight (--skip-preflight): nothing has validated this module's schema, \ - #[default] encodings or visibility filters." + "· skipping preflight (--skip-preflight): binding preparation and Loot Roll upgrade checks still run." ); } else { preflight::run(project, runner)?; @@ -512,9 +505,11 @@ mod tests { fn loot_receipt_upgrade(tmp: &TempDir) -> ProjectLayout { let project = healthy(tmp); - let binding = project.root.join(LOOT_RECEIPT_BINDING); - std::fs::create_dir_all(binding.parent().unwrap()).unwrap(); - std::fs::write(binding, "// generated receipt table binding\n").unwrap(); + let stack = FakeStack::new().with_generated_binding( + "game_loot_roll_promotion_receipt_table.rs", + "use super::character_type::Character;\n/// Table handle for the table `game_loot_roll_promotion_receipt`.\nimpl __sdk::Table for ReceiptTableHandle<'_> {\n type Row = Character;\n}\n", + ); + crate::cmd::bindings::ensure(&project, &stack.runner()).unwrap(); project } @@ -763,8 +758,10 @@ mod tests { fn skip_preflight_cannot_skip_the_live_loot_upgrade_check() { let tmp = TempDir::new().unwrap(); let project = loot_receipt_upgrade(&tmp); - std::fs::write(project.rust_toolchain_file(), "channel = \"1.0.0\"\n").unwrap(); - let stack = clean_old_schema_stack().with_stdout("FROM game_loot_roll WHERE", " n\n 1\n"); + + let stack = clean_old_schema_stack() + .fail_on("cargo check", "preflight must be skipped") + .with_stdout("FROM game_loot_roll WHERE", " n\n 1\n"); let error = run(&project, &stack.runner(), &names(&["lyracore"]), true).unwrap_err(); @@ -783,7 +780,7 @@ mod tests { } #[test] - fn ordinary_publishes_without_the_receipt_binding_keep_the_existing_path() { + fn ordinary_publishes_without_the_receipt_table_keep_the_existing_path() { let tmp = TempDir::new().unwrap(); let project = healthy(&tmp); let stack = FakeStack::new().fail_on("spacetime describe", "must not inspect live state"); @@ -801,16 +798,16 @@ mod tests { } #[test] - fn an_invalid_receipt_binding_marker_refuses_before_live_reads_or_publish() { + fn failed_schema_preparation_stops_before_live_reads_or_publish() { let tmp = TempDir::new().unwrap(); let project = healthy(&tmp); - std::fs::create_dir_all(project.root.join(LOOT_RECEIPT_BINDING)).unwrap(); - let stack = FakeStack::new(); - - let error = run(&project, &stack.runner(), &names(&["lyracore"]), true).unwrap_err(); - - assert!(error.to_string().contains("not a file"), "{error}"); - assert!(stack.rendered().is_empty(), "{:?}", stack.rendered()); + let stack = FakeStack::new().fail_on("spacetime generate", "bad Module"); + assert!(run(&project, &stack.runner(), &names(&["lyracore"]), true).is_err()); + assert!(!stack + .rendered() + .iter() + .any(|command| command.starts_with("spacetime describe") + || command.starts_with("spacetime publish"))); } // ---- bare `publish` resolves against the recorded topology, not a hardcoded default ---- @@ -988,6 +985,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let project = healthy(&tmp); std::fs::write(project.rust_toolchain_file(), "channel = \"1.0.0\"\n").unwrap(); + let stack = FakeStack::new(); let error = run(&project, &stack.runner(), &names(&["lyracore"]), false).unwrap_err(); @@ -1068,9 +1066,8 @@ mod tests { fn skip_preflight_is_the_only_way_past_the_gate() { let tmp = TempDir::new().unwrap(); let project = healthy(&tmp); - // A checkout preflight would reject... - std::fs::write(project.rust_toolchain_file(), "channel = \"1.0.0\"\n").unwrap(); - let stack = FakeStack::new(); + // A Module build preflight would refuse. + let stack = FakeStack::new().fail_on("cargo check", "invalid native build"); run(&project, &stack.runner(), &names(&["lyracore"]), true).unwrap(); assert!( stack diff --git a/src/proc/fake.rs b/src/proc/fake.rs index 0f4c6ad..4f4039f 100644 --- a/src/proc/fake.rs +++ b/src/proc/fake.rs @@ -42,6 +42,7 @@ struct Inner { log_override: Option, /// The working copy a `git clone` on this stack produces. See [`FakeStack::with_git_clone`]. git_clone: Option, + generated_bindings: HashMap, /// A spawn whose render matches `needle` (0) models a version-manager shim's `exec` (#431): /// `identity()` reports `before` (1) on its first read for that PID and `after` (2) on every /// read after. See [`FakeStack::with_shim_exec`]. @@ -59,6 +60,15 @@ struct Inner { pub struct FakeStack(Arc>); impl FakeStack { + pub fn with_generated_binding(self, name: &str, content: &str) -> Self { + self.0 + .lock() + .unwrap() + .generated_bindings + .insert(name.into(), content.into()); + self + } + pub fn new() -> Self { Self(Arc::new(Mutex::new(Inner { next_pid: 4000, @@ -318,7 +328,7 @@ fn canned_stdout(render: &str) -> String { /// `spacetime generate` writes a module's bindings into its `--out-dir`. A fake that only recorded /// the call would leave the RLS-identifier check (which READS those bindings) unexercisable. -fn materialize_generated_bindings(cmd: &CommandSpec) { +fn materialize_generated_bindings(cmd: &CommandSpec, extra: &HashMap) { let args = cmd.args(); let Some(index) = args.iter().position(|a| a == "--out-dir") else { return; @@ -330,6 +340,10 @@ fn materialize_generated_bindings(cmd: &CommandSpec) { if std::fs::create_dir_all(out).is_err() { return; } + let _ = std::fs::write(out.join("mod.rs"), "pub mod character_type;\n"); + for (name, content) in extra { + let _ = std::fs::write(out.join(name), content); + } let _ = std::fs::write( out.join("game_character_table.rs"), "use super::character_type::Character;\n\ @@ -377,7 +391,7 @@ impl ProcessRunner for FakeProcessRunner { let render = cmd.render(); self.record(Call::Wait(cmd.clone()), &render)?; if render.contains("spacetime generate") { - materialize_generated_bindings(cmd); + materialize_generated_bindings(cmd, &self.0.lock().unwrap().generated_bindings); } let git_destination = if render.starts_with("git clone") { cmd.args().last().map(Path::new) From 5eae8792ed66525eb8f8294a17ce42aa3398a1b3 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sun, 11 Oct 2026 22:46:51 +0200 Subject: [PATCH 2/3] fix(bindings): keep preparation output concise --- src/bin/main.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/bin/main.rs b/src/bin/main.rs index 3788dfc..e50d0f8 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -51,16 +51,17 @@ fn run(args: &[String]) -> Result { } Command::Bindings { check } => { let project = ProjectLayout::discover()?; - let prepared = if check { - cmd::bindings::check(&project)? + if check { + let prepared = cmd::bindings::check(&project)?; + println!( + "{}", + serde_json::to_string(&prepared) + .map_err(|e| lyracore_cli::Error::Process(e.to_string()))? + ); } else { - cmd::bindings::ensure(&project, &runner)? - }; - println!( - "{}", - serde_json::to_string(&prepared) - .map_err(|e| lyracore_cli::Error::Process(e.to_string()))? - ); + cmd::bindings::ensure(&project, &runner)?; + println!("Gateway bindings are ready."); + } Ok(EXIT_OK) } Command::Preflight => { From ca736690e5d1d40cd967e1c7e4b428109a1a8b08 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Mon, 12 Oct 2026 11:42:56 +0200 Subject: [PATCH 3/3] fix(bindings): include all source directory names in freshness checks --- src/cmd/bindings.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/cmd/bindings.rs b/src/cmd/bindings.rs index 8b08a67..9ce7738 100644 --- a/src/cmd/bindings.rs +++ b/src/cmd/bindings.rs @@ -56,12 +56,6 @@ fn collect( ancestors.push(resolved); for entry in fs::read_dir(path)? { let entry = entry?; - if matches!( - entry.file_name().to_str(), - Some("target" | ".git" | "node_modules") - ) { - continue; - } collect(&entry.path(), files, ancestors)?; } ancestors.pop(); @@ -294,6 +288,25 @@ mod tests { assert!(check(&project).is_err()); } + #[test] + fn source_directory_names_do_not_hide_schema_changes() { + let tmp = tempfile::tempdir().unwrap(); + let project = fixture(tmp.path()); + for relative in [ + "module/src/target/mod.rs", + "module/src/node_modules/mod.rs", + "module/src/.git/included.rs", + "packages/example/src/target/mod.rs", + ] { + let source = tmp.path().join(relative); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, "pub struct Row { pub value: u32 }").unwrap(); + ensure(&project, &FakeStack::new().runner()).unwrap(); + fs::write(&source, "pub struct Row { pub value: String }").unwrap(); + assert!(check(&project).is_err(), "{relative}"); + } + } + #[test] fn a_failed_replacement_keeps_the_previous_complete_output() { let tmp = tempfile::tempdir().unwrap();