From 621bee90975b2a2f8897ad923f7cd51a772ff7b4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:32:02 +0000 Subject: [PATCH 1/6] Add TinyBase schema generation to specta-zod and create store-types crate - Add TinyBase exporter module to specta-zod for generating TinyBase table schemas - Fix double z.preprocess issue for optional fields that are already nullable - Create store-types crate with Rust type definitions matching packages/store schemas - Add generator binary to output both Zod and TinyBase schemas from Rust types - Include snapshot tests for TinyBase schema generation Co-Authored-By: yujonglee --- Cargo.lock | 10 + crates/specta-zod/src/lib.rs | 2 + ...a_zod__tinybase__tests__simple_struct.snap | 10 + ...ecta_zod__tinybase__tests__with_array.snap | 9 + ...a_zod__tinybase__tests__with_optional.snap | 10 + crates/specta-zod/src/tinybase.rs | 195 +++++++++++++ crates/specta-zod/src/zod.rs | 4 +- crates/store-types/Cargo.toml | 14 + crates/store-types/src/bin/generate.rs | 35 +++ crates/store-types/src/lib.rs | 268 ++++++++++++++++++ 10 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__simple_struct.snap create mode 100644 crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_array.snap create mode 100644 crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_optional.snap create mode 100644 crates/specta-zod/src/tinybase.rs create mode 100644 crates/store-types/Cargo.toml create mode 100644 crates/store-types/src/bin/generate.rs create mode 100644 crates/store-types/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ba49997d2f..19349414b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15385,6 +15385,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "store-types" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "specta", + "specta-zod", +] + [[package]] name = "str_inflector" version = "0.12.0" diff --git a/crates/specta-zod/src/lib.rs b/crates/specta-zod/src/lib.rs index 3b0b1a1f1d..1eed204f4d 100644 --- a/crates/specta-zod/src/lib.rs +++ b/crates/specta-zod/src/lib.rs @@ -1,5 +1,7 @@ mod error; +mod tinybase; mod zod; pub use error::Error; +pub use tinybase::TinyBase; pub use zod::Zod; diff --git a/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__simple_struct.snap b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__simple_struct.snap new file mode 100644 index 0000000000..cf821a34c3 --- /dev/null +++ b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__simple_struct.snap @@ -0,0 +1,10 @@ +--- +source: crates/specta-zod/src/tinybase.rs +assertion_line: 165 +expression: output +--- +export const simple_structTinybaseSchema = { + name: { type: "string" }, + age: { type: "number" }, + active: { type: "boolean" }, +}; diff --git a/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_array.snap b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_array.snap new file mode 100644 index 0000000000..1b159f94ac --- /dev/null +++ b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_array.snap @@ -0,0 +1,9 @@ +--- +source: crates/specta-zod/src/tinybase.rs +assertion_line: 181 +expression: output +--- +export const with_arrayTinybaseSchema = { + tags: { type: "string" }, + scores: { type: "string" }, +}; diff --git a/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_optional.snap b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_optional.snap new file mode 100644 index 0000000000..292f77e4d0 --- /dev/null +++ b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_optional.snap @@ -0,0 +1,10 @@ +--- +source: crates/specta-zod/src/tinybase.rs +assertion_line: 173 +expression: output +--- +export const with_optionalTinybaseSchema = { + required: { type: "string" }, + optional: { type: "string" }, + optional_num: { type: "number" }, +}; diff --git a/crates/specta-zod/src/tinybase.rs b/crates/specta-zod/src/tinybase.rs new file mode 100644 index 0000000000..6302dd4d95 --- /dev/null +++ b/crates/specta-zod/src/tinybase.rs @@ -0,0 +1,195 @@ +use std::{borrow::Cow, fmt::Write, path::Path}; + +use specta::{ + TypeCollection, + datatype::{DataType, NamedDataType, PrimitiveType, StructFields}, +}; + +use crate::Error; + +pub struct TinyBase { + pub header: Cow<'static, str>, +} + +impl Default for TinyBase { + fn default() -> Self { + Self { + header: r#"import type { TablesSchema, ValuesSchema } from "tinybase/with-schemas"; + +import type { InferTinyBaseSchema } from "./shared"; + +"# + .into(), + } + } +} + +impl TinyBase { + pub fn new() -> Self { + Self::default() + } + + pub fn header(mut self, header: impl Into>) -> Self { + self.header = header.into(); + self + } + + pub fn export(&self, types: &TypeCollection) -> Result { + let mut output = self.header.to_string(); + + for (_, ndt) in types { + export_named_datatype(&mut output, ndt)?; + output.push('\n'); + } + + Ok(output) + } + + pub fn export_to(&self, path: impl AsRef, types: &TypeCollection) -> Result<(), Error> { + let content = self.export(types)?; + std::fs::write(path, content)?; + Ok(()) + } +} + +fn export_named_datatype(s: &mut String, ndt: &NamedDataType) -> Result<(), Error> { + let name = ndt.name(); + + match &ndt.inner { + DataType::Struct(st) => { + if let StructFields::Named(named) = st.fields() { + let fields: Vec<_> = named + .fields() + .iter() + .filter(|(_, f)| f.ty().is_some()) + .collect(); + + write!( + s, + "export const {}TinybaseSchema = {{\n", + to_snake_case(name) + )?; + + for (i, (field_name, field)) in fields.iter().enumerate() { + let ty = field.ty().unwrap(); + let tinybase_type = datatype_to_tinybase_type(ty); + + if i > 0 { + s.push_str(",\n"); + } + write!(s, " {}: {{ type: \"{}\" }}", field_name, tinybase_type)?; + } + + if !fields.is_empty() { + s.push(','); + } + s.push_str("\n};\n"); + } + } + _ => {} + } + + Ok(()) +} + +fn datatype_to_tinybase_type(dt: &DataType) -> &'static str { + match dt { + DataType::Primitive(p) => primitive_to_tinybase_type(p), + DataType::Nullable(inner) => datatype_to_tinybase_type(inner), + DataType::List(_) => "string", + DataType::Map(_) => "string", + DataType::Struct(_) => "string", + DataType::Enum(_) => "string", + DataType::Tuple(_) => "string", + DataType::Reference(_) => "string", + _ => "string", + } +} + +fn primitive_to_tinybase_type(p: &PrimitiveType) -> &'static str { + use PrimitiveType::*; + + match p { + i8 | i16 | i32 | u8 | u16 | u32 | f32 | f64 | usize | isize | i64 | u64 | i128 | u128 => { + "number" + } + bool => "boolean", + String | char => "string", + } +} + +fn to_snake_case(name: &str) -> String { + let mut result = String::new(); + + for (i, c) in name.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use specta::Type; + + #[derive(Type, Serialize, Deserialize)] + struct SimpleStruct { + name: String, + age: i32, + active: bool, + } + + #[derive(Type, Serialize, Deserialize)] + struct WithOptional { + required: String, + optional: Option, + optional_num: Option, + } + + #[derive(Type, Serialize, Deserialize)] + struct WithArray { + tags: Vec, + scores: Vec, + } + + #[test] + fn test_simple_struct() { + let mut types = TypeCollection::default(); + types.register::(); + let output = TinyBase::new().header("").export(&types).unwrap(); + insta::assert_snapshot!(output); + } + + #[test] + fn test_with_optional() { + let mut types = TypeCollection::default(); + types.register::(); + let output = TinyBase::new().header("").export(&types).unwrap(); + insta::assert_snapshot!(output); + } + + #[test] + fn test_with_array() { + let mut types = TypeCollection::default(); + types.register::(); + let output = TinyBase::new().header("").export(&types).unwrap(); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snake_case_conversion() { + assert_eq!(to_snake_case("SimpleStruct"), "simple_struct"); + assert_eq!(to_snake_case("MyStruct"), "my_struct"); + assert_eq!(to_snake_case("ABC"), "a_b_c"); + assert_eq!(to_snake_case("already_snake"), "already_snake"); + } +} diff --git a/crates/specta-zod/src/zod.rs b/crates/specta-zod/src/zod.rs index 95504bb56f..9f38fbbdd9 100644 --- a/crates/specta-zod/src/zod.rs +++ b/crates/specta-zod/src/zod.rs @@ -237,7 +237,9 @@ fn field_type(s: &mut String, types: &TypeCollection, field: &Field) -> Result<( return Ok(()); }; - if field.optional() { + let is_nullable = matches!(ty, DataType::Nullable(_)); + + if field.optional() && !is_nullable { s.push_str("z.preprocess((val) => val ?? undefined, "); datatype(s, types, ty, false)?; s.push_str(".optional())"); diff --git a/crates/store-types/Cargo.toml b/crates/store-types/Cargo.toml new file mode 100644 index 0000000000..b5fb26c7e8 --- /dev/null +++ b/crates/store-types/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "store-types" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +specta = { workspace = true, features = ["derive"] } +specta-zod = { path = "../specta-zod" } + +[[bin]] +name = "generate-store-types" +path = "src/bin/generate.rs" diff --git a/crates/store-types/src/bin/generate.rs b/crates/store-types/src/bin/generate.rs new file mode 100644 index 0000000000..9d7796b42d --- /dev/null +++ b/crates/store-types/src/bin/generate.rs @@ -0,0 +1,35 @@ +use specta::TypeCollection; +use specta_zod::{TinyBase, Zod}; +use store_types::*; + +fn main() { + let mut types = TypeCollection::default(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::