Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/spock-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ spock-lang = { path = "../spock-lang" }
spock-runtime = { path = "../spock-runtime" }
spock-project = { path = "../spock-project" }
spock-host = { path = "../spock-host" }
uhura-check = { path = "../../uhura/crates/uhura-check" }
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
Expand Down
13 changes: 10 additions & 3 deletions crates/spock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ enum GenTarget {
/// The app assembly declaration (.wire spike syntax).
#[arg(long)]
app: PathBuf,
/// The Uhura client project whose `uhura.toml` carries the package identity.
#[arg(long)]
uhura: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
},
Expand Down Expand Up @@ -218,9 +221,13 @@ fn execute(command: Command) -> ExitCode {
program.contract(),
GenerationTarget::GraphqlSchema,
),
GenTarget::Provider { app, .. } => {
spock_cli::provider_gen::generate_from_contract(program.contract(), &app)
.map_err(anyhow::Error::msg)
GenTarget::Provider { app, uhura, .. } => {
spock_cli::provider_gen::generate_from_contract(
program.contract(),
&app,
&uhura,
)
.map_err(anyhow::Error::msg)
}
};
match artifact {
Expand Down
68 changes: 52 additions & 16 deletions crates/spock-cli/src/provider_gen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,12 +552,18 @@ pub fn validate_against(file: &WireFile, schema: &SpockSchema) -> Vec<String> {
problems
}

/// Table → the machine id type it keys (`post` → `PostId`). One definition so the
/// machine types, the view types, and the provider key tags cannot drift apart.
fn id_type_name(table: &str) -> Option<String> {
let mut chars = table.chars();
let head = chars.next()?;
Some(format!("{}{}Id", head.to_ascii_uppercase(), chars.as_str()))
}

/// Scalar column type → machine type. FK/enum columns never reach here.
fn column_machine_type(table: &str, column: &SpockColumn) -> Option<String> {
if column.key && column.base == "uuid" {
let mut chars = table.chars();
let head = chars.next()?;
return Some(format!("{}{}Id", head.to_ascii_uppercase(), chars.as_str()));
return id_type_name(table);
}
match column.base.as_str() {
"text" => Some("Text".to_string()),
Expand Down Expand Up @@ -665,28 +671,43 @@ pub fn generate_refusals(file: &WireFile) -> Result<Vec<(String, Vec<String>)>,

/// Assemble the generated artifacts into a runnable provider tables module (ESM JS).
/// Logic (queue/settlement) belongs to the shared runtime; this module carries data and pure dispatch only.
///
/// `module` is the Uhura package identity (`app.instagram@1`) the client was resolved
/// under. Key tags are app data, so they are generated here rather than supplied by
/// the runtime, which must stay app-independent.
pub fn generate_provider_module(
file: &WireFile,
schema: &SpockSchema,
module: &str,
) -> Result<String, Vec<String>> {
let snapshot = generate_snapshot_query(file, schema)?;
let dispatch = generate_dispatch(file)?;
let refusals = generate_refusals(file)?;

let mut id_types: Vec<String> = Vec::new();
let mut id_types: Vec<(String, String)> = Vec::new();
for m in &file.mutations {
for f in &m.fields {
if let Some((table, "id")) = f.source.split_once('.') {
let name = format!("{}_ID_TYPE", screaming(table));
if !id_types.contains(&name) {
id_types.push(name);
let Some(ty) = id_type_name(table) else {
continue;
};
if !id_types.iter().any(|(n, _)| n == &name) {
id_types.push((name, ty));
}
}
}
}

let routing = generate_routing(file);
let mut out = String::from("// Generated by `spock gen provider` — do not edit.\n\n");
out.push_str(&format!("export const MODULE = \"{module}\";\n\n"));
for (name, ty) in &id_types {
out.push_str(&format!("const {name} = `${{MODULE}}::{ty}`;\n"));
}
if !id_types.is_empty() {
out.push('\n');
}
out.push_str(&format!("export const SNAPSHOT_QUERY = `{snapshot}`;\n\n"));
out.push_str(&format!("export const MUTATION_ROUTING = {routing};\n\n"));
out.push_str("export const COMMAND_REFUSALS = {\n");
Expand All @@ -699,13 +720,8 @@ pub fn generate_provider_module(
out.push_str(&format!(" \"{route}\": [{list}],\n"));
}
out.push_str("};\n\n");
let mut helper_names = vec![
"keyText".to_string(),
"requiredField".to_string(),
"boolValue".to_string(),
"textValue".to_string(),
];
helper_names.extend(id_types);
// Exactly the app-independent helpers. Anything app-specific is generated above.
let helper_names = ["keyText", "requiredField", "boolValue", "textValue"];
out.push_str(&format!(
"export function toBackendOperation(mutation, request, fields, helpers) {{\n const {{ {} }} = helpers;\n switch (mutation) {{\n{dispatch} }}\n}}\n",
helper_names.join(", ")
Expand Down Expand Up @@ -1260,11 +1276,30 @@ pub fn extract_contract(source: &str) -> Result<SpockSchema, Vec<String>> {

// ===== CLI seam: typed contract + declaration path → provider module =====

/// `spock gen provider <program> --app <decl>`: generate the provider tables module
/// from the contract (compiler-owned) and the app declaration; problems merge into one failure.
/// Read the Uhura package identity (`app.instagram@1`) from a resolved client
/// project. The identity already exists in `uhura.toml`; it is never guessed here.
pub fn read_module_identity(uhura_project: &std::path::Path) -> Result<String, String> {
let manifest_path = uhura_project.join("uhura.toml");
let text = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("cannot read {}: {e}", manifest_path.display()))?;
let manifest =
uhura_check::project_manifest::load_project_manifest(&text).map_err(|issues| {
issues
.iter()
.map(|issue| format!("{}: {}", issue.path, issue.message))
.collect::<Vec<_>>()
.join("\n")
})?;
Ok(manifest.project.package_id().to_string())
}

/// `spock gen provider <program> --app <decl> --uhura <project>`: generate the provider
/// tables module from the contract (compiler-owned), the app declaration, and the
/// client's own package identity; problems merge into one failure.
pub fn generate_from_contract<C: ::serde::Serialize>(
contract: &C,
app_declaration: &std::path::Path,
uhura_project: &std::path::Path,
) -> Result<String, String> {
let json = serde_json::to_string(contract)
.map_err(|e| format!("contract serialization failed: {e}"))?;
Expand All @@ -1276,5 +1311,6 @@ pub fn generate_from_contract<C: ::serde::Serialize>(
if !problems.is_empty() {
return Err(problems.join("\n"));
}
generate_provider_module(&file, &schema).map_err(|p| p.join("\n"))
let module = read_module_identity(uhura_project)?;
generate_provider_module(&file, &schema, &module).map_err(|p| p.join("\n"))
}
130 changes: 118 additions & 12 deletions crates/spock-cli/tests/provider_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,30 +127,136 @@ fn schema_lies_are_refused_with_precise_problems() {
assert!(err[0].contains("unknown table `ghosts`"), "{err:?}");
}

/// Node-based execution harness — opt-in only (CI does not require node):
/// `cargo test -p spock-cli --test provider_gen -- --ignored`
const MODULE: &str = "app.instagram@1";

/// The key tags the dispatch switch reads are app data: they must be generated
/// module constants, and the runtime contract must stay the four generic helpers.
#[test]
#[ignore = "requires node"]
fn generated_module_drives_the_shared_runtime_under_node() {
fn key_tags_are_generated_data_and_helpers_stay_app_independent() {
let file = pg::parse(WIRE).expect("parse");
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");

assert!(
module.contains(&format!("export const MODULE = \"{MODULE}\";")),
"the module publishes the identity it was generated for"
);
for (constant, ty) in [
("POST_ID_TYPE", "PostId"),
("USER_ID_TYPE", "UserId"),
("STORY_ID_TYPE", "StoryId"),
] {
assert!(
module.contains(&format!("const {constant} = `${{MODULE}}::{ty}`;")),
"{constant} must be generated, not supplied by the runtime"
);
}
assert!(
module.contains("const { keyText, requiredField, boolValue, textValue } = helpers;"),
"helpers carry no app-specific names:\n{module}"
);
}

/// Uhura declares these id types; the provider tags keys with the same names.
/// One generator keeps them from drifting apart.
#[test]
fn generated_key_tags_agree_with_the_generated_machine_types() {
let file = pg::parse(WIRE).expect("parse");
let module = pg::generate_provider_module(&file, &schema()).expect("generates");
let machine = pg::generate_machine_types(&file);
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");
for ty in ["PostId", "UserId", "StoryId"] {
assert!(machine.contains(ty), "machine types declare {ty}");
assert!(module.contains(&format!("::{ty}`")), "provider tags {ty}");
}
}

fn write_module(name: &str, contents: String) -> std::path::PathBuf {
let dir = std::env::temp_dir().join("spock-provider-spike");
std::fs::create_dir_all(&dir).expect("temp dir");
let module_path = dir.join("provider-tables.mjs");
std::fs::write(&module_path, module).expect("write module");
let path = dir.join(name);
std::fs::write(&path, contents).expect("write module");
path
}

fn run_node(script: &str, args: &[&std::path::Path]) -> String {
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/provider_runtime");
let output = std::process::Command::new("node")
.arg(format!("{base}/runtime-unit.mjs"))
.arg(&module_path)
.arg(format!("{base}/runtime.mjs"))
let mut command = std::process::Command::new("node");
command.arg(format!("{base}/{script}"));
for arg in args {
command.arg(arg);
}
command.arg(format!("{base}/runtime.mjs"));
let output = command
.output()
.expect("node must be available for this opt-in check");
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"stdout: {stdout}\nstderr: {stderr}"
);
stdout
}

/// Node-based execution harness. Node is already required to reach a green
/// workspace: the canonical Play artifacts these crates test against are built by
/// `pnpm -C uhura/web build:provider`, so nothing here is optional.
#[test]
fn generated_module_drives_the_shared_runtime_under_node() {
let file = pg::parse(WIRE).expect("parse");
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");
let path = write_module("runtime-tables.mjs", module);
let stdout = run_node("runtime-unit.mjs", &[&path]);
assert!(stdout.contains("runtime ok"), "{stdout}");
}

/// The runtime may not name any app, and it must refuse keys that do not carry
/// the Uhura type the generated tables declare.
#[test]
fn the_runtime_is_app_independent_and_checks_key_types() {
let file = pg::parse(WIRE).expect("parse");
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");
let path = write_module("genericity-tables.mjs", module);
let source = std::path::PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/provider_runtime/runtime.mjs"
));
let stdout = run_node("genericity.mjs", &[&path, &source]);
assert!(stdout.contains("genericity ok"), "{stdout}");
}

/// A second app, with its own tables and its own package identity, drives the very
/// same runtime file. This is what makes the runtime shared rather than Instagram's.
#[test]
fn a_second_app_drives_the_same_runtime() {
const BLOG_CONTRACT: &str = r#"{
"tables": [
{ "name": "article", "key": ["id"],
"fields": [
{ "name": "id", "type": { "kind": "uuid" } },
{ "name": "title", "type": { "kind": "text" } }
] }
],
"errors": [{ "code": "not_authorized" }],
"fns": [
{ "name": "publish_article", "errors": ["not_authorized"] },
{ "name": "retract_article", "errors": ["not_authorized"] }
]
}"#;
const BLOG_WIRE: &str = r#"
app "Blog";
snapshot app { cap 50 per table; read article; }
mutation SetPublished { article: article.id, published: bool }
-> if published call publish_article(article) route desk/publish allow not_authorized
else call retract_article(article) route desk/retract allow not_authorized;
"#;

let schema = pg::extract_contract(BLOG_CONTRACT).expect("contract parses");
let file = pg::parse(BLOG_WIRE).expect("parse");
assert!(pg::validate_against(&file, &schema).is_empty());
let module = pg::generate_provider_module(&file, &schema, "app.blog@2").expect("generates");
assert!(module.contains("const ARTICLE_ID_TYPE = `${MODULE}::ArticleId`;"));

let path = write_module("blog-tables.mjs", module);
let stdout = run_node("second-app.mjs", &[&path]);
assert!(stdout.contains("second app ok"), "{stdout}");
}
Loading
Loading