From 426ae505486d2b8aaaaeb83695d171964367ae63 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 16:54:58 +0000 Subject: [PATCH] feat(zero-rust,zero-swift): make connection.connect take ConnectAuth and fail like JS JS's `connection.connect({auth})` rejects before it does anything when the client has no userID and the token is non-empty, and `auth: null` clears the token. The Rust and Swift clients only logged the invalid pair and ignored the call, and had no way to clear the token. Rust's `Connection::connect` now takes `ConnectAuth { Keep, Set(String), Clear }` and returns `Result<(), Error>`; Swift's `connect(_:)` takes `ConnectAuth { keep, set(String), clear }` and throws. An invalid pair is the Internal error "ZeroOptions.userID is required when auth is set." and changes nothing. Both stay synchronous: `subscribe` follows the state. The conformance runners map a vector's absent, null, and string `auth` to Keep, Clear, and Set, as the JS harness passes them. Swift's `connect(auth:)` is gone, an API break below 1.0 that the minor changeset declares. --- .changeset/zero-rust-connect-auth.md | 5 + .changeset/zero-swift-connect-auth.md | 5 + rust/PARITY.md | 9 +- rust/conformance/src/suites/client.rs | 42 +++-- rust/zero-client/src/client/engine.rs | 15 +- rust/zero-client/src/client/mod.rs | 2 +- rust/zero-client/src/client/zero.rs | 37 +++- rust/zero-client/tests/connect_auth.rs | 162 ++++++++++++++++++ swift/DESIGN.md | 4 +- swift/Sources/Zero/Client/Engine.swift | 16 +- swift/Sources/Zero/Client/ZeroClient.swift | 33 +++- .../Sources/Zero/Zero.docc/GettingStarted.md | 2 +- swift/Sources/Zero/Zero.docc/JSParity.md | 4 +- .../ClientConformanceTests.swift | 23 ++- .../ZeroTests/HardenConnectionTests.swift | 44 +++-- swift/Tests/ZeroTests/HardenCrashTests.swift | 2 +- swift/Tests/ZeroTests/SweepClientTests.swift | 6 +- 17 files changed, 346 insertions(+), 65 deletions(-) create mode 100644 .changeset/zero-rust-connect-auth.md create mode 100644 .changeset/zero-swift-connect-auth.md create mode 100644 rust/zero-client/tests/connect_auth.rs diff --git a/.changeset/zero-rust-connect-auth.md b/.changeset/zero-rust-connect-auth.md new file mode 100644 index 0000000000..3f1835a237 --- /dev/null +++ b/.changeset/zero-rust-connect-auth.md @@ -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)?`. diff --git a/.changeset/zero-swift-connect-auth.md b/.changeset/zero-swift-connect-auth.md new file mode 100644 index 0000000000..51575382ff --- /dev/null +++ b/.changeset/zero-swift-connect-auth.md @@ -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()`. diff --git a/rust/PARITY.md b/rust/PARITY.md index 26e0546bc6..b39b284572 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -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` 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` | -| `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 | @@ -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 diff --git a/rust/conformance/src/suites/client.rs b/rust/conformance/src/suites/client.rs index 4575b6c671..93cef073f7 100644 --- a/rust/conformance/src/suites/client.rs +++ b/rust/conformance/src/suites/client.rs @@ -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}; @@ -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 { + 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(""))); @@ -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>> = Rc::default(); let w = self.me.clone(); let u = unsubscribe.clone(); @@ -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)?, diff --git a/rust/zero-client/src/client/engine.rs b/rust/zero-client/src/client/engine.rs index 26c3ccb217..ed7d4dd982 100644 --- a/rust/zero-client/src/client/engine.rs +++ b/rust/zero-client/src/client/engine.rs @@ -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>) { + pub(crate) fn connect(&self, auth: Option>) -> 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() @@ -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) { diff --git a/rust/zero-client/src/client/mod.rs b/rust/zero-client/src/client/mod.rs index 00e207a486..4acc0ec745 100644 --- a/rust/zero-client/src/client/mod.rs +++ b/rust/zero-client/src/client/mod.rs @@ -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}; diff --git a/rust/zero-client/src/client/zero.rs b/rust/zero-client/src/client/zero.rs index 149d8ceff7..ef899e0e5d 100644 --- a/rust/zero-client/src/client/zero.rs +++ b/rust/zero-client/src/client/zero.rs @@ -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) { + /// 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(); diff --git a/rust/zero-client/tests/connect_auth.rs b/rust/zero-client/tests/connect_auth.rs new file mode 100644 index 0000000000..f4f31c0805 --- /dev/null +++ b/rust/zero-client/tests/connect_auth.rs @@ -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, + factory: Rc, +} + +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 { + 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 { + 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 { + 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); +} diff --git a/swift/DESIGN.md b/swift/DESIGN.md index 586f7e3843..5ecb1fba7f 100644 --- a/swift/DESIGN.md +++ b/swift/DESIGN.md @@ -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`. @@ -231,7 +231,7 @@ zql.issue - `zero.mutate(request)` is `@discardableResult` and returns a handle with `client` and `server` outcomes (`Deferred`), 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 diff --git a/swift/Sources/Zero/Client/Engine.swift b/swift/Sources/Zero/Client/Engine.swift index 694956ac93..edbcc44f26 100644 --- a/swift/Sources/Zero/Client/Engine.swift +++ b/swift/Sources/Zero/Client/Engine.swift @@ -152,7 +152,7 @@ final class Engine { /// The inspector's requests (`zero.inspector`). let inspectRPCs = InspectRPCs() - /// The current token: `options.auth`, then the last one `connect(auth:)` set. + /// The current token: `options.auth`, then the last one `connect(_:)` set. private(set) var auth: String? private var repClosed = false private var socket: SocketHandle? @@ -206,7 +206,7 @@ final class Engine { self.schema = schema self.options = options self.userID = options.userID - // Set now, as JS's constructor does, so a token from `connect(auth:)` right after init isn't replaced when the run loop starts. + // Set now, as JS's constructor does, so a token from `connect(_:)` right after init isn't replaced when the run loop starts. self.auth = options.auth ?? "" self.mutators = mutators self.socketFactory = socketFactory @@ -1207,16 +1207,12 @@ final class Engine { } } - /// `connection.connect()`: resumes from needs-auth or error. - func connect(auth: String??) { + /// `connection.connect()`: resumes from needs-auth or error. `auth`, when set, replaces the token + /// first (`.some(nil)` clears it). + func connect(auth: String??) throws { if case .some(let auth) = auth { // JS's `#setAuth` throws this, so `connect` rejects before it does anything. - do { - try Self.checkAuthValid(userID, auth) - } catch { - log.error("connect(auth:) ignored: \(error)") - return - } + try Self.checkAuthValid(userID, auth) setAuth(auth) } if case .disconnected(let r) = connectionManager.state, r.clientKind == .noSocketOrigin { diff --git a/swift/Sources/Zero/Client/ZeroClient.swift b/swift/Sources/Zero/Client/ZeroClient.swift index c930e87bcf..f91233eb46 100644 --- a/swift/Sources/Zero/Client/ZeroClient.swift +++ b/swift/Sources/Zero/Client/ZeroClient.swift @@ -443,13 +443,38 @@ public final class Connection { return { [weak self] in self?.listeners.removeAll { $0.id == id } } } - /// Resumes connecting from `needsAuth` or `error`. With `auth`, sets the - /// token first; when already connected, the server gets the new token. - public func connect(auth: String? = nil) { - engine.loop.call { engine.connect(auth: auth.map { .some($0) }) } + /// Resumes connecting from `needsAuth` or `error` (`connect` in `connection.ts`). From any other + /// state it only applies `auth`, and after a disconnect for a missing `cacheURL` 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 `userID` + /// throws an `Internal` error (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 `state` and `subscribe` follow here. + public func connect(_ auth: ConnectAuth = .keep) throws { + let auth: String?? = + switch auth { + case .keep: nil + case .set(let token): .some(token) + case .clear: .some(nil) + } + try engine.loop.call { try engine.connect(auth: auth) } } } +/// What `Connection.connect` does with the auth token first: the `auth` of `connect({auth})` +/// absent, a string, or `null`. +public enum ConnectAuth: Sendable, Hashable { + /// Keeps the token (no `auth`). + case keep + /// Replaces the token. + case set(String) + /// Removes the token (`auth: null`). + case clear +} + /// A `Zero` of any context, for code that needs only what doesn't depend /// on it, such as the SwiftUI environment. @MainActor diff --git a/swift/Sources/Zero/Zero.docc/GettingStarted.md b/swift/Sources/Zero/Zero.docc/GettingStarted.md index b7074df50b..9564eb2000 100644 --- a/swift/Sources/Zero/Zero.docc/GettingStarted.md +++ b/swift/Sources/Zero/Zero.docc/GettingStarted.md @@ -152,7 +152,7 @@ reconnects, so don't retry it as a failure. ``Connection`` is observable. Its ``ConnectionState`` is `.needsAuth` when the server rejects the token: get a new one and call -``Connection/connect(auth:)``. +``Connection/connect(_:)`` with `.set(token)`. ## Develop against a local backend diff --git a/swift/Sources/Zero/Zero.docc/JSParity.md b/swift/Sources/Zero/Zero.docc/JSParity.md index 8ed38c00c9..87770da253 100644 --- a/swift/Sources/Zero/Zero.docc/JSParity.md +++ b/swift/Sources/Zero/Zero.docc/JSParity.md @@ -46,7 +46,7 @@ are arguments of | `zero.run(query, {type})` | `try await zero.run(_:type:ttl:)` | | `zero.preload(query)` | `zero.preload(_:ttl:)`, a ``Preload`` with `cleanup`, `cached`, and `complete` | | `zero.mutate(request)` | `zero.mutate(_:)`, a ``MutatorResult`` whose `client` and `server` are ``Deferred`` outcomes | -| `zero.connection.state`, `.subscribe`, `.connect({auth})` | ``Connection/state``, ``Connection/subscribe(_:)``, ``Connection/connect(auth:)`` | +| `zero.connection.state`, `.subscribe`, `.connect({auth})` | ``Connection/state``, ``Connection/subscribe(_:)``, ``Connection/connect(_:)`` with `.keep`, `.set(token)`, or `.clear` | | `zero.close()` | `zero.close()`; dropping the last reference to a `Zero` closes it too | | `zero.mutate.issue.insert(...)` (CRUD mutators) | not ported: write a mutator | | `zero.delete()`, `dropAllDatabases()` | `await zero.delete()`, `await dropAllDatabases(_:)`, which return the ``DroppedDatabases`` | @@ -180,7 +180,7 @@ is written or sent. rest of its task are done, so every listener ends on the latest rows and every view sees each change. Its `client` outcome settles then. - **Connection logs.** The connection's life is logged with zero-client's messages and levels, such as "Connecting...", "Got socket open event", "Connected", "ping failed in … ms - disconnecting", and "disconnecting". Their details follow the message as `key=value` pairs rather than as an object. Three debug messages are Swift's own: "A WebSocket send hasn't finished after 1 second", "A WebSocket send finished after … ms" (when it took over a second), and "Sending the WebSocket close". A browser's `send` buffers and returns at once, but a `Transport`'s socket writes one frame at a time, so a send that doesn't finish holds up the frames and the close after it. -- **A token for a logged-out client.** `connect(auth:)` doesn't throw. Where JS's `connect({auth})` rejects because the client has no `userID`, Swift logs the error and ignores the call: the token isn't sent and the connection stays as it was. +- **`connect` returns before the state changes.** ``Connection/connect(_:)`` throws where JS's `connect({auth})` rejects, such as a token for a client without a `userID`, but isn't `async`: JS's promise waits for the next connection state, which ``Connection/state`` and ``Connection/subscribe(_:)`` follow. - **Queries that can't be built.** A query JS rejects when it is materialized, such as a flipped `whereExists` over a subquery with a `limit`, makes `materialize` and `liveQuery` throw and `run` reject with diff --git a/swift/Tests/ConformanceTests/ClientConformanceTests.swift b/swift/Tests/ConformanceTests/ClientConformanceTests.swift index fb438e81be..1f79c2a249 100644 --- a/swift/Tests/ConformanceTests/ClientConformanceTests.swift +++ b/swift/Tests/ConformanceTests/ClientConformanceTests.swift @@ -382,19 +382,23 @@ final class ScenarioRunner { case "onState": let name = step["state"]!.stringValue! let then = step["then"]! + let auth = then["op"] == "close" ? nil : try connectAuth(then) var unsubscribe: (() -> Void)? unsubscribe = zero.connection.subscribe { [weak self] state in guard let self, state.status.rawValue == name else { return } unsubscribe?() unsubscribe = nil - if then["op"] == "close" { - self.zero.close() + if let auth { + // The harness drops the promise (`void`). + try? self.zero.connection.connect(auth) } else { - self.zero.connection.connect(auth: then["auth"]?.stringValue) + self.zero.close() } } case "connect": - zero.connection.connect(auth: step["auth"]?.stringValue) + let auth = try connectAuth(step) + // The harness drops the promise (`void`). + try? zero.connection.connect(auth) case "close": zero.close() case "restart": @@ -434,6 +438,17 @@ final class ScenarioRunner { } } + /// The harness's `connect(auth === undefined ? undefined : {auth})`: a missing `auth` keeps the + /// token, `null` clears it, a string sets it. + func connectAuth(_ step: JSONValue) throws -> ConnectAuth { + switch step["auth"] { + case nil: .keep + case .null: .clear + case .string(let auth): .set(auth) + case let v?: throw ClientApp.Failure("connect auth must be a string or null, not \(v)") + } + } + /// An `inspect` step: calls `zero.inspector` and records how its result /// settles (`inspect` in `harness.ts`). The calls are async, so each runs /// in a task that `settle` lets run. diff --git a/swift/Tests/ZeroTests/HardenConnectionTests.swift b/swift/Tests/ZeroTests/HardenConnectionTests.swift index d431c70daa..cfb0cd57cd 100644 --- a/swift/Tests/ZeroTests/HardenConnectionTests.swift +++ b/swift/Tests/ZeroTests/HardenConnectionTests.swift @@ -259,7 +259,7 @@ import ZeroTesting try SecProtocols.decode(#require(socket?.secProtocol))["authToken"]?.stringValue } - /// The client that replaces one the server lost (`replaceClient`, the default `onClientStateNotFound`) connects with the token the app last gave through `connect(auth:)`, not the one in the options: the old token may be the expired one. + /// The client that replaces one the server lost (`replaceClient`, the default `onClientStateNotFound`) connects with the token the app last gave through `connect(_:)`, not the one in the options: the old token may be the expired one. @Test func aReplacedClientKeepsTheRotatedToken() throws { let sockets = MockSocketFactory() var options = ZeroOptions( @@ -270,7 +270,7 @@ import ZeroTesting schema: .test, mutators: [Self.create], options: options, scheduler: scheduler, socketFactory: sockets) let first = try connect(sockets) - zero.connection.connect(auth: "t2") + try zero.connection.connect(.set("t2")) scheduler.runDueTimers() #expect(first.messages.contains(#"["updateAuth",{"auth":"t2"}]"#)) first.receive( @@ -281,8 +281,8 @@ import ZeroTesting zero.close() } - /// A logged-out client can't take a token: JS's `#setAuth` throws `checkAuthValid`'s error, so `connect({auth})` rejects before it resumes. The client stays in needs-auth and never sends the token. - @Test func connectWithAuthOnALoggedOutClientIsRejected() throws { + /// A logged-out client can't take a token: JS's `#setAuth` throws `checkAuthValid`'s error, so `connect({auth})` rejects before it does anything. Connected, the server gets no `updateAuth`; in needs-auth, the client stays there and never sends the token. `auth: null` clears the token: no `updateAuth`, and the next connection carries none. + @Test func connectWithAuthOnALoggedOutClientThrowsAndClearDropsTheToken() throws { let sockets = MockSocketFactory() var options = ZeroOptions( cacheURL: "http://zero.test", storageKey: randomID(), storage: .memory) @@ -290,23 +290,47 @@ import ZeroTesting let zero = try Zero( schema: .test, mutators: [Self.create], options: options, scheduler: scheduler, socketFactory: sockets) + let first = try connect(sockets) + let rejection = ConnectionError.client( + .internal, message: "ZeroOptions.userID is required when auth is set.") + #expect(throws: rejection) { try zero.connection.connect(.set("t")) } scheduler.runDueTimers() - try #require(sockets.sockets.last).receive( + #expect(!first.messages.contains { $0.hasPrefix(#"["updateAuth""#) }) + first.receive( text: #"["error",{"kind":"Unauthorized","message":"no","origin":"zeroCache"}]"#) scheduler.runDueTimers() #expect(zero.connection.state.status == .needsAuth) - zero.connection.connect(auth: "t") + #expect(throws: rejection) { try zero.connection.connect(.set("t")) } scheduler.runDueTimers() #expect(sockets.sockets.count == 1) #expect(zero.connection.state.status == .needsAuth) - #expect( - log.entries.contains { $0.level == .error && $0.message.contains("userID is required") }) - // Without a token it still resumes. - zero.connection.connect() + // An empty token is no token, so it still resumes. + try zero.connection.connect(.set("")) scheduler.runDueTimers() #expect(sockets.sockets.count == 2) #expect(try authToken(sockets.sockets.last) == nil) zero.close() + + let signedIn = MockSocketFactory() + let other = try makeZero(signedIn, auth: "t1") + let socket = try connect(signedIn) + #expect(try authToken(socket) == "t1") + try other.connection.connect(.set("t2")) + try other.connection.connect(.clear) + scheduler.runDueTimers() + #expect( + socket.messages.filter { $0.hasPrefix(#"["updateAuth""#) } == [ + #"["updateAuth",{"auth":"t2"}]"# + ]) + socket.receive( + text: #"["error",{"kind":"Unauthorized","message":"no","origin":"zeroCache"}]"#) + scheduler.runDueTimers() + #expect(other.connection.state.status == .needsAuth) + try other.connection.connect() + scheduler.runDueTimers() + #expect(signedIn.sockets.count == 2) + #expect(try authToken(signedIn.sockets.last) == nil) + other.close() } // MARK: - A message cut off by the connection's end diff --git a/swift/Tests/ZeroTests/HardenCrashTests.swift b/swift/Tests/ZeroTests/HardenCrashTests.swift index 375e0a1c20..d910d7fa29 100644 --- a/swift/Tests/ZeroTests/HardenCrashTests.swift +++ b/swift/Tests/ZeroTests/HardenCrashTests.swift @@ -200,7 +200,7 @@ import ZeroTesting #""rowsPatch":[{"op":"update","tableName":"issues","id":{"id":"a"},"merge":{"id":"b"}}]"#) precondition(socket.closed) precondition(view.data.entries.map { $0.row["id"] } == ["a", "b"]) - h.zero.connection.connect() + try h.zero.connection.connect() let next = try h.connect() precondition(next !== socket) h.poke( diff --git a/swift/Tests/ZeroTests/SweepClientTests.swift b/swift/Tests/ZeroTests/SweepClientTests.swift index dfd0be4042..0f0794c585 100644 --- a/swift/Tests/ZeroTests/SweepClientTests.swift +++ b/swift/Tests/ZeroTests/SweepClientTests.swift @@ -456,21 +456,21 @@ import ZeroTesting // MARK: - Auth and close - /// `connect(auth:)` while connecting: no `updateAuth` on the attempt's + /// `connect(.set(_:))` while connecting: no `updateAuth` on the attempt's /// socket, and the run loop keeps the attempt. @Test(.timeLimit(.minutes(1))) func connectWithAuthWhileConnecting() throws { let zero = try makeZero() scheduler.runDueTimers() let socket = try #require(sockets.sockets.last) - zero.connection.connect(auth: "t2") + try zero.connection.connect(.set("t2")) scheduler.runDueTimers() #expect(sent("updateAuth", on: socket).isEmpty) socket.receive(text: #"["connected",{"wsid":"w"}]"#) scheduler.runDueTimers() #expect(zero.connection.state == .connected) #expect(sockets.sockets.count == 1) - zero.connection.connect(auth: "t3") + try zero.connection.connect(.set("t3")) #expect(sent("updateAuth", on: socket) == [["auth": "t3"]]) }