Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/zero-rust-connect-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-rust': minor
---

`Connection::connect` now matches JS's `connection.connect({auth})`. It takes a `ConnectAuth`: `Keep` keeps the token, `Set(token)` replaces it, and `Clear` removes it, which JS's `auth: null` does and Rust couldn't. It returns a `Result`, `Err` where JS's promise rejects: a token for a client without a `user_id` is the `Internal` connection error "ZeroOptions.userID is required when auth is set." and changes nothing, where it was logged and ignored. Write `connect(Some(token))` as `connect(ConnectAuth::Set(token))?` and `connect(None)` as `connect(ConnectAuth::Keep)?`.
5 changes: 5 additions & 0 deletions .changeset/zero-swift-connect-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': minor
---

`Connection.connect(_:)` now matches JS's `connection.connect({auth})`, and replaces `connect(auth:)`. It takes a `ConnectAuth`: `.keep` (the default) keeps the token, `.set(token)` replaces it, and `.clear` removes it, which JS's `auth: null` does and Swift couldn't. It throws where JS's promise rejects: a token for a client without a `userID` throws the `Internal` error "ZeroOptions.userID is required when auth is set." and changes nothing, where it was logged and ignored. Write `connect(auth: token)` as `try connect(.set(token))` and `connect()` as `try connect()`.
9 changes: 8 additions & 1 deletion rust/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ whose window can stay hidden for long can set JS's 300,000.
| `zero.run(query, {type})` | `run(&query, RunType, ttl)`, a `Deferred<ViewValue>` to await or read once settled |
| `zero.preload(query)` | `preload(&query, &Ttl::DEFAULT_PRELOAD)`, a `Preload` with `cleanup`, `cached`, and `complete` |
| `zero.mutate(request)` | `mutate(name, args)`, a `MutatorResult` whose `client` and `server` are `Deferred<MutationOutcome>` |
| `zero.connection.state`, `.subscribe`, `.connect({auth})` | `connection().state()`, `.subscribe(listener)`, `.connect(auth)` |
| `zero.connection.state`, `.subscribe`, `.connect({auth})` | `connection().state()`, `.subscribe(listener)`, `.connect(ConnectAuth)?`; see below |
| `zero.close()`, `zero.delete()`, `dropAllDatabases()` | `close()`, `delete()`, `client::drop_all_databases(&storage)`, which return the `DroppedDatabases` |
| `escapeLike(text)` | `like::escape_like` |
| `zero.mutate.issue.insert(...)` (CRUD mutators) | not ported: write a mutator |
Expand All @@ -107,6 +107,13 @@ Rust adds `reconnect_now` (the network may be back, so skip the backoff),
`when_pending_settled`, and `flush_storage` (wait for the store's queued
writes, such as before the app exits).

`connect({auth})` is `connect(ConnectAuth::Keep)` without `auth`,
`ConnectAuth::Set(token)` with a token, and `ConnectAuth::Clear` with
`null`. Where JS's promise rejects, such as for a token on a client without
a `user_id`, it returns the error and changes nothing. It returns before
the state changes, where JS's promise waits for the next one: `subscribe`
follows it.

When the server no longer has the client's state, JS asks the app to
reload. Without `on_client_state_not_found`, Rust replaces the client
inside the same `Zero`, as Swift does: a new client ID and client group on
Expand Down
42 changes: 30 additions & 12 deletions rust/conformance/src/suites/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ use std::sync::Arc;
use zero_client::ast::Ast;
use zero_client::ast::{Direction, JsonPathSegment, SimpleOperator, System};
use zero_client::client::{
AnalyzeQueryOptions, ApplicationError, Deferred, Error, InspectorClient, InspectorQuery,
LogLevel, MaterializedView, Mutator, PlanDebugEvent, RunType, SocketMessage, Transaction, Zero,
ZeroOptions, ZeroStorage,
AnalyzeQueryOptions, ApplicationError, ConnectAuth, Deferred, Error, InspectorClient,
InspectorQuery, LogLevel, MaterializedView, Mutator, PlanDebugEvent, RunType, SocketMessage,
Transaction, Zero, ZeroOptions, ZeroStorage,
};
use zero_client::decode::JsonCodable;
use zero_client::json::{self, Object, Value};
Expand Down Expand Up @@ -316,6 +316,17 @@ fn field(args: Option<&Value>, key: &str, fallback: Value) -> Value {
}
}

