diff --git a/.changeset/zero-rust-define.md b/.changeset/zero-rust-define.md new file mode 100644 index 0000000000..6d050a3eee --- /dev/null +++ b/.changeset/zero-rust-define.md @@ -0,0 +1,5 @@ +--- +'zero-rust': minor +--- + +Add typed named queries and mutators behind a new `serde` feature, shaped like the Swift client's `defineQuery` and `defineMutator`. `define::define_query(name, |args, ctx| query)` and `define::define_mutator(name, |tx, args, ctx| ...)` take serde argument types, and `.with(args)` makes a `QueryRequest` for `materialize`, `run`, and `preload` (built with the `Zero`'s context) or a `client::MutateRequest` for the new `Zero::mutate_request`. `json::Value` implements serde, with `json::to_value` and `json::from_value`. `materialize`, `run`, and `preload` now take any `query::ZeroQuery` (an `AnyQuery`, a typed `Query`, or a `QueryRequest`), so existing calls keep working; the default build still has no third-party dependencies. diff --git a/apps/zbugs-gpui/Cargo.lock b/apps/zbugs-gpui/Cargo.lock index a10be6bfff..2f9008a416 100644 --- a/apps/zbugs-gpui/Cargo.lock +++ b/apps/zbugs-gpui/Cargo.lock @@ -8399,7 +8399,6 @@ version = "0.0.0" dependencies = [ "getrandom 0.4.3", "serde", - "serde_json", "zero-client", ] @@ -8592,6 +8591,7 @@ name = "zero-client" version = "0.1.3" dependencies = [ "rusqlite", + "serde", ] [[package]] diff --git a/apps/zbugs-gpui/Cargo.toml b/apps/zbugs-gpui/Cargo.toml index 4db0a4016d..ac83f1f9c5 100644 --- a/apps/zbugs-gpui/Cargo.toml +++ b/apps/zbugs-gpui/Cargo.toml @@ -43,7 +43,10 @@ tokio-tungstenite = { version = "0.30", default-features = false, features = [ ureq = { version = "3", default-features = false, features = ["rustls"] } zbugs-model = { path = "../zbugs-model" } # SQLite storage, so the app keeps its data across launches. -zero-client = { path = "../../rust/zero-client", features = ["sqlite"] } +zero-client = { path = "../../rust/zero-client", features = [ + "sqlite", + "serde", +] } [features] # The end-to-end run CI does against zbugs' backend: see src/app/e2e.rs. diff --git a/apps/zbugs-gpui/src/app.rs b/apps/zbugs-gpui/src/app.rs index a0b737988c..aeed8672b6 100644 --- a/apps/zbugs-gpui/src/app.rs +++ b/apps/zbugs-gpui/src/app.rs @@ -31,8 +31,8 @@ use std::rc::Rc; use zbugs_model::args::*; use zbugs_model::mutators::{ ADD_COMMENT, ADD_EMOJI_TO_COMMENT, ADD_EMOJI_TO_ISSUE, ADD_LABEL, CREATE_AND_ADD_LABEL, - CREATE_ISSUE, DELETE_ISSUE, DELETE_ISSUES, EDIT_COMMENT, MutateRequest, REMOVE_COMMENT, - REMOVE_EMOJI, REMOVE_LABEL, SET_USER_PREF, SET_VIEW_STATE, UPDATE_ISSUE, UPDATE_NOTIFICATION, + CREATE_ISSUE, DELETE_ISSUE, DELETE_ISSUES, EDIT_COMMENT, REMOVE_COMMENT, REMOVE_EMOJI, + REMOVE_LABEL, SET_USER_PREF, SET_VIEW_STATE, UPDATE_ISSUE, UPDATE_NOTIFICATION, }; use zbugs_model::queries::{ ALL_PROJECTS, COMMENTS_PAGE, ISSUE_DETAIL, ISSUE_LIST_COUNT, ISSUE_LIST_V2, LABELS, USER, @@ -40,7 +40,7 @@ use zbugs_model::queries::{ }; use zbugs_model::schema::{Comment, Emoji, Issue, IssueVisibility, Label, Project, User, UserPref}; use zbugs_model::{AuthData, ZERO_PROJECT_NAME, make_id}; -use zero_client::client::{ConnectionState, MutationOutcome}; +use zero_client::client::{ConnectionState, MutateRequest, MutationOutcome}; use zero_client::ttl::Ttl; /// Rows the list asks for at a time, as zbugs' list pages. @@ -468,16 +468,15 @@ impl ZbugsApp { }); let (list, count) = Self::queries(&session, &filters, limit, cx); - let auth = session.auth.as_ref(); - let projects = Live::new(&session.zero, ALL_PROJECTS.call(&(), auth), &ttl(), cx); + let projects = Live::new(&session.zero, ALL_PROJECTS.with(()), &ttl(), cx); let (creators, assignees, crew, labels) = Self::project_lives(&session, &filters.project, cx); let me = session .auth .as_ref() - .map(|a| Live::new(&session.zero, USER.call(&a.sub, Some(a)), &ttl(), cx)); - let skin_tone = session.auth.as_ref().map(|a| { - let q = USER_PREF.call(&SKIN_TONE_PREF.to_owned(), Some(a)); + .map(|a| Live::new(&session.zero, USER.with(a.sub.clone()), &ttl(), cx)); + let skin_tone = session.auth.as_ref().map(|_| { + let q = USER_PREF.with(SKIN_TONE_PREF.to_owned()); Live::new(&session.zero, q, &ttl(), cx) }); // A zero-cache address the client can't take, from the environment @@ -746,7 +745,7 @@ impl ZbugsApp { label_name: name, project_id: None, }; - self.mutate(CREATE_AND_ADD_LABEL.with(&args), cx); + self.mutate(CREATE_AND_ADD_LABEL.with(args), cx); true } @@ -883,7 +882,6 @@ impl ZbugsApp { /// The creators, assignees, crew, and labels of `project`. fn project_lives(session: &Session, project: &str, cx: &mut Context) -> ProjectLives { - let auth = session.auth.as_ref(); let mut users = |filter| { let args = UsersForProjectArgs { disabled: false, @@ -891,12 +889,7 @@ impl ZbugsApp { project_name: project.into(), filter: Some(filter), }; - Live::new( - &session.zero, - USERS_FOR_PROJECT.call(&args, auth), - &ttl(), - cx, - ) + Live::new(&session.zero, USERS_FOR_PROJECT.with(args), &ttl(), cx) }; let creators = users(UsersFilter::Creators); let assignees = users(UsersFilter::Assignees); @@ -904,7 +897,7 @@ impl ZbugsApp { let args = ProjectArgs { project_name: project.into(), }; - let labels = Live::new(&session.zero, LABELS.call(&args, auth), &ttl(), cx); + let labels = Live::new(&session.zero, LABELS.with(args), &ttl(), cx); (creators, assignees, crew, labels) } @@ -962,19 +955,15 @@ impl ZbugsApp { limit: i64, cx: &mut Context, ) -> (Live>, Live) { - let auth = session.auth.as_ref(); let list_context = Self::list_context(filters); - let list = ISSUE_LIST_V2.call( - &IssueListV2Args { - list_context: list_context.clone(), - limit: Some(limit), - start: None, - dir: PageDirection::Forward, - inclusive: None, - }, - auth, - ); - let count = ISSUE_LIST_COUNT.call(&IssueListCountArgs { list_context }, auth); + let list = ISSUE_LIST_V2.with(IssueListV2Args { + list_context: list_context.clone(), + limit: Some(limit), + start: None, + dir: PageDirection::Forward, + inclusive: None, + }); + let count = ISSUE_LIST_COUNT.with(IssueListCountArgs { list_context }); ( Live::new(&session.zero, list, &ttl(), cx), Live::new(&session.zero, count, &ttl(), cx), @@ -1034,13 +1023,10 @@ impl ZbugsApp { if self.selected.as_deref() == Some(id.as_str()) { return; } - let q = ISSUE_DETAIL.call( - &IssueDetailArgs { - id_field: IdField::Id, - id: IssueKey::String(id.clone()), - }, - self.session.auth.as_ref(), - ); + let q = ISSUE_DETAIL.with(IssueDetailArgs { + id_field: IdField::Id, + id: IssueKey::String(id.clone()), + }); self.detail = Some(Live::new(&self.session.zero, q, &ttl(), cx)); self.selected = Some(id); self.comments_limit = COMMENT_PAGE; @@ -1105,7 +1091,7 @@ impl ZbugsApp { dir: PageDirection::Backward, inclusive: false, }; - let q = COMMENTS_PAGE.call(&args, self.session.auth.as_ref()); + let q = COMMENTS_PAGE.with(args); self.comment_page = Some(Live::new(&self.session.zero, q, &ttl(), cx)); cx.notify(); } @@ -1113,7 +1099,7 @@ impl ZbugsApp { /// Runs a mutation and shows why it failed, on the client or the /// server. fn mutate(&mut self, request: MutateRequest, cx: &mut Context) { - let result = request.run(&self.session.zero); + let result = self.session.zero.mutate_request(request); if let Some(Ok(MutationOutcome::AppError { message, .. })) = result.client.result() { self.error = Some(message); cx.notify(); @@ -1203,12 +1189,12 @@ impl ZbugsApp { cx.notify(); return false; } - self.mutate(EDIT_COMMENT.with(&EditCommentArgs { id, body }), cx); + self.mutate(EDIT_COMMENT.with(EditCommentArgs { id, body }), cx); true } fn delete_comment(&mut self, id: &String, cx: &mut Context) { - self.mutate(REMOVE_COMMENT.with(id), cx); + self.mutate(REMOVE_COMMENT.with(id.to_owned()), cx); } /// Asks before a delete, which `delete` does. @@ -1252,7 +1238,7 @@ impl ZbugsApp { /// Deletes issue `id`, and leaves it if it's selected, as the web app /// goes back to the list. fn delete_issue(&mut self, id: &String, cx: &mut Context) { - self.mutate(DELETE_ISSUE.with(id), cx); + self.mutate(DELETE_ISSUE.with(id.to_owned()), cx); self.leave_deleted(std::slice::from_ref(id), cx); } @@ -1296,7 +1282,7 @@ impl ZbugsApp { return; }; let ids = checked.into_iter().collect::>(); - self.mutate(DELETE_ISSUES.with(&ids), cx); + self.mutate(DELETE_ISSUES.with(ids.clone()), cx); self.leave_deleted(&ids, cx); } @@ -1322,7 +1308,7 @@ impl ZbugsApp { }), ..UpdateIssueArgs::new(issue_id, now_ms()) }; - self.mutate(UPDATE_ISSUE.with(&args), cx); + self.mutate(UPDATE_ISSUE.with(args), cx); } fn set_subscribed(&mut self, issue_id: String, subscribe: bool, cx: &mut Context) { @@ -1335,7 +1321,7 @@ impl ZbugsApp { }, created: now_ms(), }; - self.mutate(UPDATE_NOTIFICATION.with(&args), cx); + self.mutate(UPDATE_NOTIFICATION.with(args), cx); } /// Tells the user when someone else comments on or reacts to the @@ -1466,7 +1452,7 @@ impl ZbugsApp { issue_id: id, viewed: now_ms().max(modified), }; - this.mutate(SET_VIEW_STATE.with(&args), cx); + this.mutate(SET_VIEW_STATE.with(args), cx); } this.mark_viewed(cx); }); @@ -1479,7 +1465,7 @@ impl ZbugsApp { open: Some(!issue.open), ..UpdateIssueArgs::new(issue.id.clone(), now_ms()) }; - self.mutate(UPDATE_ISSUE.with(&args), cx); + self.mutate(UPDATE_ISSUE.with(args), cx); } fn set_assignee(&mut self, issue_id: String, user_id: Option, cx: &mut Context) { @@ -1487,7 +1473,7 @@ impl ZbugsApp { assignee_id: Some(user_id), ..UpdateIssueArgs::new(issue_id, now_ms()) }; - self.mutate(UPDATE_ISSUE.with(&args), cx); + self.mutate(UPDATE_ISSUE.with(args), cx); } /// Adds the label to the issue, or takes it off when it has it. @@ -1495,9 +1481,9 @@ impl ZbugsApp { let has = issue.labels.iter().flatten().any(|l| l.id == label_id); let issue_id = issue.id.clone(); let request = if has { - REMOVE_LABEL.with(&RemoveLabelArgs { issue_id, label_id }) + REMOVE_LABEL.with(RemoveLabelArgs { issue_id, label_id }) } else { - ADD_LABEL.with(&IssueLabelArgs { + ADD_LABEL.with(IssueLabelArgs { issue_id, label_id, project_id: None, @@ -1522,7 +1508,7 @@ impl ZbugsApp { key: SKIN_TONE_PREF.to_owned(), value: tone.to_string(), }; - self.mutate(SET_USER_PREF.with(&args), cx); + self.mutate(SET_USER_PREF.with(args), cx); } /// Takes back the user's reaction with `unicode`, or adds it. @@ -1543,7 +1529,7 @@ impl ZbugsApp { .find(|e| e.creator_id == me && normalize_emoji(&e.value) == key) { let id = mine.id.clone(); - self.mutate(REMOVE_EMOJI.with(&id), cx); + self.mutate(REMOVE_EMOJI.with(id), cx); return; } let annotation = REACTIONS @@ -1569,7 +1555,7 @@ impl ZbugsApp { Subject::Issue => ADD_EMOJI_TO_ISSUE, Subject::Comment => ADD_EMOJI_TO_COMMENT, }; - self.mutate(add.with(&args), cx); + self.mutate(add.with(args), cx); } /// Opens the picker with every emoji, to react with any of them. @@ -1623,7 +1609,7 @@ impl ZbugsApp { body, created: now_ms(), }; - self.mutate(ADD_COMMENT.with(&args), cx); + self.mutate(ADD_COMMENT.with(args), cx); self.comment .update(cx, |state, cx| state.set_value("", window, cx)); } @@ -1735,7 +1721,7 @@ impl ZbugsApp { description: Some(description), ..UpdateIssueArgs::new(id, now) }; - self.mutate(UPDATE_ISSUE.with(&args), cx); + self.mutate(UPDATE_ISSUE.with(args), cx); } None => { let Some(project_id) = self.project_id() else { @@ -1755,7 +1741,7 @@ impl ZbugsApp { modified: now, project_id, }; - self.mutate(CREATE_ISSUE.with(&args), cx); + self.mutate(CREATE_ISSUE.with(args), cx); self.select(id, cx); } } diff --git a/apps/zbugs-gpui/src/live.rs b/apps/zbugs-gpui/src/live.rs index 4a1b6ed6da..46710b20ae 100644 --- a/apps/zbugs-gpui/src/live.rs +++ b/apps/zbugs-gpui/src/live.rs @@ -12,9 +12,10 @@ use std::cell::RefCell; use std::rc::Rc; use zbugs_model::Context as Auth; use zero_client::client::{MaterializedView, Zero}; +use zero_client::define::QueryRequest; use zero_client::ivm::ResultType; use zero_client::ttl::Ttl; -use zero_client::typed::{Query, QueryValue}; +use zero_client::typed::QueryValue; pub struct Live { value: Rc>>, @@ -34,7 +35,7 @@ impl Live { /// rows change, until dropped. pub fn new( zero: &Zero, - query: Query, + query: QueryRequest, ttl: &Ttl, cx: &mut Context, ) -> Live { @@ -45,7 +46,7 @@ impl Live { })); let (wake, mut woken) = mpsc::unbounded::<()>(); let view = zero - .materialize(query.any(), ttl) + .materialize(&query, ttl) .expect("a query of the app's schema materializes"); let v = value.clone(); let remove = view.add_listener(move |data, result_type, _| { diff --git a/apps/zbugs-gpui/src/session.rs b/apps/zbugs-gpui/src/session.rs index 6d3639b9b2..8e8ef8dc8b 100644 --- a/apps/zbugs-gpui/src/session.rs +++ b/apps/zbugs-gpui/src/session.rs @@ -130,12 +130,11 @@ impl Session { let project = ProjectArgs { project_name: ZERO_PROJECT_NAME.into(), }; - let a = auth.as_ref(); let preloads = vec![ - zero.preload(ISSUE_PRELOAD_V2.call(&project, a).any(), &ttl), - zero.preload(ALL_USERS.call(&(), a).any(), &ttl), - zero.preload(ALL_LABELS.call(&(), a).any(), &ttl), - zero.preload(ALL_PROJECTS.call(&(), a).any(), &ttl), + zero.preload(&ISSUE_PRELOAD_V2.with(project), &ttl), + zero.preload(&ALL_USERS.with(()), &ttl), + zero.preload(&ALL_LABELS.with(()), &ttl), + zero.preload(&ALL_PROJECTS.with(()), &ttl), ]; Session { zero: Rc::new(zero), diff --git a/apps/zbugs-model/Cargo.lock b/apps/zbugs-model/Cargo.lock index 6ad7049832..63604b5c1d 100644 --- a/apps/zbugs-model/Cargo.lock +++ b/apps/zbugs-model/Cargo.lock @@ -19,24 +19,12 @@ dependencies = [ "r-efi", ] -[[package]] -name = "itoa" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" - [[package]] name = "libc" version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - [[package]] name = "proc-macro2" version = "1.0.107" @@ -91,19 +79,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_json" -version = "1.0.154" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - [[package]] name = "syn" version = "3.0.10" @@ -127,16 +102,12 @@ version = "0.0.0" dependencies = [ "getrandom", "serde", - "serde_json", "zero-client", ] [[package]] name = "zero-client" version = "0.1.3" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +dependencies = [ + "serde", +] diff --git a/apps/zbugs-model/Cargo.toml b/apps/zbugs-model/Cargo.toml index acd09f8ab9..3b88f15e11 100644 --- a/apps/zbugs-model/Cargo.toml +++ b/apps/zbugs-model/Cargo.toml @@ -13,10 +13,9 @@ publish = false [dependencies] # The default build: no transport and no storage; the apps choose theirs. -zero-client = { path = "../../rust/zero-client" } +zero-client = { path = "../../rust/zero-client", features = ["serde"] } getrandom = "0.4" serde = { version = "1", features = ["derive"] } -serde_json = "1" [lints.rust] unsafe_code = "forbid" diff --git a/apps/zbugs-model/README.md b/apps/zbugs-model/README.md index 150263e9c7..961a4781fa 100644 --- a/apps/zbugs-model/README.md +++ b/apps/zbugs-model/README.md @@ -17,11 +17,13 @@ and depends on this crate by path. use zbugs_model::args::{IssueListV2Args, ListContextParams, PageDirection}; use zbugs_model::{mutators, queries}; -let list = queries::ISSUE_LIST_V2.call(&args, auth.as_ref()); // a typed Query, named -let view = zero.materialize(list.any(), &ttl)?; +// `define_query` and `define_mutator` definitions; the client's context +// (the logged-in user) comes from `zero`. +let list = queries::ISSUE_LIST_V2.with(args); +let view = zero.materialize(&list, &ttl)?; let issues = list.read(&view.data())?; -mutators::CREATE_ISSUE.with(&create_args).run(&zero); +zero.mutate_request(mutators::CREATE_ISSUE.with(create_args)); // Zero::new(schema::schema(), auth, zbugs_model::client_mutators(), …) ``` diff --git a/apps/zbugs-model/src/args.rs b/apps/zbugs-model/src/args.rs index ed5bcb55f2..116ad9d011 100644 --- a/apps/zbugs-model/src/args.rs +++ b/apps/zbugs-model/src/args.rs @@ -6,56 +6,10 @@ //! what the TS side was given. use crate::schema::IssueVisibility; -use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use zero_client::json::{Object, Value}; +use zero_client::json::Value; use zero_client::typed::{Direction, ZeroValue}; -/// `value` as JSON. -pub fn to_value(value: &T) -> Value { - from_serde(serde_json::to_value(value).expect("arguments encode as JSON")) -} - -/// `value` read as `T`. -pub fn from_value(value: &Value) -> Result { - serde_json::from_value(to_serde(value)).map_err(|e| e.to_string()) -} - -fn from_serde(value: serde_json::Value) -> Value { - match value { - serde_json::Value::Null => Value::Null, - serde_json::Value::Bool(b) => Value::from(b), - serde_json::Value::Number(n) => Value::from(n.as_f64().unwrap_or(f64::NAN)), - serde_json::Value::String(s) => Value::from(s), - serde_json::Value::Array(a) => Value::Array(a.into_iter().map(from_serde).collect()), - serde_json::Value::Object(o) => Value::Object( - o.into_iter() - .map(|(k, v)| (k, from_serde(v))) - .collect::(), - ), - } -} - -fn to_serde(value: &Value) -> serde_json::Value { - match value { - Value::Null => serde_json::Value::Null, - Value::Bool(b) => serde_json::Value::Bool(*b), - Value::Number(n) => { - // Integers as integers, so they read as `i64` fields. - if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 { - serde_json::Value::from(*n as i64) - } else { - serde_json::Number::from_f64(*n).map_or(serde_json::Value::Null, Into::into) - } - } - Value::String(s) => serde_json::Value::String(s.clone()), - Value::Array(a) => serde_json::Value::Array(a.iter().map(to_serde).collect()), - Value::Object(o) => { - serde_json::Value::Object(o.iter().map(|(k, v)| (k.clone(), to_serde(v))).collect()) - } - } -} - /// `'asc' | 'desc'`. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/apps/zbugs-model/src/auth.rs b/apps/zbugs-model/src/auth.rs index 5008e2be12..d74ee5e6fd 100644 --- a/apps/zbugs-model/src/auth.rs +++ b/apps/zbugs-model/src/auth.rs @@ -107,8 +107,8 @@ pub(crate) fn must(value: Option) -> Result { value.ok_or_else(|| ApplicationError::new("Unexpected undefined value").into()) } -pub(crate) fn assert_is_logged_in(auth: Option<&AuthData>) -> Result<&AuthData, Error> { - auth.ok_or_else(|| { +pub(crate) fn assert_is_logged_in(auth: &Option) -> Result<&AuthData, Error> { + auth.as_ref().ok_or_else(|| { mutation_error( "User must be logged in for this operation", MutationErrorCode::NotLoggedIn, @@ -117,7 +117,7 @@ pub(crate) fn assert_is_logged_in(auth: Option<&AuthData>) -> Result<&AuthData, }) } -pub(crate) fn is_admin(auth: Option<&AuthData>) -> Result { +pub(crate) fn is_admin(auth: &Option) -> Result { Ok(assert_is_logged_in(auth)?.is_crew()) } @@ -157,7 +157,7 @@ impl Created for Emoji { pub(crate) fn assert_is_creator_or_admin( tx: &Transaction, - auth: Option<&AuthData>, + auth: &Option, query: Query, id: &str, ) -> Result<(), Error> { diff --git a/apps/zbugs-model/src/lib.rs b/apps/zbugs-model/src/lib.rs index 8e10eb09a4..274eb9b3fa 100644 --- a/apps/zbugs-model/src/lib.rs +++ b/apps/zbugs-model/src/lib.rs @@ -15,7 +15,7 @@ pub mod queries; pub mod schema; pub use auth::{AuthData, make_id}; -pub use mutators::{Context, MutateRequest, client_mutators}; +pub use mutators::{Context, client_mutators}; pub use queries::ZERO_PROJECT_NAME; /// The zero-cache address as the client takes it. zbugs' backend prints it diff --git a/apps/zbugs-model/src/mutators.rs b/apps/zbugs-model/src/mutators.rs index 4742a4bac4..4f946422c8 100644 --- a/apps/zbugs-model/src/mutators.rs +++ b/apps/zbugs-model/src/mutators.rs @@ -8,90 +8,15 @@ use crate::schema::{ Comment, CommentUpdate, Emoji, EmojiKey, Issue, IssueKey, IssueLabel, IssueLabelKey, IssueNotifications, IssueUpdate, IssueVisibility, Label, UserPref, ViewState, mutate, zql, }; -use serde::Serialize; -use serde::de::DeserializeOwned; -use zero_client::client::{Error, Mutator, MutatorResult, Transaction, Zero}; -use zero_client::json::Value; +use zero_client::client::{Error, Mutator, Transaction}; +use zero_client::define::{MutatorDefinition, NamedMutator, define_mutator}; use zero_client::typed::{ColumnUpdate, RelationshipAggregates}; /// What a mutator is given besides its arguments: who is logged in. pub type Context = Option; -type Body = fn(&Transaction, A, Option<&AuthData>) -> Result<(), Error>; - -/// A mutator with typed arguments (`defineMutator`). -pub struct MutatorDef { - pub name: &'static str, - body: Body, -} - -/// A mutation to run: the mutator's name and its arguments as JSON, for -/// `Zero::mutate`. -#[derive(Clone, Debug, PartialEq)] -pub struct MutateRequest { - pub name: &'static str, - pub args: Value, -} - -impl MutateRequest { - /// Runs the mutation on `zero`: optimistically now, then on the server. - pub fn run(self, zero: &Zero) -> MutatorResult { - zero.mutate(self.name, Some(self.args)) - } -} - -impl MutatorDef { - /// The mutation of this mutator with `args`. - pub fn with(&self, args: &A) -> MutateRequest { - MutateRequest { - name: self.name, - args: to_value(args), - } - } -} - -impl MutatorDef { - /// The client's mutator: reads the JSON arguments as `A`. - pub fn mutator(&self) -> Mutator { - let body = self.body; - Mutator::new(self.name, move |tx, args, auth: &Context| { - let args: A = from_value(args.unwrap_or(&Value::Null)) - .map_err(zero_client::client::ApplicationError::new)?; - body(tx, args, auth.as_ref())?; - Ok(None) - }) - } - - /// `args` read as `A` and written back, for the drift test. - pub fn reencode(&self, args: Option<&Value>) -> Result { - let a: A = from_value(args.unwrap_or(&Value::Null))?; - Ok(to_value(&a)) - } -} - -/// Reads JSON arguments and writes them back. -pub type Reencode = Box) -> Result>; - -/// A mutator of every definition, for the client and the drift test. -pub struct AnyMutator { - pub name: &'static str, - pub mutator: Mutator, - pub reencode: Reencode, -} - -fn erase(def: MutatorDef) -> AnyMutator { - let mutator = def.mutator(); - let name = def.name; - AnyMutator { - name, - mutator, - reencode: Box::new(move |args| def.reencode(args)), - } -} - -pub const CREATE_ISSUE: MutatorDef = MutatorDef { - name: "issue.create", - body: |tx, args, auth| { +pub const CREATE_ISSUE: MutatorDefinition = + define_mutator("issue.create", |tx, args, auth| { let auth = assert_is_logged_in(auth)?; mutate::issue(tx).insert(&Issue { id: args.id.clone(), @@ -125,12 +50,10 @@ pub const CREATE_ISSUE: MutatorDef = MutatorDef { args.created, false, ) - }, -}; + }); -pub const UPDATE_ISSUE: MutatorDef = MutatorDef { - name: "issue.update", - body: |tx, change, auth| { +pub const UPDATE_ISSUE: MutatorDefinition = + define_mutator("issue.update", |tx, change, auth| { // Authorization comes first, so the error does not tell whether a // private issue exists. assert_is_creator_or_admin(tx, auth, zql::issue(), &change.id)?; @@ -189,22 +112,18 @@ pub const UPDATE_ISSUE: MutatorDef = MutatorDef { )?; } Ok(()) - }, -}; + }); -pub const DELETE_ISSUE: MutatorDef = MutatorDef { - name: "issue.delete", - body: |tx, id, auth| { +pub const DELETE_ISSUE: MutatorDefinition = + define_mutator("issue.delete", |tx, id, auth| { assert_is_creator_or_admin(tx, auth, zql::issue(), &id)?; mutate::issue(tx).delete(&IssueKey { id }) - }, -}; + }); /// Deletes the issues if the user created every one of them (or is an /// admin); otherwise none. -pub const DELETE_ISSUES: MutatorDef> = MutatorDef { - name: "issue.deleteMany", - body: |tx, ids, auth| { +pub const DELETE_ISSUES: MutatorDefinition, Context> = + define_mutator("issue.deleteMany", |tx, ids, auth| { assert_is_logged_in(auth)?; for id in &ids { assert_is_creator_or_admin(tx, auth, zql::issue(), id)?; @@ -213,32 +132,26 @@ pub const DELETE_ISSUES: MutatorDef> = MutatorDef { mutate::issue(tx).delete(&IssueKey { id })?; } Ok(()) - }, -}; + }); -pub const ADD_LABEL: MutatorDef = MutatorDef { - name: "issue.addLabel", - body: |tx, args, auth| { +pub const ADD_LABEL: MutatorDefinition = + define_mutator("issue.addLabel", |tx, args, auth| { assert_is_creator_or_admin(tx, auth, zql::issue(), &args.issue_id)?; let project_id = issue_project_id(tx, &args.issue_id)?; mutate::issue_label(tx).insert(&issue_label(args.issue_id, args.label_id, project_id)) - }, -}; + }); -pub const REMOVE_LABEL: MutatorDef = MutatorDef { - name: "issue.removeLabel", - body: |tx, args, auth| { +pub const REMOVE_LABEL: MutatorDefinition = + define_mutator("issue.removeLabel", |tx, args, auth| { assert_is_creator_or_admin(tx, auth, zql::issue(), &args.issue_id)?; mutate::issue_label(tx).delete(&IssueLabelKey { label_id: args.label_id, issue_id: args.issue_id, }) - }, -}; + }); -pub const UPDATE_NOTIFICATION: MutatorDef = MutatorDef { - name: "notification.update", - body: |tx, args, auth| { +pub const UPDATE_NOTIFICATION: MutatorDefinition = + define_mutator("notification.update", |tx, args, auth| { let auth = assert_is_logged_in(auth)?; update_issue_notification( tx, @@ -248,30 +161,26 @@ pub const UPDATE_NOTIFICATION: MutatorDef = MutatorDef { args.created, true, ) - }, -}; + }); -pub const ADD_EMOJI_TO_ISSUE: MutatorDef = MutatorDef { - name: "emoji.addToIssue", - body: |tx, args, auth| add_emoji(tx, EmojiSubject::Issue, args, auth), -}; +pub const ADD_EMOJI_TO_ISSUE: MutatorDefinition = + define_mutator("emoji.addToIssue", |tx, args, auth| { + add_emoji(tx, EmojiSubject::Issue, args, auth) + }); -pub const ADD_EMOJI_TO_COMMENT: MutatorDef = MutatorDef { - name: "emoji.addToComment", - body: |tx, args, auth| add_emoji(tx, EmojiSubject::Comment, args, auth), -}; +pub const ADD_EMOJI_TO_COMMENT: MutatorDefinition = + define_mutator("emoji.addToComment", |tx, args, auth| { + add_emoji(tx, EmojiSubject::Comment, args, auth) + }); -pub const REMOVE_EMOJI: MutatorDef = MutatorDef { - name: "emoji.remove", - body: |tx, id, auth| { +pub const REMOVE_EMOJI: MutatorDefinition = + define_mutator("emoji.remove", |tx, id, auth| { assert_is_creator_or_admin(tx, auth, zql::emoji(), &id)?; mutate::emoji(tx).delete(&EmojiKey { id }) - }, -}; + }); -pub const ADD_COMMENT: MutatorDef = MutatorDef { - name: "comment.add", - body: |tx, args, auth| { +pub const ADD_COMMENT: MutatorDefinition = + define_mutator("comment.add", |tx, args, auth| { let creator_id = assert_is_logged_in(auth)?.sub.clone(); assert_user_can_see_issue(tx, &creator_id, &args.issue_id)?; mutate::comment(tx).insert(&Comment { @@ -293,31 +202,25 @@ pub const ADD_COMMENT: MutatorDef = MutatorDef { args.created, false, ) - }, -}; + }); -pub const EDIT_COMMENT: MutatorDef = MutatorDef { - name: "comment.edit", - body: |tx, args, auth| { +pub const EDIT_COMMENT: MutatorDefinition = + define_mutator("comment.edit", |tx, args, auth| { assert_is_creator_or_admin(tx, auth, zql::comment(), &args.id)?; mutate::comment(tx).update(&CommentUpdate { body: Some(args.body), ..CommentUpdate::new(args.id) }) - }, -}; + }); -pub const REMOVE_COMMENT: MutatorDef = MutatorDef { - name: "comment.remove", - body: |tx, id, auth| { +pub const REMOVE_COMMENT: MutatorDefinition = + define_mutator("comment.remove", |tx, id, auth| { assert_is_creator_or_admin(tx, auth, zql::comment(), &id)?; mutate::comment(tx).delete(&crate::schema::CommentKey { id }) - }, -}; + }); -pub const CREATE_LABEL: MutatorDef = MutatorDef { - name: "label.create", - body: |tx, args, auth| { +pub const CREATE_LABEL: MutatorDefinition = + define_mutator("label.create", |tx, args, auth| { if !is_admin(auth)? { return Err(mutation_error( "Only admins can create labels", @@ -326,12 +229,10 @@ pub const CREATE_LABEL: MutatorDef = MutatorDef { )); } mutate::label(tx).insert(&label(args.id, args.name, args.project_id)) - }, -}; + }); -pub const CREATE_AND_ADD_LABEL: MutatorDef = MutatorDef { - name: "label.createAndAddToIssue", - body: |tx, args, auth| { +pub const CREATE_AND_ADD_LABEL: MutatorDefinition = + define_mutator("label.createAndAddToIssue", |tx, args, auth| { if !is_admin(auth)? { return Err(mutation_error( "Only admins can create labels", @@ -346,59 +247,54 @@ pub const CREATE_AND_ADD_LABEL: MutatorDef = MutatorDef { project_id.clone(), ))?; mutate::issue_label(tx).insert(&issue_label(args.issue_id, args.label_id, project_id)) - }, -}; + }); -pub const SET_VIEW_STATE: MutatorDef = MutatorDef { - name: "viewState.set", - body: |tx, args, auth| { +pub const SET_VIEW_STATE: MutatorDefinition = + define_mutator("viewState.set", |tx, args, auth| { let user_id = assert_is_logged_in(auth)?.sub.clone(); mutate::view_state(tx).upsert(&ViewState { issue_id: args.issue_id, user_id, viewed: args.viewed, }) - }, -}; + }); -pub const SET_USER_PREF: MutatorDef = MutatorDef { - name: "userPref.set", - body: |tx, args, auth| { +pub const SET_USER_PREF: MutatorDefinition = + define_mutator("userPref.set", |tx, args, auth| { let user_id = assert_is_logged_in(auth)?.sub.clone(); mutate::user_pref(tx).upsert(&UserPref { key: args.key, user_id, value: args.value, }) - }, -}; + }); /// Every mutator, for the client and the drift test. -pub fn all() -> Vec { +pub fn all() -> Vec>> { vec![ - erase(CREATE_ISSUE), - erase(UPDATE_ISSUE), - erase(DELETE_ISSUE), - erase(DELETE_ISSUES), - erase(ADD_LABEL), - erase(REMOVE_LABEL), - erase(UPDATE_NOTIFICATION), - erase(ADD_EMOJI_TO_ISSUE), - erase(ADD_EMOJI_TO_COMMENT), - erase(REMOVE_EMOJI), - erase(ADD_COMMENT), - erase(EDIT_COMMENT), - erase(REMOVE_COMMENT), - erase(CREATE_LABEL), - erase(CREATE_AND_ADD_LABEL), - erase(SET_VIEW_STATE), - erase(SET_USER_PREF), + Box::new(CREATE_ISSUE), + Box::new(UPDATE_ISSUE), + Box::new(DELETE_ISSUE), + Box::new(DELETE_ISSUES), + Box::new(ADD_LABEL), + Box::new(REMOVE_LABEL), + Box::new(UPDATE_NOTIFICATION), + Box::new(ADD_EMOJI_TO_ISSUE), + Box::new(ADD_EMOJI_TO_COMMENT), + Box::new(REMOVE_EMOJI), + Box::new(ADD_COMMENT), + Box::new(EDIT_COMMENT), + Box::new(REMOVE_COMMENT), + Box::new(CREATE_LABEL), + Box::new(CREATE_AND_ADD_LABEL), + Box::new(SET_VIEW_STATE), + Box::new(SET_USER_PREF), ] } /// The client's mutators: every one, so pending mutations replay by name. pub fn client_mutators() -> Vec> { - all().into_iter().map(|m| m.mutator).collect() + all().iter().map(|m| m.mutator()).collect() } fn label(id: String, name: String, project_id: String) -> Label { @@ -447,7 +343,7 @@ fn add_emoji( tx: &Transaction, subject: EmojiSubject, args: AddEmojiArgs, - auth: Option<&AuthData>, + auth: &Context, ) -> Result<(), Error> { let creator_id = assert_is_logged_in(auth)?.sub.clone(); match subject { diff --git a/apps/zbugs-model/src/queries.rs b/apps/zbugs-model/src/queries.rs index 39aab55e2c..1a080b72ef 100644 --- a/apps/zbugs-model/src/queries.rs +++ b/apps/zbugs-model/src/queries.rs @@ -1,85 +1,22 @@ //! The queries of `apps/zbugs/shared/queries.ts`. The drift test checks //! that each builds the same AST as its TS definition. //! -//! Each is a [`NamedQuery`]: the client builds its AST for local results -//! and sends its name and arguments, which zero-cache asks the API server -//! to run with the TS definition. +//! Each is a [`QueryDefinition`]: the client builds its AST for local +//! results and sends its name and arguments, which zero-cache asks the API +//! server to run with the TS definition. use crate::args::*; -use crate::auth::AuthData; +use crate::mutators::Context; use crate::schema::{ Comment, Emoji, Issue, IssueLabel, Label, Project, User, UserPref, UserRole, ViewState, zql, }; +use zero_client::define::{NamedQuery, QueryDefinition, define_query}; use zero_client::json::Value; use zero_client::like::escape_like; use zero_client::typed::{Condition, Direction, ExistsOptions, Query, SimpleOperator, ZeroRow}; pub const ZERO_PROJECT_NAME: &str = "Zero"; -/// A query the server runs by name (`defineQuery`): `A` is its argument, -/// `()` for none. -pub struct NamedQuery> { - pub name: &'static str, - build: fn(&A, Option<&AuthData>) -> Query, - encode: fn(&A) -> Vec, -} - -impl NamedQuery { - /// The query for `args`, as the logged-in user (`auth`) sees it. - pub fn call(&self, args: &A, auth: Option<&AuthData>) -> Query { - let q = (self.build)(args, auth); - Query::from_any(q.into_any().name_and_args(self.name, (self.encode)(args))) - } -} - -fn no_args(_: &()) -> Vec { - Vec::new() -} - -fn one_arg(args: &A) -> Vec { - vec![to_value(args)] -} - -/// Builds a query from JSON arguments, with the arguments as encoded. -pub type BuildAny = - Box) -> Result<(Query<()>, Vec), String>>; - -/// A query of every query's definition, for the drift test: from the JSON -/// arguments, the query and the arguments as this side encodes them. -pub struct AnyNamedQuery { - pub name: &'static str, - pub build: BuildAny, -} - -fn erase( - q: NamedQuery, - decode: fn(&[Value]) -> Result, -) -> AnyNamedQuery { - AnyNamedQuery { - name: q.name, - build: Box::new(move |args, auth| { - let a = decode(args)?; - let built = q.call(&a, auth); - Ok((Query::from_any(built.into_any()), (q.encode)(&a))) - }), - } -} - -fn decode_none(args: &[Value]) -> Result<(), String> { - if args.is_empty() { - Ok(()) - } else { - Err(format!("expected no arguments, got {}", args.len())) - } -} - -fn decode_one(args: &[Value]) -> Result { - match args { - [a] => from_value(a), - _ => Err(format!("expected one argument, got {}", args.len())), - } -} - /// The string when JS would take it as true: neither `None` nor empty. fn truthy(s: Option<&str>) -> Option<&str> { s.filter(|s| !s.is_empty()) @@ -142,18 +79,18 @@ fn labels_query(project_name: &str, order_by: Option) -> Query) -> Option { - auth.map(|a| a.role) +fn role(auth: &Context) -> Option { + auth.as_ref().map(|a| a.role) } /// The user's own rows of a per-user relationship, or none when logged /// out. fn own( q: Query, - auth: Option<&AuthData>, + auth: &Context, user_id: zero_client::typed::Column, ) -> Query { - match truthy(auth.map(|a| a.sub.as_str())) { + match truthy(auth.as_ref().map(|a| a.sub.as_str())) { Some(sub) => q.where_eq(user_id, sub.to_owned()), None => always_false(q), } @@ -173,56 +110,38 @@ fn crew(q: Query) -> Query { }) } -pub const ALL_LABELS: NamedQuery<(), Label> = NamedQuery { - name: "allLabels", - build: |_, _| zql::label(), - encode: no_args, -}; +pub const ALL_LABELS: QueryDefinition<(), Context, Label> = + define_query("allLabels", |_, _| zql::label()); -pub const ALL_USERS: NamedQuery<(), User> = NamedQuery { - name: "allUsers", - build: |_, _| zql::user(), - encode: no_args, -}; +pub const ALL_USERS: QueryDefinition<(), Context, User> = + define_query("allUsers", |_, _| zql::user()); -pub const ALL_PROJECTS: NamedQuery<(), Project> = NamedQuery { - name: "allProjects", - build: |_, _| zql::project(), - encode: no_args, -}; +pub const ALL_PROJECTS: QueryDefinition<(), Context, Project> = + define_query("allProjects", |_, _| zql::project()); -pub const USER: NamedQuery> = NamedQuery { - name: "user", - build: |id, _| zql::user().where_eq(User::ID, id.clone()).one(), - encode: one_arg, -}; +pub const USER: QueryDefinition> = + define_query("user", |id, _| { + zql::user().where_eq(User::ID, id.clone()).one() + }); -pub const CREW_USER: NamedQuery> = NamedQuery { - name: "crewUser", - build: |id, _| { +pub const CREW_USER: QueryDefinition> = + define_query("crewUser", |id, _| { zql::user() .where_eq(User::ID, id.clone()) .where_eq(User::ROLE, UserRole::Crew) .one() - }, - encode: one_arg, -}; + }); -pub const LABELS: NamedQuery = NamedQuery { - name: "labels", - build: |a, _| labels_query(&a.project_name, None), - encode: one_arg, -}; +pub const LABELS: QueryDefinition = + define_query("labels", |a, _| labels_query(&a.project_name, None)); -pub const LABELS_ORDER_BY_NAME: NamedQuery = NamedQuery { - name: "labelsOrderByName", - build: |a, _| labels_query(&a.project_name, Some(a.order_by)), - encode: one_arg, -}; +pub const LABELS_ORDER_BY_NAME: QueryDefinition = + define_query("labelsOrderByName", |a, _| { + labels_query(&a.project_name, Some(a.order_by)) + }); -pub const ISSUE_PRELOAD_V2: NamedQuery = NamedQuery { - name: "issuePreloadV2", - build: |a, auth| { +pub const ISSUE_PRELOAD_V2: QueryDefinition = + define_query("issuePreloadV2", |a, auth| { apply_issue_permissions( in_project(zql::issue(), Issue::PROJECT, &a.project_name) .related(Issue::LABELS, |l| l) @@ -241,28 +160,24 @@ pub const ISSUE_PRELOAD_V2: NamedQuery = NamedQuery { .limit(1000), role(auth), ) - }, - encode: one_arg, -}; + }); -pub const USER_PREF: NamedQuery> = NamedQuery { - name: "userPref", - build: |key, auth| { +pub const USER_PREF: QueryDefinition> = + define_query("userPref", |key, auth| { // `where('userID', auth?.sub)`: `= null` when logged out. - let sub = auth.map_or(Value::Null, |a| Value::from(a.sub.as_str())); + let sub = auth + .as_ref() + .map_or(Value::Null, |a| Value::from(a.sub.as_str())); where_named( zql::user_pref().where_eq(UserPref::KEY, key.clone()), "userID", sub, ) .one() - }, - encode: one_arg, -}; + }); -pub const USERS_FOR_PROJECT: NamedQuery = NamedQuery { - name: "usersForProject", - build: |a, _| { +pub const USERS_FOR_PROJECT: QueryDefinition = + define_query("usersForProject", |a, _| { let q = zql::user(); if let (true, Some(login)) = (a.disabled, truthy(a.login.as_deref())) { return q.where_eq(User::LOGIN, login.to_owned()); @@ -282,15 +197,12 @@ pub const USERS_FOR_PROJECT: NamedQuery = NamedQuery } None => q, } - }, - encode: one_arg, -}; + }); /// A page of an issue's comments, ordered by (created, id); a backward page /// flips the order. `start` is the cursor row. -pub const COMMENTS_PAGE: NamedQuery = NamedQuery { - name: "commentsPage", - build: |a, auth| { +pub const COMMENTS_PAGE: QueryDefinition = + define_query("commentsPage", |a, auth| { let dir = match a.dir { PageDirection::Forward => Direction::Asc, PageDirection::Backward => Direction::Desc, @@ -314,13 +226,10 @@ pub const COMMENTS_PAGE: NamedQuery = NamedQuery { ); } q.limit(a.limit) - }, - encode: one_arg, -}; + }); -pub const COMMENT: NamedQuery> = NamedQuery { - name: "comment", - build: |id, auth| { +pub const COMMENT: QueryDefinition> = + define_query("comment", |id, auth| { apply_comment_permissions( zql::comment() .related(Comment::CREATOR, |u| u) @@ -329,13 +238,10 @@ pub const COMMENT: NamedQuery> = NamedQuery { role(auth), ) .one() - }, - encode: one_arg, -}; + }); -pub const ISSUE_DETAIL: NamedQuery> = NamedQuery { - name: "issueDetail", - build: |a, auth| { +pub const ISSUE_DETAIL: QueryDefinition> = + define_query("issueDetail", |a, auth| { apply_issue_permissions( where_named(zql::issue(), a.id_field.column(), a.id.json()) .related(Issue::PROJECT, |p| p) @@ -361,51 +267,41 @@ pub const ISSUE_DETAIL: NamedQuery> = Name .one(), role(auth), ) - }, - encode: one_arg, -}; + }); -pub const ISSUE_LIST_V2: NamedQuery = NamedQuery { - name: "issueListV2", - build: |a, auth| { +pub const ISSUE_LIST_V2: QueryDefinition = + define_query("issueListV2", |a, auth| { build_list_query(ListQueryArgs { list_context: Some(&a.list_context), - user_id: auth.map(|a| a.sub.as_str()), + user_id: auth.as_ref().map(|a| a.sub.as_str()), role: role(auth), limit: a.limit, start: a.start.as_ref(), dir: a.dir, inclusive: a.inclusive.unwrap_or(false), }) - }, - encode: one_arg, -}; + }); /// The number of issues in the list: the same filters and permissions as /// `issueListV2`, reduced to a `count()`. -pub const ISSUE_LIST_COUNT: NamedQuery = NamedQuery { - name: "issueListCount", - build: |a, auth| filter_list_query(zql::issue(), &a.list_context, role(auth)).count(), - encode: one_arg, -}; +pub const ISSUE_LIST_COUNT: QueryDefinition = + define_query("issueListCount", |a, auth| { + filter_list_query(zql::issue(), &a.list_context, role(auth)).count() + }); /// One issue, for the order to start a list at. -pub const LIST_ISSUE_BY_ID: NamedQuery> = NamedQuery { - name: "listIssueByID", - build: |a, auth| { +pub const LIST_ISSUE_BY_ID: QueryDefinition> = + define_query("listIssueByID", |a, auth| { let q = build_list_query(ListQueryArgs { list_context: Some(&a.list_context), role: role(auth), ..ListQueryArgs::default() }); where_named(q, a.id_field.column(), a.id_value.json()).one() - }, - encode: one_arg, -}; + }); -pub const EMOJI_CHANGE: NamedQuery = NamedQuery { - name: "emojiChange", - build: |subject_id, auth| { +pub const EMOJI_CHANGE: QueryDefinition = + define_query("emojiChange", |subject_id, auth| { let role = role(auth); let mut q = zql::emoji().where_eq(Emoji::SUBJECT_ID, subject_id.clone()); if role != Some(UserRole::Crew) { @@ -422,14 +318,11 @@ pub const EMOJI_CHANGE: NamedQuery = NamedQuery { }); } q.related(Emoji::CREATOR, |u| u.one()) - }, - encode: one_arg, -}; + }); /// Deprecated in zbugs. -pub const PREV_NEXT: NamedQuery> = NamedQuery { - name: "prevNext", - build: |a, auth| { +pub const PREV_NEXT: QueryDefinition> = + define_query("prevNext", |a, auth| { build_list_query(ListQueryArgs { list_context: a.list_context.as_ref(), role: role(auth), @@ -441,29 +334,23 @@ pub const PREV_NEXT: NamedQuery> = NamedQuery ..ListQueryArgs::default() }) .one() - }, - encode: one_arg, -}; + }); /// Deprecated in zbugs. -pub const ISSUE_LIST: NamedQuery = NamedQuery { - name: "issueList", - build: |a, auth| { +pub const ISSUE_LIST: QueryDefinition = + define_query("issueList", |a, auth| { build_list_query(ListQueryArgs { list_context: Some(&a.list_context), - user_id: auth.map(|a| a.sub.as_str()), + user_id: auth.as_ref().map(|a| a.sub.as_str()), role: role(auth), limit: Some(a.limit), ..ListQueryArgs::default() }) - }, - encode: one_arg, -}; + }); /// Deprecated in zbugs. -pub const USER_PICKER: NamedQuery = NamedQuery { - name: "userPicker", - build: |a, _| { +pub const USER_PICKER: QueryDefinition = + define_query("userPicker", |a, _| { let q = zql::user(); if let (true, Some(login)) = (a.disabled, truthy(a.login.as_deref())) { return q.where_eq(User::LOGIN, login.to_owned()); @@ -475,33 +362,31 @@ pub const USER_PICKER: NamedQuery = NamedQuery { } None => q, } - }, - encode: one_arg, -}; + }); /// Every query, for the drift test. -pub fn all() -> Vec { +pub fn all() -> Vec>> { vec![ - erase(ALL_LABELS, decode_none), - erase(ALL_USERS, decode_none), - erase(ALL_PROJECTS, decode_none), - erase(USER, decode_one), - erase(CREW_USER, decode_one), - erase(LABELS, decode_one), - erase(LABELS_ORDER_BY_NAME, decode_one), - erase(ISSUE_PRELOAD_V2, decode_one), - erase(USER_PREF, decode_one), - erase(USERS_FOR_PROJECT, decode_one), - erase(COMMENTS_PAGE, decode_one), - erase(COMMENT, decode_one), - erase(ISSUE_DETAIL, decode_one), - erase(ISSUE_LIST_V2, decode_one), - erase(ISSUE_LIST_COUNT, decode_one), - erase(LIST_ISSUE_BY_ID, decode_one), - erase(EMOJI_CHANGE, decode_one), - erase(PREV_NEXT, decode_one), - erase(ISSUE_LIST, decode_one), - erase(USER_PICKER, decode_one), + Box::new(ALL_LABELS), + Box::new(ALL_USERS), + Box::new(ALL_PROJECTS), + Box::new(USER), + Box::new(CREW_USER), + Box::new(LABELS), + Box::new(LABELS_ORDER_BY_NAME), + Box::new(ISSUE_PRELOAD_V2), + Box::new(USER_PREF), + Box::new(USERS_FOR_PROJECT), + Box::new(COMMENTS_PAGE), + Box::new(COMMENT), + Box::new(ISSUE_DETAIL), + Box::new(ISSUE_LIST_V2), + Box::new(ISSUE_LIST_COUNT), + Box::new(LIST_ISSUE_BY_ID), + Box::new(EMOJI_CHANGE), + Box::new(PREV_NEXT), + Box::new(ISSUE_LIST), + Box::new(USER_PICKER), ] } diff --git a/apps/zbugs-model/tests/drift.rs b/apps/zbugs-model/tests/drift.rs index 37c50d354a..dbb07b7348 100644 --- a/apps/zbugs-model/tests/drift.rs +++ b/apps/zbugs-model/tests/drift.rs @@ -48,10 +48,8 @@ fn matches_the_ts_queries_and_mutators() { let qs = queries::all(); let ms = mutators::all(); - let rust_queries: HashMap<&str, &queries::AnyNamedQuery> = - qs.iter().map(|q| (q.name, q)).collect(); - let rust_mutators: HashMap<&str, &mutators::AnyMutator> = - ms.iter().map(|m| (m.name, m)).collect(); + let rust_queries: HashMap<&str, _> = qs.iter().map(|q| (q.name(), q)).collect(); + let rust_mutators: HashMap<&str, _> = ms.iter().map(|m| (m.name(), m)).collect(); let (ts_queries, ts_mutators) = (names(&v, "queries"), names(&v, "mutators")); for n in &ts_queries { if !rust_queries.contains_key(n.as_str()) { @@ -91,7 +89,7 @@ fn matches_the_ts_queries_and_mutators() { .and_then(Value::as_array) .cloned() .unwrap_or_default(); - match (q.build)(&args, context(sample).as_ref()) { + match q.query_from_json(&args, &context(sample)) { Err(e) => failures.push(format!("{label}: {e}")), Ok((query, sent)) => { if sent != args { @@ -101,7 +99,7 @@ fn matches_the_ts_queries_and_mutators() { json::stringify(&Value::Array(args)) )); } - let any = query.any(); + let any = &query; if let Some(e) = any.invalid() { failures.push(format!("{label}: invalid query: {e}")); } @@ -134,19 +132,19 @@ fn check_mutator(label: &str, sample: &Value) -> Vec { let name = sample.get("name").and_then(Value::as_str).unwrap(); let args = sample.get("args"); let all = mutators::all(); - let def = all.iter().find(|m| m.name == name).unwrap(); - match (def.reencode)(args) { - Ok(sent) if Some(&sent) == args => {} + let def = all.iter().find(|m| m.name() == name).unwrap(); + match def.reencode(args) { + Ok(sent) if sent.as_ref() == args => {} Ok(sent) => failures.push(format!( "{label}: the arguments encode as {}, not {}", - json::stringify(&sent), + sent.as_ref().map_or("undefined".into(), json::stringify), args.map_or("undefined".into(), json::stringify) )), Err(e) => failures.push(format!("{label}: the arguments don't read: {e}")), } let schema = schema::schema(); - let mut ms: Vec> = all.into_iter().map(|m| m.mutator).collect(); + let mut ms: Vec> = all.iter().map(|m| m.mutator()).collect(); // Puts the sample's rows (`{table: [row]}`) before the mutator runs. ms.push(Mutator::new("__drift.seed", |tx, args, _| { for (table, rows) in args.and_then(Value::as_object).into_iter().flatten() { diff --git a/apps/zbugs-rune/Cargo.lock b/apps/zbugs-rune/Cargo.lock index 2cb846d715..e0c5379e6a 100644 --- a/apps/zbugs-rune/Cargo.lock +++ b/apps/zbugs-rune/Cargo.lock @@ -4772,7 +4772,6 @@ version = "0.0.0" dependencies = [ "getrandom 0.4.3", "serde", - "serde_json", "zero-client", ] @@ -4920,6 +4919,7 @@ dependencies = [ "futures-util", "rusqlite", "rustls", + "serde", "tokio", "tokio-tungstenite", ] diff --git a/apps/zbugs-rune/src/main.rs b/apps/zbugs-rune/src/main.rs index f336d3256c..438d5847f6 100644 --- a/apps/zbugs-rune/src/main.rs +++ b/apps/zbugs-rune/src/main.rs @@ -30,10 +30,10 @@ use zbugs_model::mutators::{ REMOVE_LABEL, SET_USER_PREF, SET_VIEW_STATE, UPDATE_ISSUE, UPDATE_NOTIFICATION, }; use zbugs_model::schema::{Comment, Emoji, Issue, IssueVisibility, Label, Project, User, UserPref}; -use zbugs_model::{AuthData, Context, MutateRequest, ZERO_PROJECT_NAME, make_id, queries}; -use zero_client::client::ConnectionState; +use zbugs_model::{AuthData, Context, ZERO_PROJECT_NAME, make_id, queries}; +use zero_client::client::{ConnectionState, MutateRequest}; use zero_client::ivm::ResultType; -use zero_client::query::AnyQuery; +use zero_client::query::ZeroQuery; /// Rows the issue list asks for at a time: as it nears the end, it asks for /// this many more. @@ -225,16 +225,15 @@ fn now_ms() -> f64 { .map_or(0.0, |d| d.as_millis() as f64) } -fn preload(auth: &Context) -> Vec { +fn preload() -> Vec>> { let project = ProjectArgs { project_name: ZERO_PROJECT_NAME.to_owned(), }; - let auth = auth.as_ref(); vec![ - queries::ISSUE_PRELOAD_V2.call(&project, auth).into_any(), - queries::ALL_USERS.call(&(), auth).into_any(), - queries::ALL_LABELS.call(&(), auth).into_any(), - queries::ALL_PROJECTS.call(&(), auth).into_any(), + Box::new(queries::ISSUE_PRELOAD_V2.with(project)), + Box::new(queries::ALL_USERS.with(())), + Box::new(queries::ALL_LABELS.with(())), + Box::new(queries::ALL_PROJECTS.with(())), ] } @@ -361,19 +360,16 @@ impl App { }); self.sync.watch( "projects", - |auth: &Context| queries::ALL_PROJECTS.call(&(), auth.as_ref()), + queries::ALL_PROJECTS.with(()), |s, rows, _, _| s.projects = rows, ); self.watch_project(); if let Some(sub) = self.auth.as_ref().map(|a| a.sub.clone()) { - self.sync.watch( - "me", - move |auth: &Context| queries::USER.call(&sub, auth.as_ref()), - |s, me, _, _| s.me = me, - ); + self.sync + .watch("me", queries::USER.with(sub), |s, me, _, _| s.me = me); self.sync.watch( "skin-tone", - |auth: &Context| queries::USER_PREF.call(&SKIN_TONE_PREF.to_owned(), auth.as_ref()), + queries::USER_PREF.with(SKIN_TONE_PREF.to_owned()), |s, pref: Option, _, _| { s.skin_tone = pref.and_then(|p| p.value.parse().ok()).unwrap_or(0); }, @@ -400,7 +396,7 @@ impl App { let crew = users(UsersFilter::Crew); self.sync.watch( "crew", - move |auth: &Context| queries::USERS_FOR_PROJECT.call(&crew, auth.as_ref()), + queries::USERS_FOR_PROJECT.with(crew), |s, mut rows: Vec, _, _| { by_login(&mut rows); s.crew = rows; @@ -409,7 +405,7 @@ impl App { let creators = users(UsersFilter::Creators); self.sync.watch( "creators", - move |auth: &Context| queries::USERS_FOR_PROJECT.call(&creators, auth.as_ref()), + queries::USERS_FOR_PROJECT.with(creators), |s, mut rows: Vec, _, _| { by_login(&mut rows); s.creators = rows; @@ -418,7 +414,7 @@ impl App { let assignees = users(UsersFilter::Assignees); self.sync.watch( "assignees", - move |auth: &Context| queries::USERS_FOR_PROJECT.call(&assignees, auth.as_ref()), + queries::USERS_FOR_PROJECT.with(assignees), |s, mut rows: Vec, _, _| { by_login(&mut rows); s.assignees = rows; @@ -430,7 +426,7 @@ impl App { }; self.sync.watch( "project-labels", - move |auth: &Context| queries::LABELS_ORDER_BY_NAME.call(&labels, auth.as_ref()), + queries::LABELS_ORDER_BY_NAME.with(labels), |s, rows, _, _| s.project_labels = rows, ); } @@ -519,15 +515,12 @@ impl App { dir: PageDirection::Forward, inclusive: None, }; - self.sync.watch( - "issues", - move |auth: &Context| queries::ISSUE_LIST_V2.call(&args, auth.as_ref()), - apply_issues, - ); + self.sync + .watch("issues", queries::ISSUE_LIST_V2.with(args), apply_issues); let count = IssueListCountArgs { list_context }; self.sync.watch( "count", - move |auth: &Context| queries::ISSUE_LIST_COUNT.call(&count, auth.as_ref()), + queries::ISSUE_LIST_COUNT.with(count), |s, n, _, _| s.count = Some(n), ); } @@ -582,7 +575,7 @@ impl App { }; self.sync.watch( "detail", - move |auth: &Context| queries::ISSUE_DETAIL.call(&args, auth.as_ref()), + queries::ISSUE_DETAIL.with(args), |s, issue: Option, result_type, _| { if let Some(project) = issue.as_ref().and_then(|i| i.project.as_ref()) { s.watch_labels(&project.name); @@ -607,7 +600,7 @@ impl App { }; self.sync.watch( "labels", - move |auth: &Context| queries::LABELS_ORDER_BY_NAME.call(&args, auth.as_ref()), + queries::LABELS_ORDER_BY_NAME.with(args), |s, rows, _, _| s.labels = rows, ); } @@ -623,7 +616,7 @@ impl App { }; self.sync.watch( "comments", - move |auth: &Context| queries::COMMENTS_PAGE.call(&args, auth.as_ref()), + queries::COMMENTS_PAGE.with(args), |s, rows, _, _| s.comments = rows, ); } @@ -641,7 +634,7 @@ impl App { fn mutate(&mut self, request: MutateRequest) { self.sync.mutate( - move |zero| request.run(zero), + move |zero| zero.mutate_request(request), |s, message| { s.error = Some(message); }, @@ -723,7 +716,7 @@ impl App { }; let body = self.comment_edit.text.trim().to_owned(); if !body.is_empty() { - self.mutate(EDIT_COMMENT.with(&EditCommentArgs { id, body })); + self.mutate(EDIT_COMMENT.with(EditCommentArgs { id, body })); } } @@ -731,7 +724,7 @@ impl App { pub fn confirmed(&mut self) { match self.confirm.take() { Some(Confirm::DeleteIssue(id)) => { - self.mutate(DELETE_ISSUE.with(&id)); + self.mutate(DELETE_ISSUE.with(id.clone())); if self.selected.as_ref() == Some(&id) { self.deselect(); } @@ -740,14 +733,14 @@ impl App { if self.selected.as_ref().is_some_and(|id| ids.contains(id)) { self.deselect(); } - self.mutate(DELETE_ISSUES.with(&ids)); + self.mutate(DELETE_ISSUES.with(ids)); self.toggle_select_mode(); } Some(Confirm::DeleteComment(id)) => { if self.editing_comment.as_ref() == Some(&id) { self.editing_comment = None; } - self.mutate(REMOVE_COMMENT.with(&id)); + self.mutate(REMOVE_COMMENT.with(id)); } None => {} } @@ -767,7 +760,7 @@ impl App { return; } let Some(issue) = &self.detail else { return }; - self.mutate(CREATE_AND_ADD_LABEL.with(&CreateAndAddLabelArgs { + self.mutate(CREATE_AND_ADD_LABEL.with(CreateAndAddLabelArgs { issue_id: issue.id.clone(), label_id: make_id(), label_name: name, @@ -783,7 +776,7 @@ impl App { open: Some(!issue.open), ..UpdateIssueArgs::new(issue.id.clone(), now_ms()) }; - self.mutate(UPDATE_ISSUE.with(&args)); + self.mutate(UPDATE_ISSUE.with(args)); } /// Opens `picker`, or closes it when it's open. @@ -825,7 +818,7 @@ impl App { // server's clock says. viewed: now_ms().max(issue.modified), }; - s.mutate(SET_VIEW_STATE.with(&args)); + s.mutate(SET_VIEW_STATE.with(args)); } }); }); @@ -847,7 +840,7 @@ impl App { visibility: Some(visibility), ..UpdateIssueArgs::new(issue.id.clone(), now_ms()) }; - self.mutate(UPDATE_ISSUE.with(&args)); + self.mutate(UPDATE_ISSUE.with(args)); } /// Whether the user gets notifications about the issue. @@ -873,7 +866,7 @@ impl App { }, created: now_ms(), }; - self.mutate(UPDATE_NOTIFICATION.with(&args)); + self.mutate(UPDATE_NOTIFICATION.with(args)); } /// Shows emoji in this skin tone (0 to 5, light to dark after the @@ -884,7 +877,7 @@ impl App { key: SKIN_TONE_PREF.to_owned(), value: tone.to_string(), }; - self.mutate(SET_USER_PREF.with(&args)); + self.mutate(SET_USER_PREF.with(args)); } pub fn toggle_picker(&mut self, picker: Picker) { @@ -911,7 +904,7 @@ impl App { assignee_id: Some(assignee.map(|u| u.id.clone())), ..UpdateIssueArgs::new(issue.id.clone(), now_ms()) }; - self.mutate(UPDATE_ISSUE.with(&args)); + self.mutate(UPDATE_ISSUE.with(args)); } /// Adds the label to the issue, or removes it. The menu stays open for @@ -921,9 +914,9 @@ impl App { let has = issue.labels.iter().flatten().any(|l| l.id == label_id); let (issue_id, label_id) = (issue.id.clone(), label_id.to_owned()); self.mutate(if has { - REMOVE_LABEL.with(&RemoveLabelArgs { issue_id, label_id }) + REMOVE_LABEL.with(RemoveLabelArgs { issue_id, label_id }) } else { - ADD_LABEL.with(&IssueLabelArgs { + ADD_LABEL.with(IssueLabelArgs { issue_id, label_id, project_id: None, @@ -956,7 +949,7 @@ impl App { .find(|e| e.creator_id == sub && e.value == value); if let Some(e) = mine { let id = e.id.clone(); - self.mutate(REMOVE_EMOJI.with(&id)); + self.mutate(REMOVE_EMOJI.with(id)); return; } // Its CLDR name, as the web picker sends it. @@ -977,9 +970,9 @@ impl App { created: now_ms(), }; self.mutate(if on_issue { - ADD_EMOJI_TO_ISSUE.with(&args) + ADD_EMOJI_TO_ISSUE.with(args) } else { - ADD_EMOJI_TO_COMMENT.with(&args) + ADD_EMOJI_TO_COMMENT.with(args) }); } @@ -990,7 +983,7 @@ impl App { if body.is_empty() { return; } - self.mutate(ADD_COMMENT.with(&AddCommentArgs { + self.mutate(ADD_COMMENT.with(AddCommentArgs { id: make_id(), issue_id: id, body, @@ -1038,14 +1031,14 @@ impl App { description: Some(description), ..UpdateIssueArgs::new(issue.id.clone(), now) }; - self.mutate(UPDATE_ISSUE.with(&args)); + self.mutate(UPDATE_ISSUE.with(args)); } else { let Some(project) = self.project() else { self.sheet_error = Some("The project hasn't synced yet.".into()); return; }; let id = make_id(); - self.mutate(CREATE_ISSUE.with(&CreateIssueArgs { + self.mutate(CREATE_ISSUE.with(CreateIssueArgs { id: id.clone(), title, description: Some(description), diff --git a/apps/zbugs-rune/src/sync.rs b/apps/zbugs-rune/src/sync.rs index 90d04ada9c..e15a4d1e8b 100644 --- a/apps/zbugs-rune/src/sync.rs +++ b/apps/zbugs-rune/src/sync.rs @@ -19,12 +19,13 @@ use zero_client::client::{ ConnectionState, MaterializedView, MutationOutcome, Mutator, MutatorResult, Preload, Zero, ZeroOptions, ZeroStorage, }; +use zero_client::define::QueryRequest; use zero_client::ivm::ResultType; -use zero_client::query::AnyQuery; +use zero_client::query::ZeroQuery; use zero_client::schema::Schema; use zero_client::tokio_transport::{TokioScheduler, TokioSocketFactory}; use zero_client::ttl::Ttl; -use zero_client::typed::{Query, QueryValue}; +use zero_client::typed::QueryValue; /// The app's state, which the sync thread updates. pub type Ui = AppHandle; @@ -61,7 +62,7 @@ pub struct Setup { pub schema: fn() -> Arc, pub mutators: fn() -> Vec>, /// The queries to keep synced for a client's life, as zbugs preloads. - pub preload: fn(&C) -> Vec, + pub preload: fn() -> Vec>>, /// Keeps local stores of one app apart from another's. pub storage_key: &'static str, /// Where the SQLite replica is kept. @@ -115,7 +116,7 @@ impl SyncThread { pub fn watch( &self, slot: &'static str, - query: impl Fn(&C) -> Query + Send + 'static, + query: QueryRequest, apply: fn(&mut S, T, ResultType, &mut rune::Tx), ) { self.send(move |w| w.watch(slot, query, apply)); @@ -278,9 +279,9 @@ impl Worker { } }; let ttl = Ttl::seconds(10.0); - let preloads = (self.setup.preload)(&session.context) + let preloads = (self.setup.preload)() .iter() - .map(|q| zero.preload(q, &ttl)) + .map(|q| zero.preload(q.as_ref(), &ttl)) .collect(); let (ui, f) = (self.ui.clone(), self.on_connection); let state = zero.connection().state(); @@ -303,16 +304,15 @@ impl Worker { fn watch( &mut self, slot: &'static str, - query: impl Fn(&C) -> Query + Send + 'static, + query: QueryRequest, apply: fn(&mut S, T, ResultType, &mut rune::Tx), ) { let ui = self.ui.clone(); // Built for each client, with its context: logging in changes what // a query asks for. let remake: Remake = Rc::new(move |zero| { - let query = query(zero.context()); - let view = zero.materialize(query.any(), &Ttl::seconds(10.0)).ok()?; - let ui = ui.clone(); + let view = zero.materialize(&query, &Ttl::seconds(10.0)).ok()?; + let (ui, query) = (ui.clone(), query.clone()); let remove = view.add_listener(move |data, result_type, _| { // Rows the generated types can't read are a bug of the // port; say so rather than show nothing. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1dd5cf8828..fb83446d8c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -348,6 +348,36 @@ dependencies = [ "untrusted", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.11.0" @@ -644,6 +674,7 @@ dependencies = [ "futures-util", "rusqlite", "rustls", + "serde", "tokio", "tokio-tungstenite", ] diff --git a/rust/PARITY.md b/rust/PARITY.md index 2619bea8f9..5e2ec6b4ae 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -112,22 +112,24 @@ the same store, with the views and preloads moved to the new client. Generated code (`zero-rust codegen`) has a row struct per table with `Column` and `Relationship` constants, and `zql::()` for its typed `Query`. `AnyQuery` is the untyped builder, named by strings; a typed -query wraps one (`any()`, `into_any()`, `Query::from_any`), which is what -`materialize`, `run`, and `preload` take. - -| JS | Rust | -| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `defineQueries(...)`, `defineQuery(...)` | not ported: name a query with `AnyQuery::name_and_args(name, args)` | -| `.where('open', true)` | `.where_eq(Issue::OPEN, true)` | -| `.where('title', 'LIKE', '%x%')` | `.where_(Issue::TITLE, SimpleOperator::Like, "%x%".into())` | -| `.where(({cmp, and, or, not, exists}) => ...)` | `.where_expr(\|e\| e.and(vec![...]))`, with `RowExpression` | -| `.where(({cmp, json}) => cmp(json('metadata', 'tags', 0), 'bug'))` | `.where_expr(\|e\| e.json(User::METADATA, path).eq("bug".into()))` | -| `.whereExists('labels', q => ...)` | `.where_exists(Issue::LABELS, ExistsOptions::default(), \|q\| ...)` | -| `.related('comments', q => q.limit(10))` | `.related(Issue::COMMENTS, \|q\| q.limit(10))` | -| `.orderBy('created', 'desc')`, `.limit(n)`, `.start(row)`, `.one()` | `.order_by(Issue::CREATED, Direction::Desc)`, `.limit(n)`, `.start(&row, inclusive)`, `.one()` | -| `.count()`, `.sum`, `.avg`, `.min`, `.max` | the same, with `_json` forms for a JSON path; `count` is an `i64` | -| `.related('comments', q => q.count())` | `.related(Issue::COMMENTS, \|q\| q.count())`, read as `row.aggregates.count(Issue::COMMENTS)` | -| `.distinct(...)`, `.groupBy(...).count()` | `.distinct(vec![...])`, `.group_by(vec![...]).count()` | +query wraps one (`any()`, `into_any()`, `Query::from_any`). `materialize`, +`run`, and `preload` take any `ZeroQuery`: an `AnyQuery`, a typed `Query`, +or a named query's `QueryRequest`, which they build with the client's +context. + +| JS | Rust | +| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `defineQueries(...)`, `defineQuery(...)` | `define::define_query(name, \|args, ctx\| query)` (`serde` feature), called as `Q.with(args)`; untyped, `AnyQuery::name_and_args(name, args)` | +| `.where('open', true)` | `.where_eq(Issue::OPEN, true)` | +| `.where('title', 'LIKE', '%x%')` | `.where_(Issue::TITLE, SimpleOperator::Like, "%x%".into())` | +| `.where(({cmp, and, or, not, exists}) => ...)` | `.where_expr(\|e\| e.and(vec![...]))`, with `RowExpression` | +| `.where(({cmp, json}) => cmp(json('metadata', 'tags', 0), 'bug'))` | `.where_expr(\|e\| e.json(User::METADATA, path).eq("bug".into()))` | +| `.whereExists('labels', q => ...)` | `.where_exists(Issue::LABELS, ExistsOptions::default(), \|q\| ...)` | +| `.related('comments', q => q.limit(10))` | `.related(Issue::COMMENTS, \|q\| q.limit(10))` | +| `.orderBy('created', 'desc')`, `.limit(n)`, `.start(row)`, `.one()` | `.order_by(Issue::CREATED, Direction::Desc)`, `.limit(n)`, `.start(&row, inclusive)`, `.one()` | +| `.count()`, `.sum`, `.avg`, `.min`, `.max` | the same, with `_json` forms for a JSON path; `count` is an `i64` | +| `.related('comments', q => q.count())` | `.related(Issue::COMMENTS, \|q\| q.count())`, read as `row.aggregates.count(Issue::COMMENTS)` | +| `.distinct(...)`, `.groupBy(...).count()` | `.distinct(vec![...])`, `.group_by(vec![...]).count()` | A view's data is a `ViewValue`; a typed query reads it as its rows with `query.read(&view.data())` (or `Vec::::from_view`). An unnamed @@ -137,24 +139,30 @@ never settles, and preloading it does nothing. ### Mutators -| JS | Rust | -| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `defineMutator(async ({tx, args, ctx}) => ...)` | `Mutator::new(name, \|tx, args, ctx\| ...)`, a synchronous closure over JSON arguments | -| `await tx.mutate.issue.insert(row)`, `.upsert`, `.update`, `.delete` | `mutate::issue(tx).insert(&row)?` (generated), or `tx.table("issue").insert(&object)?` | -| `await tx.run(query)` | `tx.run(&query)?` | -| `tx.location`, `tx.reason`, `tx.clientID`, `tx.mutationID`, `tx.token` | `location()`, `reason()`, `client_id()`, `mutation_id()`, `token()` | -| `throw new ApplicationError(message, {details})` | `Err(ApplicationError { message, details }.into())` | +| JS | Rust | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `defineMutator(async ({tx, args, ctx}) => ...)` | `define::define_mutator(name, \|tx, args, ctx\| ...)` (`serde` feature), a synchronous function over typed arguments; untyped, `Mutator::new(name, \|tx, args, ctx\| ...)` over JSON | +| `zero.mutate(mutators.issue.create(args))` | `zero.mutate_request(CREATE_ISSUE.with(args))`, or `zero.mutate(name, args)` with JSON arguments | +| `await tx.mutate.issue.insert(row)`, `.upsert`, `.update`, `.delete` | `mutate::issue(tx).insert(&row)?` (generated), or `tx.table("issue").insert(&object)?` | +| `await tx.run(query)` | `tx.run(&query)?` | +| `tx.location`, `tx.reason`, `tx.clientID`, `tx.mutationID`, `tx.token` | `location()`, `reason()`, `client_id()`, `mutation_id()`, `token()` | +| `throw new ApplicationError(message, {details})` | `Err(ApplicationError { message, details }.into())` | A mutator runs once optimistically and again on every rebase while it is pending, so it must depend only on the transaction, its arguments, and the -context. Calling a mutator that isn't registered throws in JS; in Rust -both outcomes are a `ZeroError` saying so, and nothing is written or sent. +context. A definition is registered with its `mutator()` in `Zero::new`; +its arguments are read back as its type before the body runs, and +arguments that don't read fail it with an `ApplicationError`, where JS +validates them with the definition's schema. Calling a mutator that isn't +registered throws in JS; in Rust both outcomes are a `ZeroError` saying so, and nothing is written or sent. ### React and SolidJS No UI bindings: `useQuery` is a `MaterializedView` and its listener. `apps/zbugs-rune` shows how a Rune app binds views to its UI, on the -shared zbugs model (`apps/zbugs-model`); a GPUI app is in progress. +shared zbugs model (`apps/zbugs-model`), whose queries and mutators are +`define_query` and `define_mutator` definitions; `apps/zbugs-gpui` is a +GPUI app on the same model. ## How some vectors are compared diff --git a/rust/zero-client/Cargo.toml b/rust/zero-client/Cargo.toml index 01784a9d3e..314a864683 100644 --- a/rust/zero-client/Cargo.toml +++ b/rust/zero-client/Cargo.toml @@ -27,12 +27,18 @@ sqlite = ["dep:rusqlite"] # SQLite storage with SQLite compiled in, for platforms without a # `libsqlite3`. sqlite-bundled = ["sqlite", "rusqlite/bundled"] +# Typed named queries and mutators (`define`), whose arguments are serde +# types, and serde for `json::Value`. +serde = ["dep:serde"] [dependencies] futures-util = { version = "0.3", default-features = false, features = [ "sink", ], optional = true } rusqlite = { version = "0.40", default-features = false, optional = true } +serde = { version = "1", default-features = false, features = [ + "std", +], optional = true } rustls = { version = "0.23", default-features = false, features = [ "ring", "std", @@ -53,6 +59,7 @@ all-features = true # The end-to-end tests' WebSocket client and server. [dev-dependencies] +serde = { version = "1", features = ["derive"] } futures-util = { version = "0.3", default-features = false, features = [ "sink", ] } @@ -79,3 +86,7 @@ required-features = ["tokio-transport"] [lints] workspace = true + +[[test]] +name = "define" +required-features = ["serde"] diff --git a/rust/zero-client/src/client/mod.rs b/rust/zero-client/src/client/mod.rs index 9743380563..4b9c855dd0 100644 --- a/rust/zero-client/src/client/mod.rs +++ b/rust/zero-client/src/client/mod.rs @@ -37,4 +37,4 @@ pub use scheduler::*; pub use socket::{SocketConnection, SocketEvents, SocketFactory, SocketMessage}; pub use storage::*; pub use transaction::{Reason, TableMutator, Transaction}; -pub use zero::{Connection, Mutator, Zero}; +pub use zero::{Connection, MutateRequest, Mutator, Zero}; diff --git a/rust/zero-client/src/client/zero.rs b/rust/zero-client/src/client/zero.rs index 183fb045f5..b120c918a3 100644 --- a/rust/zero-client/src/client/zero.rs +++ b/rust/zero-client/src/client/zero.rs @@ -10,7 +10,7 @@ use super::transaction::Transaction; use super::{ApplicationError, Deferred, Error, Scheduler, SocketFactory}; use crate::ivm::ViewValue; use crate::json::Value; -use crate::query::AnyQuery; +use crate::query::{AnyQuery, ZeroQuery}; use crate::schema::Schema; use crate::ttl::Ttl; use std::cell::{Cell, RefCell}; @@ -57,6 +57,28 @@ impl Mutator { } } +/// A mutator applied to its arguments, ready for [`Zero::mutate_request`] +/// (`MutateRequest` in JS and Swift). A `define_mutator` definition makes +/// one with `with`. +#[derive(Clone, Debug, PartialEq)] +pub struct MutateRequest { + /// The mutator's name. + pub name: String, + /// Its arguments as sent: `None` when it takes none (JS `undefined`). + pub args: Option, +} + +impl MutateRequest { + /// A request with arguments already in their JSON form, for example to + /// replay a mutation by name. Prefer a definition's `with`. + pub fn new(name: impl Into, args: Option) -> Self { + MutateRequest { + name: name.into(), + args, + } + } +} + /// A Zero client: syncs the rows of named queries from zero-cache into a /// local replica, keeps materialized views of them current, and runs /// mutators optimistically before the server confirms them. @@ -196,12 +218,17 @@ impl Zero { /// A view of the query's rows that stays current. Named queries are /// synced from the server; an unnamed one only sees local data. - pub fn materialize(&self, query: &AnyQuery, ttl: &Ttl) -> Result { + pub fn materialize( + &self, + query: &(impl ZeroQuery + ?Sized), + ttl: &Ttl, + ) -> Result { + let query = query.any_query(self.context()); let e = self.engine(); - let view = e.event_loop.call(|| e.context.materialize(query, ttl))?; + let view = e.event_loop.call(|| e.context.materialize(&query, ttl))?; let mut views = self.core.views.borrow_mut(); views.retain(|(v, _, _)| v.upgrade().is_some_and(|v| !v.is_destroyed())); - views.push((view.downgrade(), query.clone(), ttl.clone())); + views.push((view.downgrade(), query.into_owned(), ttl.clone())); Ok(view) } @@ -213,22 +240,24 @@ impl Zero { /// it, so waiting for `Complete` or `Cached` never settles. pub fn run( &self, - query: &AnyQuery, + query: &(impl ZeroQuery + ?Sized), run_type: RunType, ttl: Option<&Ttl>, ) -> Deferred { + let query = query.any_query(self.context()); let e = self.engine(); - e.event_loop.call(|| e.context.run(query, run_type, ttl)) + e.event_loop.call(|| e.context.run(&query, run_type, ttl)) } /// Keeps the query's rows synced without a view, until `cleanup`. /// Preloading an unnamed query does nothing. JS's default `ttl` for a /// preload is [`Ttl::DEFAULT_PRELOAD`], not `Ttl::default()`. - pub fn preload(&self, query: &AnyQuery, ttl: &Ttl) -> Preload { + pub fn preload(&self, query: &(impl ZeroQuery + ?Sized), ttl: &Ttl) -> Preload { + let query = query.any_query(self.context()); let e = self.engine(); - let preload = e.event_loop.call(|| e.context.preload(query, ttl)); + let preload = e.event_loop.call(|| e.context.preload(&query, ttl)); let record = Rc::new(PreloadRecord { - query: query.clone(), + query: query.into_owned(), ttl: ttl.clone(), cleanup: RefCell::new(preload.cleanup), active: Cell::new(true), @@ -255,6 +284,13 @@ impl Zero { e.event_loop.call(|| e.mutate(name, args)) } + /// [`mutate`](Zero::mutate) of a request, such as a `define_mutator` + /// definition applied to its arguments: JS's and Swift's + /// `zero.mutate(request)`. + pub fn mutate_request(&self, request: MutateRequest) -> MutatorResult { + self.mutate(&request.name, request.args) + } + /// The app was shown, or hidden. Hidden for /// `ZeroOptions::hidden_disconnect_delay_ms`, the client disconnects /// until it is shown again. diff --git a/rust/zero-client/src/define.rs b/rust/zero-client/src/define.rs new file mode 100644 index 0000000000..0c94bac87a --- /dev/null +++ b/rust/zero-client/src/define.rs @@ -0,0 +1,294 @@ +//! Typed named queries and mutators, behind the `serde` feature: +//! [`define_query`] and [`define_mutator`] (`defineQuery` in +//! `packages/zql/src/query/query-registry.ts` and `defineMutator` in +//! `packages/zql/src/mutate/mutator.ts`, shaped like the Swift client's). +//! +//! Each is defined once, by its name in the TS registry the server runs and +//! a serde argument type, and called with a value of that type: the client +//! sends the name and the arguments as JSON, as JS does, and reads them back +//! as the type before a mutator's body runs. The context comes from the +//! [`Zero`](crate::client::Zero) the request goes to. +//! +//! ``` +//! # use zero_client::define::{MutatorDefinition, QueryDefinition, define_mutator, define_query}; +//! # use zero_client::client::{Error, Transaction}; +//! # use zero_client::typed::Query; +//! # struct Label; +//! # fn labels_query(project: &str) -> Query