diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs index 5825cd15f59..aec3a3e61b0 100644 --- a/crates/bindings-macro/src/lib.rs +++ b/crates/bindings-macro/src/lib.rs @@ -299,3 +299,55 @@ pub fn client_visibility_filter(args: StdTokenStream, item: StdTokenStream) -> S }) }) } + +/// Known setting names and their registration code generators. +const KNOWN_SETTINGS: &[&str] = &["CASE_CONVERSION_POLICY"]; + +#[proc_macro_attribute] +pub fn settings(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream { + ok_or_compile_error(|| { + if !args.is_empty() { + return Err(syn::Error::new_spanned( + TokenStream::from(args), + "The `settings` attribute does not accept arguments", + )); + } + + let item: ItemConst = syn::parse(item)?; + let ident = &item.ident; + let ident_str = ident.to_string(); + + if !KNOWN_SETTINGS.contains(&ident_str.as_str()) { + return Err(syn::Error::new_spanned( + ident, + format!( + "unknown setting `{ident_str}`. Known settings: {}", + KNOWN_SETTINGS.join(", ") + ), + )); + } + + // Use a fixed export name so that two `#[spacetimedb::settings]` consts + // for the same setting produce a linker error (duplicate symbol). + let register_symbol = format!("__preinit__05_setting_{ident_str}"); + + // Generate the registration call based on the setting name. + let register_call = match ident_str.as_str() { + "CASE_CONVERSION_POLICY" => quote! { + spacetimedb::rt::register_case_conversion_policy(#ident) + }, + _ => unreachable!("validated above"), + }; + + Ok(quote! { + #item + + const _: () = { + #[export_name = #register_symbol] + extern "C" fn __register_setting() { + #register_call + } + }; + }) + }) +} diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index 5d1fc053c65..22ff25256d0 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -6,6 +6,7 @@ import { type AlgebraicTypeVariants, } from './algebraic_type'; import type { + CaseConversionPolicy, RawModuleDefV10, RawModuleDefV10Section, RawScopedTypeNameV10, @@ -193,9 +194,23 @@ export class ModuleContext { value: module.explicitNames, } ); + push( + module.caseConversionPolicy && { + tag: 'CaseConversionPolicy', + value: module.caseConversionPolicy, + } + ); return { sections }; } + /** + * Set the case conversion policy for this module. + * Called by the settings mechanism. + */ + setCaseConversionPolicy(policy: CaseConversionPolicy) { + this.#moduleDef.caseConversionPolicy = policy; + } + get typespace() { return this.#moduleDef.typespace; } diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 4b7b494be0f..ac4a492556f 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -1,5 +1,11 @@ export * from '../lib/type_builders'; -export { schema, type InferSchema, type ModuleExport } from './schema'; +export { + schema, + type InferSchema, + type ModuleExport, + type ModuleSettings, +} from './schema'; +export { CaseConversionPolicy } from '../lib/autogen/types'; export { table } from '../lib/table'; export { SenderError, SpacetimeHostError, errors } from './errors'; export type { Reducer, ReducerCtx } from '../lib/reducers'; diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index e4de1fd2fb6..b9eb258762b 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -1,5 +1,5 @@ import { moduleHooks, type ModuleDefaultExport } from 'spacetime:sys@2.0'; -import { Lifecycle } from '../lib/autogen/types'; +import { CaseConversionPolicy, Lifecycle } from '../lib/autogen/types'; import { type ParamsAsObject, type ParamsObj, @@ -525,10 +525,34 @@ export type InferSchema> = * }); * ``` */ +/** + * Module-level settings that can be passed to `schema()`. + */ +export interface ModuleSettings { + /** + * The case conversion policy for this module. + * Defaults to `SnakeCase` if not specified. + * + * @example + * ```ts + * export default schema({ + * player, + * }, { CASE_CONVERSION_POLICY: CaseConversionPolicy.None }); + * ``` + */ + CASE_CONVERSION_POLICY?: CaseConversionPolicy; +} + export function schema>( - tables: H + tables: H, + moduleSettings?: ModuleSettings ): Schema> { const ctx = new SchemaInner>(ctx => { + // Apply module settings. + if (moduleSettings?.CASE_CONVERSION_POLICY != null) { + ctx.setCaseConversionPolicy(moduleSettings.CASE_CONVERSION_POLICY); + } + const tableSchemas: Record = {}; for (const [accName, table] of Object.entries(tables)) { const tableDef = table.tableDef(ctx, accName); diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 35d61b04aed..daba0f3c334 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -36,6 +36,7 @@ pub use sats::SpacetimeType; pub use spacetimedb_bindings_macro::__TableHelper; pub use spacetimedb_bindings_sys as sys; pub use spacetimedb_lib; +pub use spacetimedb_lib::db::raw_def::v10::CaseConversionPolicy; pub use spacetimedb_lib::de::{Deserialize, DeserializeOwned}; pub use spacetimedb_lib::sats; pub use spacetimedb_lib::ser::Serialize; @@ -99,6 +100,30 @@ pub use spacetimedb_bindings_macro::duration; #[doc(inline, hidden)] // TODO: RLS filters are currently unimplemented, and are not enforced. pub use spacetimedb_bindings_macro::client_visibility_filter; +/// Declare a module-level setting. +/// +/// Apply this attribute to a `const` item whose name is a known setting: +/// +/// ```ignore +/// use spacetimedb::CaseConversionPolicy; +/// +/// #[spacetimedb::settings] +/// const CASE_CONVERSION_POLICY: CaseConversionPolicy = CaseConversionPolicy::SnakeCase; +/// ``` +/// +/// # Known Settings +/// +/// | Const Name | Type | Default | Description | +/// |---|---|---|---| +/// | `CASE_CONVERSION_POLICY` | [`CaseConversionPolicy`] | `SnakeCase` | How identifiers are converted to canonical names | +/// +/// # Errors +/// +/// - Unknown setting name: compile error listing known settings +/// - Duplicate setting: linker error (duplicate symbol) +#[doc(inline)] +pub use spacetimedb_bindings_macro::settings; + /// Declares a table with a particular row type. /// /// This attribute is applied to a struct type with named fields. diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 266ffe16a44..505068b6a10 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -4,7 +4,9 @@ use crate::query_builder::{FromWhere, HasCols, LeftSemiJoin, RawQuery, RightSemi use crate::table::IndexAlgo; use crate::{sys, AnonymousViewContext, IterBuf, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext}; use spacetimedb_lib::bsatn::EncodeError; -use spacetimedb_lib::db::raw_def::v10::{ExplicitNames as RawExplicitNames, RawModuleDefV10Builder}; +use spacetimedb_lib::db::raw_def::v10::{ + CaseConversionPolicy, ExplicitNames as RawExplicitNames, RawModuleDefV10Builder, +}; pub use spacetimedb_lib::db::raw_def::v9::Lifecycle as LifecycleReducer; use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, TableType, ViewResultHeader}; use spacetimedb_lib::de::{self, Deserialize, DeserializeOwned, Error as _, SeqProductAccess}; @@ -856,6 +858,22 @@ pub fn register_row_level_security(sql: &'static str) { }) } +/// Set the case conversion policy for this module. +/// +/// This is called by the `#[spacetimedb::settings]` attribute macro. +/// Do not call directly; use the attribute instead: +/// +/// ```ignore +/// #[spacetimedb::settings] +/// const CASE_CONVERSION_POLICY: CaseConversionPolicy = CaseConversionPolicy::SnakeCase; +/// ``` +#[doc(hidden)] +pub fn register_case_conversion_policy(policy: CaseConversionPolicy) { + register_describer(move |module| { + module.inner.set_case_conversion_policy(policy); + }) +} + /// A builder for a module. #[derive(Default)] pub struct ModuleBuilder { diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index e2294bec69e..a801ea286be 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -1054,6 +1054,22 @@ impl RawModuleDefV10Builder { self.explicit_names_mut().merge(names); } + /// Set the case conversion policy for this module. + /// + /// By default, SpacetimeDB applies `SnakeCase` conversion to table names, + /// column names, reducer names, etc. Use `CaseConversionPolicy::None` to + /// disable all case conversion (useful for modules with existing data that + /// was stored under the original naming convention). + pub fn set_case_conversion_policy(&mut self, policy: CaseConversionPolicy) { + // Remove any existing policy section. + self.module + .sections + .retain(|s| !matches!(s, RawModuleDefV10Section::CaseConversionPolicy(_))); + self.module + .sections + .push(RawModuleDefV10Section::CaseConversionPolicy(policy)); + } + /// Finish building, consuming the builder and returning the module. /// The module should be validated before use. pub fn finish(self) -> RawModuleDefV10 {