Skip to content

Commit 23f9185

Browse files
tmmampagent
andauthored
feat(zero-rust,zero-swift): make connection.connect take ConnectAuth and fail like JS (#215)
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. Co-authored-by: Amp <amp@ampcode.com>
1 parent 0ba9559 commit 23f9185

17 files changed

Lines changed: 346 additions & 65 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-rust': minor
3+
---
4+
5+
`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)?`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-swift': minor
3+
---
4+
5+
`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()`.

‎rust/PARITY.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ set 0 to disconnect as soon as it says it is hidden, as Swift does.
9898
| `zero.run(query, {type})` | `run(&query, RunType, ttl)`, a `Deferred<ViewValue>` to await or read once settled |
9999
| `zero.preload(query)` | `preload(&query, &Ttl::DEFAULT_PRELOAD)`, a `Preload` with `cleanup`, `cached`, and `complete` |
100100
| `zero.mutate(request)` | `mutate(name, args)`, a `MutatorResult` whose `client` and `server` are `Deferred<MutationOutcome>` |
101-
| `zero.connection.state`, `.subscribe`, `.connect({auth})` | `connection().state()`, `.subscribe(listener)`, `.connect(auth)` |
101+
| `zero.connection.state`, `.subscribe`, `.connect({auth})` | `connection().state()`, `.subscribe(listener)`, `.connect(ConnectAuth)?`; see below |
102102
| `zero.close()`, `zero.delete()`, `dropAllDatabases()` | `close()`, `delete()`, `client::drop_all_databases(&storage)`, which return the `DroppedDatabases` |
103103
| `escapeLike(text)` | `like::escape_like` |
104104
| `zero.mutate.issue.insert(...)` (CRUD mutators) | not ported: write a mutator |
@@ -109,6 +109,13 @@ Rust adds `reconnect_now` (the network may be back, so skip the backoff),
109109
`when_pending_settled`, and `flush_storage` (wait for the store's queued
110110
writes, such as before the app exits).
111111

112+
`connect({auth})` is `connect(ConnectAuth::Keep)` without `auth`,
113+
`ConnectAuth::Set(token)` with a token, and `ConnectAuth::Clear` with
114+
`null`. Where JS's promise rejects, such as for a token on a client without
115+
a `user_id`, it returns the error and changes nothing. It returns before
116+
the state changes, where JS's promise waits for the next one: `subscribe`
117+
follows it.
118+
112119
When the server no longer has the client's state, JS asks the app to
113120
reload. Without `on_client_state_not_found`, Rust replaces the client
114121
inside the same `Zero`, as Swift does: a new client ID and client group on

‎rust/conformance/src/suites/client.rs‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ use std::sync::Arc;
2020
use zero_client::ast::Ast;
2121
use zero_client::ast::{Direction, JsonPathSegment, SimpleOperator, System};
2222
use zero_client::client::{
23-
AnalyzeQueryOptions, ApplicationError, Deferred, Error, InspectorClient, InspectorQuery,
24-
LogLevel, MaterializedView, Mutator, PlanDebugEvent, RunType, SocketMessage, Transaction, Zero,
25-
ZeroOptions, ZeroStorage,
23+
AnalyzeQueryOptions, ApplicationError, ConnectAuth, Deferred, Error, InspectorClient,
24+
InspectorQuery, LogLevel, MaterializedView, Mutator, PlanDebugEvent, RunType, SocketMessage,
25+
Transaction, Zero, ZeroOptions, ZeroStorage,
2626
};
2727
use zero_client::decode::JsonCodable;
2828
use zero_client::json::{self, Object, Value};
@@ -316,6 +316,17 @@ fn field(args: Option<&Value>, key: &str, fallback: Value) -> Value {
316316
}
317317
}
318318

