Skip to main content

ankurah_core/
model.rs

1pub mod tsify;
2
3use std::sync::Arc;
4
5use ankurah_proto::{CollectionId, EntityId, State};
6
7use crate::entity::Entity;
8use crate::error::StateError;
9
10use crate::property::PropertyError;
11
12use anyhow::Result;
13
14#[cfg(feature = "wasm")]
15use js_sys;
16#[cfg(feature = "wasm")]
17use wasm_bindgen;
18#[cfg(feature = "wasm")]
19use wasm_bindgen::JsCast;
20
21/// A model is a struct that represents the present values for a given entity
22/// Schema is defined primarily by the Model object, and the View is derived from that via macro.
23pub trait Model: Sized {
24    type View: View;
25    type Mutable: Mutable;
26
27    /// WASM wrapper type for Ref<Self> - enables typed entity references in TypeScript.
28    /// The RefWrapper is a monomorphized struct (e.g., RefUser for Ref<User>) that
29    /// provides methods like `.get(ctx)` and `.id()` with proper TypeScript types.
30    #[cfg(feature = "wasm")]
31    type RefWrapper: From<crate::property::Ref<Self>> + Into<crate::property::Ref<Self>>;
32
33    fn collection() -> CollectionId;
34    // TODO - this seems to be necessary, but I don't understand why
35    // Backend fields should be getting initialized on demand when the values are set
36    fn initialize_new_entity(&self, entity: &Entity);
37}
38
39/// A read only view of an Entity which offers typed accessors
40pub trait View {
41    type Model: Model;
42    type Mutable: Mutable;
43    fn id(&self) -> EntityId { self.entity().id() }
44
45    fn collection() -> CollectionId { <Self::Model as Model>::collection() }
46    fn entity(&self) -> &Entity;
47    fn from_entity(inner: Entity) -> Self;
48    fn to_model(&self) -> Result<Self::Model, PropertyError>;
49}
50
51/// A lifetime-constrained wrapper around a Mutable for compile-time transaction safety
52#[derive(Debug)]
53pub struct MutableBorrow<'rec, T: Mutable> {
54    mutable: T,
55    _entity_ref: &'rec Entity,
56}
57
58impl<'rec, T: Mutable> MutableBorrow<'rec, T> {
59    pub fn new(entity_ref: &'rec Entity) -> Self { Self { mutable: T::new(entity_ref.clone()), _entity_ref: entity_ref } }
60
61    /// Extract the core mutable (for WASM usage)
62    pub fn into_core(self) -> T { self.mutable }
63}
64
65impl<'rec, T: Mutable> std::ops::Deref for MutableBorrow<'rec, T> {
66    type Target = T;
67    fn deref(&self) -> &Self::Target { &self.mutable }
68}
69
70impl<'rec, T: Mutable> std::ops::DerefMut for MutableBorrow<'rec, T> {
71    fn deref_mut(&mut self) -> &mut Self::Target { &mut self.mutable }
72}
73
74/// A mutable Model instance for an Entity with typed accessors.
75/// It is associated with a transaction, and may not outlive said transaction.
76pub trait Mutable {
77    type Model: Model;
78    type View: View;
79    fn id(&self) -> EntityId { self.entity().id() }
80    fn collection() -> CollectionId { <Self::Model as Model>::collection() }
81
82    fn entity(&self) -> &Entity;
83    fn new(entity: Entity) -> Self
84    where Self: Sized;
85
86    fn state(&self) -> Result<State, StateError> { self.entity().to_state() }
87
88    fn read(&self) -> Self::View {
89        let inner = self.entity();
90
91        let new_inner = match &inner.kind {
92            // If there is an upstream, use it
93            crate::entity::EntityKind::Transacted { upstream, .. } => upstream.clone(),
94            // Else we're a new Entity, and we have to rely on the commit to add this to the node
95            crate::entity::EntityKind::Primary => inner.clone(),
96        };
97
98        Self::View::from_entity(new_inner)
99    }
100}
101
102// Helper function to convert Result<T, PropertyError> to Result<T, JsValue> with context for generated WASM accessors
103#[doc(hidden)]
104#[cfg(feature = "wasm")]
105pub fn wasm_prop<T>(result: Result<T, PropertyError>, property: &'static str, model: &'static str) -> Result<T, wasm_bindgen::JsValue> {
106    result.map_err(|err| match err {
107        PropertyError::Missing => wasm_bindgen::JsValue::from_str(&format!("property '{}' is missing in model '{}'", property, model)),
108        _ => wasm_bindgen::JsValue::from_str(&err.to_string()),
109    })
110}
111
112// Helper function for Subscribe implementations in generated Views
113// don't document this
114#[doc(hidden)]
115pub fn view_subscribe<V, F>(view: &V, listener: F) -> ankurah_signals::SubscriptionGuard
116where
117    V: ankurah_signals::Signal + View + Clone + Send + Sync + 'static,
118    F: ankurah_signals::subscribe::IntoSubscribeListener<V>,
119{
120    let listener = listener.into_subscribe_listener();
121    let view_clone = view.clone();
122    let subscription = view.listen(Arc::new(move |_| {
123        // Call the listener with the current view when the broadcast fires
124        listener(view_clone.clone());
125    }));
126    ankurah_signals::SubscriptionGuard::new(subscription)
127}
128
129#[doc(hidden)]
130pub fn view_subscribe_no_clone<V, F>(view: &V, listener: F) -> ankurah_signals::SubscriptionGuard
131where
132    V: ankurah_signals::Signal + View + Send + Sync + 'static,
133    F: ankurah_signals::subscribe::IntoSubscribeListener<()>,
134{
135    let listener = listener.into_subscribe_listener();
136    let subscription = view.listen(Arc::new(move |_| {
137        listener(());
138    }));
139    ankurah_signals::SubscriptionGuard::new(subscription)
140}
141
142// Preprocess a Ref<T> field in a JS object before serde deserialization.
143// Uses duck typing: if value has an `.id` property (View/Ref types), extracts it.
144// Otherwise tries to parse as base64 string.
145#[doc(hidden)]
146#[cfg(feature = "wasm")]
147pub fn js_preprocess_ref_field(obj: &wasm_bindgen::JsValue, field_name: &str) -> Result<(), wasm_bindgen::JsValue> {
148    let field_key = wasm_bindgen::JsValue::from_str(field_name);
149    if let Ok(v) = js_sys::Reflect::get(obj, &field_key) {
150        // Skip if already a string
151        if v.as_string().is_some() {
152            return Ok(());
153        }
154
155        // Duck typing: check if value has an `.id` property (View/Ref types have this)
156        let id_key = wasm_bindgen::JsValue::from_str("id");
157        if let Ok(id_value) = js_sys::Reflect::get(&v, &id_key) {
158            // id_value should be an EntityId - get its base64 representation
159            let base64_key = wasm_bindgen::JsValue::from_str("to_base64");
160            if let Ok(to_base64_fn) = js_sys::Reflect::get(&id_value, &base64_key) {
161                if let Some(func) = to_base64_fn.dyn_ref::<js_sys::Function>() {
162                    if let Ok(result) = func.call0(&id_value) {
163                        if let Some(id_str) = result.as_string() {
164                            js_sys::Reflect::set(obj, &field_key, &wasm_bindgen::JsValue::from_str(&id_str))?;
165                            return Ok(());
166                        }
167                    }
168                }
169            }
170        }
171
172        // If we get here and it's not a string, it's an invalid value
173        if !v.is_undefined() && !v.is_null() {
174            return Err(wasm_bindgen::JsValue::from_str(&format!("Field '{}' must be a View, Ref, or base64 string", field_name)));
175        }
176    }
177    Ok(())
178}
179
180// Helper function for map implementations in generated WASM ResultSet wrappers
181// don't document this
182#[doc(hidden)]
183#[cfg(feature = "wasm")]
184pub fn js_resultset_map<V>(resultset: &crate::resultset::ResultSet<V>, callback: &js_sys::Function) -> js_sys::Array
185where V: View + Clone + 'static + Into<wasm_bindgen::JsValue> {
186    use ankurah_signals::Get;
187    let items = resultset.get();
188    let result_array = js_sys::Array::new();
189
190    for item in items {
191        let js_item = item.into();
192        if let Ok(mapped_value) = callback.call1(&wasm_bindgen::JsValue::NULL, &js_item) {
193            result_array.push(&mapped_value);
194        }
195    }
196
197    result_array
198}
199
200// Helper function for subscribe implementations in generated WASM LiveQuery wrappers
201#[doc(hidden)]
202#[cfg(feature = "wasm")]
203pub fn js_livequery_subscribe<V, W, F>(
204    livequery: &crate::livequery::LiveQuery<V>,
205    callback: js_sys::Function,
206    immediate: bool,
207    wrap_changeset: F,
208) -> ankurah_signals::SubscriptionGuard
209where
210    V: View + Clone + Send + Sync + 'static,
211    W: Into<wasm_bindgen::JsValue>,
212    F: Fn(crate::changes::ChangeSet<V>) -> W + Send + Sync + 'static,
213{
214    use ankurah_signals::{Peek, Subscribe};
215
216    // If immediate, call the callback with current state first
217    if immediate {
218        let current_items = livequery.peek();
219        let changes = current_items.into_iter().map(|item| crate::changes::ItemChange::Add { item, events: vec![] }).collect();
220        let initial_changeset = crate::changes::ChangeSet { resultset: livequery.resultset(), changes };
221        let wrapped = wrap_changeset(initial_changeset);
222        let _ = callback.call1(&wasm_bindgen::JsValue::NULL, &wrapped.into());
223    }
224
225    // Set up the subscription for future changes
226    let callback = ::send_wrapper::SendWrapper::new(callback);
227    livequery.subscribe(move |changeset: crate::changes::ChangeSet<V>| {
228        let wrapped_changeset = wrap_changeset(changeset);
229        let _ = callback.call1(&wasm_bindgen::JsValue::NULL, &wrapped_changeset.into());
230    })
231}