/// The harness's `connect(auth === undefined ? undefined : {auth})`: a
/// missing `auth` keeps the token, `null` clears it, a string sets it.
fn connect_auth(step: &Value) -> Result<ConnectAuth, String> {
match step.get("auth") {
None => Ok(ConnectAuth::Keep),
Some(Value::Null) => Ok(ConnectAuth::Clear),
Some(Value::String(auth)) => Ok(ConnectAuth::Set(auth.clone())),
Some(v) => Err(format!("connect auth must be a string or null, not {v:?}")),
}
}

fn issue_row(args: Option<&Value>) -> Object {
let mut row = Object::new();
row.insert("title", field(args, "title", s("")));
Expand Down Expand Up @@ -1153,6 +1164,12 @@ impl Runner {
"onState" => {
let name = string("state")?;
let then = step.field("then")?.clone();
let close = then.get("op").and_then(Value::as_str) == Some("close");
let auth = if close {
None
} else {
Some(connect_auth(&then)?)
};
let unsubscribe: Rc<RefCell<Option<Unsubscribe>>> = Rc::default();
let w = self.me.clone();
let u = unsubscribe.clone();
Expand All @@ -1167,19 +1184,20 @@ impl Runner {
}
let Some(runner) = w.upgrade() else { return };
let zero = runner.zero();
if then.get("op").and_then(Value::as_str) == Some("close") {
zero.close();
} else {
zero.connection()
.connect(then.get("auth").and_then(Value::as_str).map(str::to_owned));
match auth.clone() {
None => zero.close(),
// The harness drops the promise (`void`).
Some(auth) => {
let _ = zero.connection().connect(auth);
}
}
});
*unsubscribe.borrow_mut() = Some(remove);
}
"connect" => self
.zero()
.connection()
.connect(step.get("auth").and_then(Value::as_str).map(str::to_owned)),
"connect" => {
// The harness drops the promise (`void`).
let _ = self.zero().connection().connect(connect_auth(step)?);
}
"close" => self.zero().close(),
"restart" => self.restart(step)?,
"inspect" => self.inspect(step)?,
Expand Down
15 changes: 6 additions & 9 deletions rust/zero-client/src/client/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2364,14 +2364,11 @@ impl Engine {

/// `connection.connect()`: resumes from needs-auth or error. `auth`,
/// when set, replaces the token first (`Some(None)` clears it).
pub(crate) fn connect(&self, auth: Option<Option<String>>) {
pub(crate) fn connect(&self, auth: Option<Option<String>>) -> Result<(), Error> {
if let Some(auth) = auth {
// JS's `#setAuth` throws this, so `connect` rejects before it
// does anything.
if let Err(e) = Self::check_auth_valid(self.user_id.as_deref(), auth.as_deref()) {
self.log.error(|| format!("connect(auth:) ignored: {e}"));
return;
}
Self::check_auth_valid(self.user_id.as_deref(), auth.as_deref())?;
self.set_auth(auth);
}
if let State::Disconnected(r) = self.connection_manager.state()
Expand All @@ -2382,12 +2379,12 @@ impl Engine {
No reconnect will be attempted."
.into()
});
return;
return Ok(());
}
if !self.connection_manager.is_in_terminal_state() {
return;
if self.connection_manager.is_in_terminal_state() {
self.connection_manager.request_connect();
}
self.connection_manager.request_connect();
Ok(())
}

fn client_state_not_found(&self) {
Expand Down
2 changes: 1 addition & 1 deletion rust/zero-client/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ pub use scheduler::*;
pub use socket::{SocketConnection, SocketEvents, SocketFactory, SocketMessage};
pub use storage::*;
pub use transaction::{Reason, TableMutator, Transaction};
pub use zero::{Connection, MutateRequest, Mutator, Zero};
pub use zero::{ConnectAuth, Connection, MutateRequest, Mutator, Zero};
37 changes: 32 additions & 5 deletions rust/zero-client/src/client/zero.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,15 +547,42 @@ impl Connection {
})
}

/// Resumes connecting from `needs-auth` or `error`. With `auth`, sets
/// the token first; when already connected, the server gets the new
/// token.
pub fn connect(&self, auth: Option<String>) {
/// Resumes connecting from `needs-auth` or `error` (`connect` in
/// `connection.ts`). From any other state it only applies `auth`, and
/// after a disconnect for a missing `cache_url` it never reconnects.
///
/// `auth` first sets or clears the token, as `connect({auth})` does;
/// when already connected, the server gets a new non-empty token in
/// `updateAuth`. A token on a client without a `user_id` is an
/// [`Error::Connection`] of kind `Internal` (JS's rejection), and
/// nothing changes.
///
/// It returns once the connection is asked to resume, not once it
/// has: JS's promise waits for the next state, which
/// [`subscribe`](Self::subscribe) follows here.
pub fn connect(&self, auth: ConnectAuth) -> Result<(), Error> {
let auth = match auth {
ConnectAuth::Keep => None,
ConnectAuth::Set(token) => Some(Some(token)),
ConnectAuth::Clear => Some(None),
};
let e = self.0.engine.borrow().clone();
e.event_loop.call(|| e.connect(auth.map(Some)));
e.event_loop.call(|| e.connect(auth))
}
}

/// What [`Connection::connect`] does with the auth token first: the
/// `auth` of `connect({auth})` absent, a string, or `null`.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ConnectAuth {
/// Keeps the token (no `auth`).
Keep,
/// Replaces the token.
Set(String),
/// Removes the token (`auth: null`).
Clear,
}

