From 98bef5c9a340ae778b41dbf770cbc9bb2edf691a Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 05:42:24 +0000 Subject: [PATCH 1/3] feat(zero-rust): typed define_query and define_mutator behind a serde feature JS's defineQuery and defineMutator, shaped like the Swift client's: a definition has its name in the TS registry and a serde argument type, and `with(args)` makes a QueryRequest or a MutateRequest. The client sends the name and the arguments as JSON (struct fields in declaration order, as a JS object literal keeps them), and a mutator reads them back as its type before its body runs; arguments that don't read fail it as an app error. A definition of `()` (or any type without fields) sends no arguments, as JS's `undefined` does. The context comes from the Zero: materialize, run, and preload now take any `ZeroQuery` (an AnyQuery, a typed Query, or a QueryRequest built with the client's context), so existing `&AnyQuery` calls still compile. `Zero::mutate_request` takes a MutateRequest, next to `mutate(name, args)`, which keeps working. NamedQuery and NamedMutator erase the types, for an app's list of definitions and its drift tests. The `serde` feature adds only serde: json::to_value and json::from_value read and write `json::Value` natively, and Value and Object are serde types. The default build keeps no dependencies. Amp-Thread-ID: https://ampcode.com/threads/T-db04f816-97ed-4bc9-b19e-9ee68611a0ab Co-authored-by: tmm --- rust/Cargo.lock | 31 ++ rust/zero-client/Cargo.toml | 11 + rust/zero-client/src/client/mod.rs | 2 +- rust/zero-client/src/client/zero.rs | 54 +- rust/zero-client/src/define.rs | 293 +++++++++++ rust/zero-client/src/json/mod.rs | 4 + rust/zero-client/src/json/serde_value.rs | 642 +++++++++++++++++++++++ rust/zero-client/src/lib.rs | 6 + rust/zero-client/src/query.rs | 15 + rust/zero-client/src/typed.rs | 6 + rust/zero-client/tests/define.rs | 204 +++++++ 11 files changed, 1258 insertions(+), 10 deletions(-) create mode 100644 rust/zero-client/src/define.rs create mode 100644 rust/zero-client/src/json/serde_value.rs create mode 100644 rust/zero-client/tests/define.rs 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/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..39f9e01eaa 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, + 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, 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, 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..90a2e7fa91 --- /dev/null +++ b/rust/zero-client/src/define.rs @@ -0,0 +1,293 @@ +//! 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