319+
/// The harness's `connect(auth === undefined ? undefined : {auth})`: a
320+
/// missing `auth` keeps the token, `null` clears it, a string sets it.
321+
fn connect_auth(step: &Value) -> Result<ConnectAuth, String> {
322+
match step.get("auth") {
323+
None => Ok(ConnectAuth::Keep),
324+
Some(Value::Null) => Ok(ConnectAuth::Clear),
325+
Some(Value::String(auth)) => Ok(ConnectAuth::Set(auth.clone())),
326+
Some(v) => Err(format!("connect auth must be a string or null, not {v:?}")),
327+
}
328+
}
329+
319330
fn issue_row(args: Option<&Value>) -> Object {
320331
let mut row = Object::new();
321332
row.insert("title", field(args, "title", s("")));
@@ -1153,6 +1164,12 @@ impl Runner {
11531164
"onState" => {
11541165
let name = string("state")?;
11551166
let then = step.field("then")?.clone();
1167+
let close = then.get("op").and_then(Value::as_str) == Some("close");
1168+
let auth = if close {
1169+
None
1170+
} else {
1171+
Some(connect_auth(&then)?)
1172+
};
11561173
let unsubscribe: Rc<RefCell<Option<Unsubscribe>>> = Rc::default();
11571174
let w = self.me.clone();
11581175
let u = unsubscribe.clone();
@@ -1167,19 +1184,20 @@ impl Runner {
11671184
}
11681185
let Some(runner) = w.upgrade() else { return };
11691186
let zero = runner.zero();
1170-
if then.get("op").and_then(Value::as_str) == Some("close") {
1171-
zero.close();
1172-
} else {
1173-
zero.connection()
1174-
.connect(then.get("auth").and_then(Value::as_str).map(str::to_owned));
1187+
match auth.clone() {
1188+
None => zero.close(),
1189+
// The harness drops the promise (`void`).
1190+
Some(auth) => {
1191+
let _ = zero.connection().connect(auth);
1192+
}
11751193
}
11761194
});
11771195
*unsubscribe.borrow_mut() = Some(remove);
11781196
}
1179-
"connect" => self
1180-
.zero()
1181-
.connection()
1182-
.connect(step.get("auth").and_then(Value::as_str).map(str::to_owned)),
1197+
"connect" => {
1198+
// The harness drops the promise (`void`).
1199+
let _ = self.zero().connection().connect(connect_auth(step)?);
1200+
}
11831201
"close" => self.zero().close(),
11841202
"restart" => self.restart(step)?,
11851203
"inspect" => self.inspect(step)?,

‎rust/zero-client/src/client/engine.rs‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2367,14 +2367,11 @@ impl Engine {
23672367

23682368
/// `connection.connect()`: resumes from needs-auth or error. `auth`,
23692369
/// when set, replaces the token first (`Some(None)` clears it).
2370-
pub(crate) fn connect(&self, auth: Option<Option<String>>) {
2370+
pub(crate) fn connect(&self, auth: Option<Option<String>>) -> Result<(), Error> {
23712371
if let Some(auth) = auth {
23722372
// JS's `#setAuth` throws this, so `connect` rejects before it
23732373
// does anything.
2374-
if let Err(e) = Self::check_auth_valid(self.user_id.as_deref(), auth.as_deref()) {
2375-
self.log.error(|| format!("connect(auth:) ignored: {e}"));
2376-
return;
2377-
}
2374+
Self::check_auth_valid(self.user_id.as_deref(), auth.as_deref())?;
23782375
self.set_auth(auth);
23792376
}
23802377
if let State::Disconnected(r) = self.connection_manager.state()
@@ -2385,12 +2382,12 @@ impl Engine {
23852382
No reconnect will be attempted."
23862383
.into()
23872384
});
2388-
return;
2385+
return Ok(());
23892386
}
2390-
if !self.connection_manager.is_in_terminal_state() {
2391-
return;
2387+
if self.connection_manager.is_in_terminal_state() {
2388+
self.connection_manager.request_connect();
23922389
}
2393-
self.connection_manager.request_connect();
2390+
Ok(())
23942391
}
23952392

23962393
fn client_state_not_found(&self) {

‎rust/zero-client/src/client/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,4 @@ pub use scheduler::*;
4444
pub use socket::{SocketConnection, SocketEvents, SocketFactory, SocketMessage};
4545
pub use storage::*;
4646
pub use transaction::{Reason, TableMutator, Transaction};
47-
pub use zero::{Connection, MutateRequest, Mutator, Zero};
47+
pub use zero::{ConnectAuth, Connection, MutateRequest, Mutator, Zero};

‎rust/zero-client/src/client/zero.rs‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -547,15 +547,42 @@ impl Connection {
547547
})
548548
}
549549

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

574+
/// What [`Connection::connect`] does with the auth token first: the
575+
/// `auth` of `connect({auth})` absent, a string, or `null`.
576+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
577+
pub enum ConnectAuth {
578+
/// Keeps the token (no `auth`).
579+
Keep,
580+
/// Replaces the token.
581+
Set(String),
582+
/// Removes the token (`auth: null`).
583+
Clear,
584+
}
585+
559586
impl ConnectionInner {
560587
fn changed(&self, state: ConnectionState) {
561588
*self.state.borrow_mut() = state.clone();
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//! `connection().connect(auth)` (`connect({auth})` in
2+
//! `packages/zero-client/src/client/connection.ts`): a token a client
3+
//! without a user can't take is an error before anything happens, and
4+
//! clearing the token really drops it.
5+
6+
use std::rc::Rc;
7+
use std::sync::Arc;
8+
use zero_client::client::{
9+
ClientErrorKind, ConnectAuth, ConnectionError, ConnectionStatus, Error, Zero, ZeroOptions,
10+
};
11+
use zero_client::json::{self, Value};
12+
use zero_client::protocol::sec_protocols;
13+
use zero_client::schema::Schema;
14+
use zero_client::testing::{ManualScheduler, MockSocket, MockSocketFactory, RecordingLogSink};
15+
16+
struct Harness {
17+
zero: Zero,
18+
scheduler: Rc<ManualScheduler>,
19+
factory: Rc<MockSocketFactory>,
20+
}
21+
22+
impl Harness {
23+
fn new(user_id: Option<&str>, auth: Option<&str>) -> Self {
24+
let schema = Schema::from_json(
25+
&json::parse(
26+
r#"{"tables": {"issue": {"name": "issue", "primaryKey": ["id"],
27+
"columns": {"id": {"type": "string"}}}}}"#,
28+
)
29+
.unwrap(),
30+
)
31+
.unwrap();
32+
let scheduler = ManualScheduler::new(1_700_000_000_000.0);
33+
let factory = MockSocketFactory::new();
34+
let options = ZeroOptions {
35+
cache_url: Some("http://zero.test".into()),
36+
user_id: user_id.map(str::to_owned),
37+
auth: auth.map(str::to_owned),
38+
log_sink: Some(Rc::new(RecordingLogSink::default())),
39+
..ZeroOptions::default()
40+
};
41+
let zero = Zero::new(
42+
Arc::new(schema),
43+
(),
44+
vec![],
45+
options,
46+
scheduler.clone(),
47+
Some(factory.clone()),
48+
)
49+
.unwrap();
50+
let h = Harness {
51+
zero,
52+
scheduler,
53+
factory,
54+
};
55+
h.settle();
56+
h
57+
}
58+
59+
fn settle(&self) {
60+
self.zero.dispatch(|| {});
61+
self.scheduler.run_due_timers();
62+
}
63+
64+
fn socket(&self) -> Rc<MockSocket> {
65+
self.factory.sockets().last().cloned().expect("a socket")
66+
}
67+
68+
fn connected(&self) {
69+
self.socket().receive_text(r#"["connected",{"wsid":"w"}]"#);
70+
self.settle();
71+
assert_eq!(self.status(), ConnectionStatus::Connected);
72+
}
73+
74+
fn unauthorized(&self) {
75+
self.socket().receive_text(
76+
r#"["error",{"kind":"Unauthorized","message":"no","origin":"zeroCache"}]"#,
77+
);
78+
self.settle();
79+
assert_eq!(self.status(), ConnectionStatus::NeedsAuth);
80+
}
81+
82+
fn status(&self) -> ConnectionStatus {
83+
self.zero.connection().state().status()
84+
}
85+
86+
fn update_auths(&self) -> Vec<String> {
87+
self.socket()
88+
.messages()
89+
.into_iter()
90+
.filter(|m| m.starts_with(r#"["updateAuth""#))
91+
.collect()
92+
}
93+
94+
/// The `authToken` the last socket opened with.
95+
fn auth_token(&self) -> Option<String> {
96+
let protocol =
97+
sec_protocols::decode(self.socket().sec_protocol.as_deref().unwrap()).unwrap();
98+
protocol
99+
.get("authToken")
100+
.and_then(Value::as_str)
101+
.map(str::to_owned)
102+
}
103+
}
104+
105+
fn rejection(r: Result<(), Error>) -> ConnectionError {
106+
match r {
107+
Err(Error::Connection(e)) => e,
108+
r => panic!("not a connection error: {r:?}"),
109+
}
110+
}
111+
112+
#[test]
113+
fn an_invalid_pair_is_an_error_and_clear_drops_the_token() {
114+
// A client without a user can't take a token: JS's `#setAuth` throws,
115+
// so `connect` rejects before it does anything.
116+
let h = Harness::new(None, None);
117+
h.connected();
118+
let expected = ConnectionError::client(
119+
ClientErrorKind::Internal,
120+
"ZeroOptions.userID is required when auth is set.",
121+
);
122+
assert_eq!(
123+
rejection(h.zero.connection().connect(ConnectAuth::Set("t".into()))),
124+
expected
125+
);
126+
h.settle();
127+
assert!(h.update_auths().is_empty(), "no updateAuth while connected");
128+
h.unauthorized();
129+
assert_eq!(
130+
rejection(h.zero.connection().connect(ConnectAuth::Set("t".into()))),
131+
expected
132+
);
133+
h.settle();
134+
assert_eq!(h.factory.sockets().len(), 1, "no reconnect");
135+
assert_eq!(h.status(), ConnectionStatus::NeedsAuth);
136+
// An empty token is no token, so it is valid.
137+
h.zero
138+
.connection()
139+
.connect(ConnectAuth::Set(String::new()))
140+
.unwrap();
141+
h.settle();
142+
assert_eq!(h.factory.sockets().len(), 2);
143+
assert_eq!(h.auth_token(), None);
144+
145+
// `auth: null` clears the token: it sends no `updateAuth`, and the
146+
// next connection carries none.
147+
let h = Harness::new(Some("u"), Some("t1"));
148+
assert_eq!(h.auth_token().as_deref(), Some("t1"));
149+
h.connected();
150+
h.zero
151+
.connection()
152+
.connect(ConnectAuth::Set("t2".into()))
153+
.unwrap();
154+
h.zero.connection().connect(ConnectAuth::Clear).unwrap();
155+
h.settle();
156+
assert_eq!(h.update_auths(), [r#"["updateAuth",{"auth":"t2"}]"#]);
157+
h.unauthorized();
158+
h.zero.connection().connect(ConnectAuth::Keep).unwrap();
159+
h.settle();
160+
assert_eq!(h.factory.sockets().len(), 2);
161+
assert_eq!(h.auth_token(), None);
162+
}

‎swift/DESIGN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ In scope for v1 (everything non-deprecated):
149149
- Named queries and mutators, with CRUD inside mutators (`insert`, `upsert`, `update`, `delete`).
150150
- The full ZQL builder: `where` (all operators), `whereExists` (including `flip` and `scalar`), `related`, `orderBy`, `limit`, `start`, `one`.
151151
- `run` with `unknown`, `cached`, and `complete`; `materialize`; `preload`; TTL.
152-
- Connection state; `connect(auth:)`; the `needs-auth` flow.
152+
- Connection state; `connect(_:)`; the `needs-auth` flow.
153153
- `close`, `delete`, `context`.
154154
- `mutateURL` and `queryURL` with their headers; `getTraceparent`.
155155
- `onUpdateNeeded`, `onClientStateNotFound`, `onStorageFailure`.
@@ -231,7 +231,7 @@ zql.issue
231231

232232
- `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.
233233
- 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.
234-
- 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.
234+
- 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.
235235
- 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.
236236

237237
### UI integration

0 commit comments

Comments
 (0)