impl ConnectionInner {
fn changed(&self, state: ConnectionState) {
*self.state.borrow_mut() = state.clone();
Expand Down
162 changes: 162 additions & 0 deletions rust/zero-client/tests/connect_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//! `connection().connect(auth)` (`connect({auth})` in
//! `packages/zero-client/src/client/connection.ts`): a token a client
//! without a user can't take is an error before anything happens, and
//! clearing the token really drops it.

use std::rc::Rc;
use std::sync::Arc;
use zero_client::client::{
ClientErrorKind, ConnectAuth, ConnectionError, ConnectionStatus, Error, Zero, ZeroOptions,
};
use zero_client::json::{self, Value};
use zero_client::protocol::sec_protocols;
use zero_client::schema::Schema;
use zero_client::testing::{ManualScheduler, MockSocket, MockSocketFactory, RecordingLogSink};

struct Harness {
zero: Zero,
scheduler: Rc<ManualScheduler>,
factory: Rc<MockSocketFactory>,
}

impl Harness {
fn new(user_id: Option<&str>, auth: Option<&str>) -> Self {
let schema = Schema::from_json(
&json::parse(
r#"{"tables": {"issue": {"name": "issue", "primaryKey": ["id"],
"columns": {"id": {"type": "string"}}}}}"#,
)
.unwrap(),
)
.unwrap();
let scheduler = ManualScheduler::new(1_700_000_000_000.0);
let factory = MockSocketFactory::new();
let options = ZeroOptions {
cache_url: Some("http://zero.test".into()),
user_id: user_id.map(str::to_owned),
auth: auth.map(str::to_owned),
log_sink: Some(Rc::new(RecordingLogSink::default())),
..ZeroOptions::default()
};
let zero = Zero::new(
Arc::new(schema),
(),
vec![],
options,
scheduler.clone(),
Some(factory.clone()),
)
.unwrap();
let h = Harness {
zero,
scheduler,
factory,
};
h.settle();
h
}

fn settle(&self) {
self.zero.dispatch(|| {});
self.scheduler.run_due_timers();
}

fn socket(&self) -> Rc<MockSocket> {
self.factory.sockets().last().cloned().expect("a socket")
}

