diff --git a/src/commands/module/mod.rs b/src/commands/module/mod.rs index 59218e4..be8f01f 100644 --- a/src/commands/module/mod.rs +++ b/src/commands/module/mod.rs @@ -118,79 +118,106 @@ fn init(args: InitArgs) -> Result<()> { let pre_check = with_spinner("Checking availability…", || { api::get_module(&client, &apps_base, &creds.access_token, &slug) }); - // Determine whether we still need to POST. `--used` + already-exists - // short-circuits without prompting for confirmation again. - let needs_create = match pre_check { + // Capture the platform-assigned module ID so scaffolding can substitute + // it into Config.ID and the table prefix. Sourced from whichever branch + // succeeds (`--used` + already-exists, fresh create, or a race-refetch). + let module_id: String = match pre_check { Ok(Some(existing)) if args.used => { print_already_exists(username, &existing.slug, Some(&existing.id)); - false + existing.id } Ok(Some(_)) => { return Err(anyhow!( "@{username}/{slug} already exists (pass --used to ignore when re-running)" )); } - Ok(None) => true, - Err(ApiError::Unauthenticated) => return Err(session_expired()), - Err(e) => return Err(e.into()), - }; - - if needs_create { - if !args.yes { - eprintln!(); - eprintln!(" {} {}", style("Module:").dim(), style(&name).bold()); - eprintln!( - " {} {}", - style("Slug:").dim(), - style(format!("@{username}/{slug}")).cyan().bold() - ); - let confirmed = Confirm::with_theme(&theme) - .with_prompt("Create this module?") - .default(true) - .interact()?; - if !confirmed { - eprintln!("{}", style("aborted.").yellow()); - return Ok(()); + Ok(None) => { + if !args.yes { + eprintln!(); + eprintln!(" {} {}", style("Module:").dim(), style(&name).bold()); + eprintln!( + " {} {}", + style("Slug:").dim(), + style(format!("@{username}/{slug}")).cyan().bold() + ); + let confirmed = Confirm::with_theme(&theme) + .with_prompt("Create this module?") + .default(true) + .interact()?; + if !confirmed { + eprintln!("{}", style("aborted.").yellow()); + return Ok(()); + } } - } - let create_result = with_spinner("Creating module…", || { - api::create_module( - &client, - &apps_base, - &creds.access_token, - &CreateModuleInput { - name: &name, - slug: &slug, - }, - ) - }); - - match create_result { - Ok(m) => print_created(username, &m.slug, &m.id), - Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => { - print_already_exists(username, &slug, None); - } - Err(ApiError::Server { code, message, .. }) => { - return Err(anyhow!( - "{code}: {message}{hint}", - hint = slug_error_hint(&code) - )); + let create_result = with_spinner("Creating module…", || { + api::create_module( + &client, + &apps_base, + &creds.access_token, + &CreateModuleInput { + name: &name, + slug: &slug, + }, + ) + }); + + match create_result { + Ok(m) => { + print_created(username, &m.slug, &m.id); + m.id + } + Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => { + // Race: the slug was free at pre-check but taken by the + // time we POST'd. Re-fetch so scaffold still has a real + // module ID to substitute. + print_already_exists(username, &slug, None); + refetch_module_id(&client, &apps_base, &creds.access_token, username, &slug)? + } + Err(ApiError::Server { code, message, .. }) => { + return Err(anyhow!( + "{code}: {message}{hint}", + hint = slug_error_hint(&code) + )); + } + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => return Err(e.into()), } - Err(ApiError::Unauthenticated) => return Err(session_expired()), - Err(e) => return Err(e.into()), } - } + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => return Err(e.into()), + }; scaffold_if_requested( scaffold_target.as_deref(), &scaffold::Inputs { slug: &slug, name: &name, + module_id: &module_id, }, ) } +fn refetch_module_id( + client: &reqwest::blocking::Client, + apps_base: &str, + access_token: &str, + username: &str, + slug: &str, +) -> Result { + let refetch = with_spinner("Resolving existing module…", || { + api::get_module(client, apps_base, access_token, slug) + }); + match refetch { + Ok(Some(m)) => Ok(m.id), + Ok(None) => Err(anyhow!( + "module @{username}/{slug} disappeared between create and re-fetch" + )), + Err(ApiError::Unauthenticated) => Err(session_expired()), + Err(e) => Err(e.into()), + } +} + /// Resolve the target dir for scaffolding. `--dir ` wins; otherwise /// default to `.//`. Pass `--dir .` to scaffold into the cwd directly. fn resolve_scaffold_target(dir: Option<&Path>, slug: &str) -> PathBuf { diff --git a/src/commands/module/scaffold.rs b/src/commands/module/scaffold.rs index 70656ac..d55e188 100644 --- a/src/commands/module/scaffold.rs +++ b/src/commands/module/scaffold.rs @@ -24,12 +24,17 @@ const SQL_INIT: &str = include_str!("../../../templates/module/sql/app/0001_init // files and this renderer is auditable from a single grep. const SLUG_TOKEN: &str = "__MS_SLUG__"; const NAME_TOKEN: &str = "__MS_NAME__"; -const TABLE_PREFIX_TOKEN: &str = "__MS_TABLE_PREFIX__"; +const MODULE_ID_TOKEN: &str = "__MS_MODULE_ID__"; /// Inputs collected by the caller before scaffolding. +/// +/// `module_id` is the platform-assigned UUID — stable across slug renames, +/// which is why both `Config.ID` and the table prefix derive from it rather +/// than from the URL slug. pub(super) struct Inputs<'a> { pub slug: &'a str, pub name: &'a str, + pub module_id: &'a str, } /// Refuse if `target` already contains files. Empty dir or missing dir = OK. @@ -82,31 +87,55 @@ fn write_file(target: &Path, rel: &str, body: &str, inputs: &Inputs<'_>) -> Resu fn render(body: &str, inputs: &Inputs<'_>) -> String { body.replace(SLUG_TOKEN, inputs.slug) .replace(NAME_TOKEN, inputs.name) - .replace(TABLE_PREFIX_TOKEN, &table_prefix(inputs.slug)) + .replace(MODULE_ID_TOKEN, &sanitize_module_id(inputs.module_id)) } -/// Convert a module slug (`my-mod`) into a valid Postgres identifier prefix -/// (`my_mod`). The SQL template appends `_items` etc. — caller controls the -/// suffix. -fn table_prefix(slug: &str) -> String { - slug.replace('-', "_") +/// Convert a platform UUID (`bb8a3f8b-1234-5678-9abc-def012345678`) into a +/// valid Postgres identifier and `Config.ID`: strip hyphens, prepend a +/// literal `m` so the result always starts with a letter (UUIDs may begin +/// with a digit), and lowercase the hex (already lowercase out of +/// `gen_random_uuid()`, but normalize defensively for any other source). +/// +/// The SDK's `moduleIDPattern` regex caps `Config.ID` at 36 chars; this +/// produces 33 (`m` + 32 hex), which fits with margin. +fn sanitize_module_id(uuid: &str) -> String { + let mut out = String::with_capacity(33); + out.push('m'); + for c in uuid.chars() { + if c == '-' { + continue; + } + out.extend(c.to_lowercase()); + } + out } #[cfg(test)] mod tests { use super::*; + const SAMPLE_UUID: &str = "bb8a3f8b-1234-5678-9abc-def012345678"; + const SAMPLE_SANITIZED: &str = "mbb8a3f8b123456789abcdef012345678"; + fn ins<'a>(slug: &'a str, name: &'a str) -> Inputs<'a> { - Inputs { slug, name } + Inputs { + slug, + name, + module_id: SAMPLE_UUID, + } } #[test] - fn render_substitutes_slug_and_name() { + fn render_substitutes_module_id_into_config_id() { let out = render(MAIN_GO, &ins("my-mod", "My Mod")); - assert!(out.contains(r#"ID: "my-mod""#)); + assert!( + out.contains(&format!(r#"ID: "{}""#, SAMPLE_SANITIZED)), + "rendered main.go missing UUID-derived Config.ID" + ); assert!(out.contains(r#"Name: "My Mod""#)); assert!(!out.contains("__MS_SLUG__")); assert!(!out.contains("__MS_NAME__")); + assert!(!out.contains("__MS_MODULE_ID__")); } #[test] @@ -117,21 +146,45 @@ mod tests { } #[test] - fn table_prefix_replaces_hyphens() { - assert_eq!(table_prefix("media"), "media"); - assert_eq!(table_prefix("my-mod"), "my_mod"); - assert_eq!(table_prefix("a-b-c"), "a_b_c"); + fn sanitize_module_id_strips_hyphens_and_prepends_letter() { + assert_eq!(sanitize_module_id(SAMPLE_UUID), SAMPLE_SANITIZED); + // UUIDs that start with a digit still produce an identifier-safe ID. + assert_eq!( + sanitize_module_id("01234567-89ab-cdef-0123-456789abcdef"), + "m0123456789abcdef0123456789abcdef" + ); + } + + #[test] + fn sanitize_module_id_normalizes_uppercase() { + assert_eq!( + sanitize_module_id("BB8A3F8B-1234-5678-9ABC-DEF012345678"), + SAMPLE_SANITIZED + ); + } + + #[test] + fn sanitize_module_id_fits_under_sdk_regex_ceiling() { + // SDK's moduleIDPattern allows up to 36 chars. We emit "m" + 32 hex = 33. + let out = sanitize_module_id(SAMPLE_UUID); + assert!( + out.len() <= 36, + "sanitized ID {out} exceeds SDK regex limit" + ); + assert!(out.starts_with('m')); } #[test] - fn render_sql_uses_table_prefix() { + fn render_sql_uses_module_id_table_prefix() { let out = render(SQL_INIT, &ins("my-mod", "My Mod")); - assert!(out.contains("CREATE TABLE IF NOT EXISTS my_mod_items")); - assert!(!out.contains("__MS_TABLE_PREFIX__")); + let expected = format!("CREATE TABLE IF NOT EXISTS {}_items", SAMPLE_SANITIZED); + assert!(out.contains(&expected), "missing {expected:?}"); + assert!(!out.contains("__MS_MODULE_ID__")); } #[test] fn render_routes_substitutes_slug_in_response_body() { + // Slug stays the user-facing label even though Config.ID is UUID-derived. let out = render(ROUTES_GO, &ins("media", "Media")); assert!(out.contains(r#""hello from media""#)); } @@ -173,7 +226,7 @@ mod tests { assert!(p.exists(), "missing {}", p.display()); } let main = fs::read_to_string(target.join("main.go")).unwrap(); - assert!(main.contains(r#"ID: "media""#)); + assert!(main.contains(&format!(r#"ID: "{}""#, SAMPLE_SANITIZED))); } #[test] diff --git a/templates/module/go.mod.tmpl b/templates/module/go.mod.tmpl index f00bb2a..5d9890e 100644 --- a/templates/module/go.mod.tmpl +++ b/templates/module/go.mod.tmpl @@ -4,5 +4,5 @@ go 1.26 require ( github.com/go-chi/chi/v5 v5.2.5 - github.com/mirrorstack-ai/app-module-sdk v0.1.0 + github.com/mirrorstack-ai/app-module-sdk v0.1.1 ) diff --git a/templates/module/main.go.tmpl b/templates/module/main.go.tmpl index b8f86c3..74d65a8 100644 --- a/templates/module/main.go.tmpl +++ b/templates/module/main.go.tmpl @@ -24,7 +24,11 @@ var postInitHooks []func() func main() { if err := ms.Init(ms.Config{ - ID: "__MS_SLUG__", + // ID is derived from the platform-assigned UUID — stable across slug + // renames. Don't change it; renaming the URL slug doesn't require + // changing this. Renaming this WOULD require migrating every schema + // and table whose name is derived from it. + ID: "__MS_MODULE_ID__", Name: "__MS_NAME__", Icon: "extension", SQL: sqlFS, diff --git a/templates/module/sql/app/0001_init.up.sql.tmpl b/templates/module/sql/app/0001_init.up.sql.tmpl index f342df9..846f919 100644 --- a/templates/module/sql/app/0001_init.up.sql.tmpl +++ b/templates/module/sql/app/0001_init.up.sql.tmpl @@ -1,12 +1,13 @@ -- App-scoped migrations run per-app-tenant. The platform applies them via the -- lifecycle install/upgrade routes. Rows are isolated per app by the DB role. -- --- CONVENTION: every table name MUST start with your module ID + underscore. +-- CONVENTION: every table name MUST start with __MS_MODULE_ID__ + underscore. -- This prevents collisions when multiple modules share the same app_ schema. +-- The module ID is UUID-derived, so URL slug renames do NOT affect table names. -- -- Replace this example with your module's real schema. -CREATE TABLE IF NOT EXISTS __MS_TABLE_PREFIX___items ( +CREATE TABLE IF NOT EXISTS __MS_MODULE_ID___items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()