fn connected(&self) {
self.socket().receive_text(r#"["connected",{"wsid":"w"}]"#);
self.settle();
assert_eq!(self.status(), ConnectionStatus::Connected);
}

fn unauthorized(&self) {
self.socket().receive_text(
r#"["error",{"kind":"Unauthorized","message":"no","origin":"zeroCache"}]"#,
);
self.settle();
assert_eq!(self.status(), ConnectionStatus::NeedsAuth);
}

fn status(&self) -> ConnectionStatus {
self.zero.connection().state().status()
}

fn update_auths(&self) -> Vec<String> {
self.socket()
.messages()
.into_iter()
.filter(|m| m.starts_with(r#"["updateAuth""#))
.collect()
}

/// The `authToken` the last socket opened with.
fn auth_token(&self) -> Option<String> {
let protocol =
sec_protocols::decode(self.socket().sec_protocol.as_deref().unwrap()).unwrap();
protocol
.get("authToken")
.and_then(Value::as_str)
.map(str::to_owned)
}
}

fn rejection(r: Result<(), Error>) -> ConnectionError {
match r {
Err(Error::Connection(e)) => e,
r => panic!("not a connection error: {r:?}"),
}
}

#[test]
fn an_invalid_pair_is_an_error_and_clear_drops_the_token() {
// A client without a user can't take a token: JS's `#setAuth` throws,
// so `connect` rejects before it does anything.
let h = Harness::new(None, None);
h.connected();
let expected = ConnectionError::client(
ClientErrorKind::Internal,
"ZeroOptions.userID is required when auth is set.",
);
assert_eq!(
rejection(h.zero.connection().connect(ConnectAuth::Set("t".into()))),
expected
);
h.settle();
assert!(h.update_auths().is_empty(), "no updateAuth while connected");
h.unauthorized();
assert_eq!(
rejection(h.zero.connection().connect(ConnectAuth::Set("t".into()))),
expected
);
h.settle();
assert_eq!(h.factory.sockets().len(), 1, "no reconnect");
assert_eq!(h.status(), ConnectionStatus::NeedsAuth);
// An empty token is no token, so it is valid.
h.zero
.connection()
.connect(ConnectAuth::Set(String::new()))
.unwrap();
h.settle();
assert_eq!(h.factory.sockets().len(), 2);
assert_eq!(h.auth_token(), None);

// `auth: null` clears the token: it sends no `updateAuth`, and the
// next connection carries none.
let h = Harness::new(Some("u"), Some("t1"));
assert_eq!(h.auth_token().as_deref(), Some("t1"));
h.connected();
h.zero
.connection()
.connect(ConnectAuth::Set("t2".into()))
.unwrap();
h.zero.connection().connect(ConnectAuth::Clear).unwrap();
h.settle();
assert_eq!(h.update_auths(), [r#"["updateAuth",{"auth":"t2"}]"#]);
h.unauthorized();
h.zero.connection().connect(ConnectAuth::Keep).unwrap();
h.settle();
assert_eq!(h.factory.sockets().len(), 2);
assert_eq!(h.auth_token(), None);
}
4 changes: 2 additions & 2 deletions swift/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ In scope for v1 (everything non-deprecated):
- Named queries and mutators, with CRUD inside mutators (`insert`, `upsert`, `update`, `delete`).
- The full ZQL builder: `where` (all operators), `whereExists` (including `flip` and `scalar`), `related`, `orderBy`, `limit`, `start`, `one`.
- `run` with `unknown`, `cached`, and `complete`; `materialize`; `preload`; TTL.
- Connection state; `connect(auth:)`; the `needs-auth` flow.
- Connection state; `connect(_:)`; the `needs-auth` flow.
- `close`, `delete`, `context`.
- `mutateURL` and `queryURL` with their headers; `getTraceparent`.
- `onUpdateNeeded`, `onClientStateNotFound`, `onStorageFailure`.
Expand Down Expand Up @@ -231,7 +231,7 @@ zql.issue

- `zero.mutate(request)` is `@discardableResult` and returns a handle with `client` and `server` outcomes (`Deferred<MutationOutcome>`), the same split as JS `{client, server}`. An outcome is success with optional data, an application error, or a Zero error, as in JS, rather than a throw.
- One `ZeroError` enum mirrors the JS kinds: application (with message), input validation, storage failure, transaction closed, rejected while disconnected or closed, and Zero/server errors. The public API uses plain `throws` because mutator bodies can throw arbitrary errors.
- Auth: an `auth` token option and a `.needsAuth` connection state on 401/403. The app calls `connection.connect(auth:)`, which sends `updateAuth` when already connected. An async token-provider closure is deferred.
- Auth: an `auth` token option and a `.needsAuth` connection state on 401/403. The app calls `connection.connect(.set(token))`, which sends `updateAuth` when already connected. An async token-provider closure is deferred.
- Logging: a `LogSink` protocol and a `logLevel` option defaulting to `.warn` (what the JS code does). The default sink is `os.Logger` (subsystem `zero`) on Apple platforms and stderr on Linux. No telemetry.

### UI integration
Expand Down
Loading
Loading