From d1aacd6b95ee7fb8a477661968183431c740e01a Mon Sep 17 00:00:00 2001 From: Mayne Date: Fri, 31 Jul 2026 03:33:54 +0800 Subject: [PATCH 1/2] fix(remote): make raw existence probes proxy-safe --- crates/graft/src/remote.rs | 372 +++++++++++++++++- .../docs/docs/reference/remote-protocol.mdx | 13 + .../zh/docs/reference/remote-protocol.mdx | 9 + 3 files changed, 379 insertions(+), 15 deletions(-) diff --git a/crates/graft/src/remote.rs b/crates/graft/src/remote.rs index bef6d5fe..cd0afe7b 100644 --- a/crates/graft/src/remote.rs +++ b/crates/graft/src/remote.rs @@ -155,6 +155,7 @@ pub struct RemoteCredentials { mode: RemoteCredentialMode, http_bearer_tokens: Arc>>, http_client: Arc>>, + http_probe_client: Arc>>, http_upload_client: Arc>>, } @@ -171,6 +172,10 @@ impl fmt::Debug for RemoteCredentials { "http_client_initialized", &self.http_client.read().is_some(), ) + .field( + "http_probe_client_initialized", + &self.http_probe_client.read().is_some(), + ) .field( "http_upload_client_initialized", &self.http_upload_client.read().is_some(), @@ -192,6 +197,7 @@ impl RemoteCredentials { mode: RemoteCredentialMode::Explicit, http_bearer_tokens: Arc::default(), http_client: Arc::default(), + http_probe_client: Arc::default(), http_upload_client: Arc::default(), } } @@ -205,6 +211,7 @@ impl RemoteCredentials { mode: RemoteCredentialMode::Environment, http_bearer_tokens: Arc::default(), http_client: Arc::default(), + http_probe_client: Arc::default(), http_upload_client: Arc::default(), } } @@ -262,6 +269,7 @@ impl RemoteCredentials { /// HTTP/1.1 proxies leave otherwise idle pooled connections unusable. pub fn reset_http_clients(&self) { *self.http_client.write() = None; + *self.http_probe_client.write() = None; *self.http_upload_client.write() = None; } @@ -297,13 +305,24 @@ impl RemoteCredentials { let mut client = self.http_upload_client.write(); Ok(client.get_or_insert(candidate).clone()) } + + fn http_probe_client(&self) -> Result { + if let Some(client) = self.http_probe_client.read().clone() { + return Ok(client); + } + + let candidate = build_http_client()?; + let mut client = self.http_probe_client.write(); + Ok(client.get_or_insert(candidate).clone()) + } } fn build_http_client() -> std::result::Result { reqwest::ClientBuilder::new() // HTTP/2 request-body multiplexing stalls behind common local proxies. - // Reuse dedicated read and mutation HTTP/1.1 pools across the entire - // repository session so proxies never mix PUT bodies into read streams. + // Reuse dedicated read, existence-probe, and mutation HTTP/1.1 pools + // across the entire repository session so proxies never mix request + // types with different response-body behavior on one connection. .http1_only() .hickory_dns(true) .connect_timeout(Duration::from_secs(5)) @@ -401,6 +420,7 @@ enum RemoteBackend { #[derive(Debug, Clone)] struct HttpRemote { client: reqwest::Client, + probe_client: reqwest::Client, upload_client: reqwest::Client, url: String, token: Option, @@ -569,6 +589,7 @@ impl Remote { url, token, credentials.http_client()?, + credentials.http_probe_client()?, credentials.http_upload_client()?, )), }); @@ -1088,17 +1109,19 @@ impl HttpRemote { #[cfg(test)] fn with_client(url: String, token: Option, client: reqwest::Client) -> Self { - Self::with_clients(url, token, client.clone(), client) + Self::with_clients(url, token, client.clone(), client.clone(), client) } fn with_clients( url: String, token: Option, client: reqwest::Client, + probe_client: reqwest::Client, upload_client: reqwest::Client, ) -> Self { Self { client, + probe_client, upload_client, url: url.trim_end_matches('/').to_string(), token, @@ -1130,6 +1153,10 @@ impl HttpRemote { self.request_with(&self.upload_client, method, url) } + fn probe_request(&self, method: reqwest::Method, url: String) -> reqwest::RequestBuilder { + self.request_with(&self.probe_client, method, url) + } + fn request_with( &self, client: &reqwest::Client, @@ -1238,17 +1265,75 @@ impl HttpRemote { async fn has_raw(&self, path: &str) -> Result { let response = self .send( - self.request(reqwest::Method::HEAD, self.raw_url("raw", path)), + self.probe_request(reqwest::Method::HEAD, self.raw_url("raw", path)), "head", Some(0), ) .await?; - if response.status().as_u16() == 404 { - Self::check_protocol(&response, path)?; - return Ok(false); + let status = response.status().as_u16(); + match status { + 404 => { + Self::check_protocol(&response, path)?; + drop(response); + Ok(false) + } + 405 | 501 => { + drop(response); + self.has_raw_with_range(path).await + } + _ => { + Self::check_response(response, path).await?; + Ok(true) + } + } + } + + async fn has_raw_with_range(&self, path: &str) -> Result { + let response = self + .send( + self.probe_request(reqwest::Method::GET, self.raw_url("raw", path)) + .header(reqwest::header::RANGE, "bytes=0-0"), + "range_probe", + Some(0), + ) + .await?; + Self::check_protocol(&response, path)?; + let status = response.status().as_u16(); + match status { + 200 | 206 => { + drop(response); + Ok(true) + } + 404 => { + drop(response); + Ok(false) + } + 416 if response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .is_some_and(|value| value.as_bytes().eq_ignore_ascii_case(b"bytes */0")) => + { + drop(response); + Ok(true) + } + 200..=299 => { + drop(response); + Err(RemoteErr::HttpStatus { + status, + path: path.to_string(), + message: "range existence probe returned an unexpected success status" + .to_string(), + }) + } + _ => { + Self::check_response(response, path).await?; + Err(RemoteErr::HttpStatus { + status, + path: path.to_string(), + message: "range existence probe returned an unexpected response".to_string(), + }) + } } - Self::check_response(response, path).await?; - Ok(true) } async fn get_raw(&self, path: &str) -> Result> { @@ -2084,6 +2169,27 @@ mod tests { (format!("http://{address}/org/repo"), task) } + async fn serve_http_messages( + responses: &[&str], + ) -> (String, tokio::task::JoinHandle>>) { + let responses = responses + .iter() + .map(|response| response.as_bytes().to_vec()) + .collect::>(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + let mut requests = Vec::with_capacity(responses.len()); + for response in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + requests.push(read_http_request(&mut stream).await); + stream.write_all(&response).await.unwrap(); + } + requests + }); + (format!("http://{address}/org/repo"), task) + } + async fn serve_upload_bundle( manifest: serde_json::Value, objects: &[(&str, &[u8])], @@ -2560,7 +2666,7 @@ mod tests { } #[tokio::test] - async fn resetting_repository_credentials_starts_a_fresh_read_connection() { + async fn resetting_repository_credentials_starts_a_fresh_probe_connection() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -2627,13 +2733,26 @@ mod tests { } #[tokio::test] - async fn repository_credentials_separate_read_and_upload_connections() { + async fn repository_credentials_separate_read_probe_and_upload_connections() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let (mut read_stream, _) = listener.accept().await.unwrap(); - read_http_headers(&mut read_stream).await; + let read_request = read_http_request(&mut read_stream).await; read_stream + .write_all( + b"HTTP/1.1 404 Not Found\r\nGraft-Protocol: 1\r\nContent-Length: 0\r\n\r\n", + ) + .await + .unwrap(); + + let (mut probe_stream, _) = + tokio::time::timeout(Duration::from_secs(1), listener.accept()) + .await + .expect("probe reused the read connection") + .unwrap(); + let probe_request = read_http_request(&mut probe_stream).await; + probe_stream .write_all( b"HTTP/1.1 204 No Content\r\nGraft-Protocol: 1\r\nContent-Length: 0\r\n\r\n", ) @@ -2643,15 +2762,16 @@ mod tests { let (mut upload_stream, _) = tokio::time::timeout(Duration::from_secs(1), listener.accept()) .await - .expect("upload reused the read connection") + .expect("upload reused the read or probe connection") .unwrap(); - read_http_headers(&mut upload_stream).await; + let upload_request = read_http_request(&mut upload_stream).await; upload_stream .write_all( b"HTTP/1.1 204 No Content\r\nGraft-Protocol: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ) .await .unwrap(); + (read_request, probe_request, upload_request) }); let remote = RemoteConfig::Http { url: format!("http://{address}/org/repo"), @@ -2660,12 +2780,16 @@ mod tests { .build_with_credentials("origin", &RemoteCredentials::explicit()) .unwrap(); + assert!(remote.get_raw("refs/heads/main").await.unwrap().is_none()); assert!(remote.has_segment(&SegmentId::random()).await.unwrap()); remote .put_raw_if_not_exists("objects/one", Bytes::from_static(b"one")) .await .unwrap(); - server.await.unwrap(); + let (read_request, probe_request, upload_request) = server.await.unwrap(); + assert!(String::from_utf8_lossy(&read_request).starts_with("GET ")); + assert!(String::from_utf8_lossy(&probe_request).starts_with("HEAD ")); + assert!(String::from_utf8_lossy(&upload_request).starts_with("PUT ")); } async fn read_http_headers(stream: &mut tokio::net::TcpStream) { @@ -2827,6 +2951,224 @@ mod tests { request.await.unwrap(); } + #[tokio::test] + async fn http_remote_falls_back_to_range_get_when_head_is_not_supported() { + for (head_status, fallback_status) in [ + ("405 Method Not Allowed", "206 Partial Content"), + ("501 Not Implemented", "200 OK"), + ] { + let (url, requests) = serve_http_exchanges(&[head_status, fallback_status]).await; + let remote = HttpRemote::new(url, None).unwrap(); + + assert!(remote.has_raw("segments/example").await.unwrap()); + + let requests = requests.await.unwrap(); + let head = String::from_utf8_lossy(&requests[0]); + let fallback = String::from_utf8_lossy(&requests[1]); + assert!(head.starts_with("HEAD /org/repo/raw/segments/example ")); + assert!(fallback.starts_with("GET /org/repo/raw/segments/example ")); + assert!( + fallback + .lines() + .any(|line| line.eq_ignore_ascii_case("Range: bytes=0-0")) + ); + } + } + + #[tokio::test] + async fn http_remote_head_probe_succeeds_without_fallback() { + let (url, request) = serve_http_response("200 OK", &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + + assert!(remote.has_raw("segments/example").await.unwrap()); + assert!( + request + .await + .unwrap() + .starts_with("HEAD /org/repo/raw/segments/example ") + ); + } + + #[tokio::test] + async fn http_remote_head_probe_does_not_fallback_on_other_statuses() { + for (response_status, expected_status) in [ + ("401 Unauthorized", 401), + ("403 Forbidden", 403), + ("500 Internal Server Error", 500), + ] { + let (url, request) = serve_http_response(response_status, &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + let error = remote.has_raw("segments/example").await.unwrap_err(); + + assert!( + matches!(error, RemoteErr::HttpStatus { status, .. } if status == expected_status) + ); + assert!( + request + .await + .unwrap() + .starts_with("HEAD /org/repo/raw/segments/example ") + ); + } + + let (url, request) = serve_http_response("404 Not Found", &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(!remote.has_raw("segments/example").await.unwrap()); + request.await.unwrap(); + + let responses = [ + "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + "HTTP/1.1 206 Partial Content\r\nGraft-Protocol: 1\r\nContent-Length: 1\r\nConnection: close\r\n\r\nx", + ]; + let (url, requests) = serve_http_messages(&responses).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(remote.has_raw("segments/example").await.unwrap()); + assert_eq!(requests.await.unwrap().len(), 2); + } + + #[tokio::test] + async fn http_remote_range_probe_preserves_missing_and_error_statuses() { + let (url, requests) = + serve_http_exchanges(&["405 Method Not Allowed", "404 Not Found"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(!remote.has_raw("segments/example").await.unwrap()); + assert_eq!(requests.await.unwrap().len(), 2); + + for (fallback_status, expected_status) in [ + ("204 No Content", 204), + ("401 Unauthorized", 401), + ("500 Internal Server Error", 500), + ] { + let (url, requests) = + serve_http_exchanges(&["405 Method Not Allowed", fallback_status]).await; + let remote = HttpRemote::new(url, None).unwrap(); + let error = remote.has_raw("segments/example").await.unwrap_err(); + + assert!( + matches!(error, RemoteErr::HttpStatus { status, .. } if status == expected_status) + ); + assert_eq!(requests.await.unwrap().len(), 2); + } + } + + #[tokio::test] + async fn http_remote_head_transport_error_is_preserved_without_retry() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + drop(stream); + let no_retry = tokio::time::timeout(Duration::from_millis(250), listener.accept()) + .await + .is_err(); + (request, no_retry) + }); + let remote = HttpRemote::new(format!("http://{address}/org/repo"), None).unwrap(); + + assert!(matches!( + remote.has_raw("segments/example").await, + Err(RemoteErr::HttpTransport(_)) + )); + let (request, no_retry) = server.await.unwrap(); + assert!(String::from_utf8_lossy(&request).starts_with("HEAD ")); + assert!(no_retry, "HEAD transport error was retried"); + } + + #[tokio::test] + async fn http_remote_range_probe_transport_error_is_preserved() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut head_stream, _) = listener.accept().await.unwrap(); + read_http_request(&mut head_stream).await; + head_stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nGraft-Protocol: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + drop(head_stream); + + let (get_stream, _) = listener.accept().await.unwrap(); + drop(get_stream); + }); + let remote = HttpRemote::new(format!("http://{address}/org/repo"), None).unwrap(); + + assert!(matches!( + remote.has_raw("segments/example").await, + Err(RemoteErr::HttpTransport(_)) + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn http_remote_range_probe_recognizes_an_empty_segment() { + let responses = [ + "HTTP/1.1 405 Method Not Allowed\r\nGraft-Protocol: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + "HTTP/1.1 416 Range Not Satisfiable\r\nGraft-Protocol: 1\r\nContent-Range: bytes */0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ]; + let (url, requests) = serve_http_messages(&responses).await; + let remote = HttpRemote::new(url, None).unwrap(); + + assert!(remote.has_raw("segments/empty").await.unwrap()); + let requests = requests.await.unwrap(); + assert_eq!(requests.len(), 2); + assert!( + String::from_utf8_lossy(&requests[1]) + .lines() + .any(|line| line.eq_ignore_ascii_case("Range: bytes=0-0")) + ); + } + + #[tokio::test] + async fn http_remote_range_probe_does_not_read_a_full_segment() { + const LARGE_SEGMENT_BYTES: usize = 16 * 1024 * 1024; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut head_stream, _) = listener.accept().await.unwrap(); + read_http_request(&mut head_stream).await; + head_stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nGraft-Protocol: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + drop(head_stream); + + let (mut get_stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut get_stream).await; + let headers = format!( + "HTTP/1.1 200 OK\r\nGraft-Protocol: 1\r\nContent-Length: {LARGE_SEGMENT_BYTES}\r\n\r\n" + ); + get_stream.write_all(headers.as_bytes()).await.unwrap(); + get_stream.write_all(&[7]).await.unwrap(); + + let mut buffer = [0_u8; 1]; + let released = matches!( + tokio::time::timeout(Duration::from_secs(2), get_stream.read(&mut buffer)).await, + Ok(Ok(0)) + ); + (request, released) + }); + let remote = HttpRemote::new(format!("http://{address}/org/repo"), None).unwrap(); + + let exists = tokio::time::timeout(Duration::from_secs(1), remote.has_raw("segments/large")) + .await + .expect("range probe waited for the full segment") + .unwrap(); + assert!(exists); + + let (request, released) = server.await.unwrap(); + assert!( + String::from_utf8_lossy(&request) + .lines() + .any(|line| line.eq_ignore_ascii_case("Range: bytes=0-0")) + ); + assert!(released, "range probe response body was not released"); + } + #[tokio::test] async fn http_remote_follows_list_cursors() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/docs/src/content/docs/docs/reference/remote-protocol.mdx b/docs/src/content/docs/docs/reference/remote-protocol.mdx index e926abe4..8aa6ff42 100644 --- a/docs/src/content/docs/docs/reference/remote-protocol.mdx +++ b/docs/src/content/docs/docs/reference/remote-protocol.mdx @@ -151,6 +151,19 @@ Request bodies for `PUT` and `POST /cas` are the new raw bytes and SHOULD use A successful `HEAD` response MUST include the byte length that a corresponding full `GET` would return in `Content-Length`. +`HEAD /raw/` remains mandatory for version 1 services. For compatibility +with a legacy gateway that explicitly returns `405 Method Not Allowed` or `501 +Not Implemented`, a client MAY retry the existence probe with `GET /raw/` +and `Range: bytes=0-0`. `200` or `206` proves that the object exists, `404` +means it is absent, and `416` proves an existing empty object only when it also +includes `Content-Range: bytes */0`. The client MUST release a successful probe +response without consuming the full object when a gateway ignores `Range`, and +MUST NOT use this fallback for authentication failures, other status codes, or +transport errors. +The fallback response itself MUST still carry a valid `Graft-Protocol` header; +the initial `405` or `501` might be generated by a gateway before the protocol +service and therefore need not carry one. + Services MUST support `raw-if-not-exists` for immutable objects. A service SHOULD reject unconditional overwrite or deletion of immutable objects. Graft publishes data in this order: immutable objects first, pack index after its diff --git a/docs/src/content/docs/zh/docs/reference/remote-protocol.mdx b/docs/src/content/docs/zh/docs/reference/remote-protocol.mdx index 33c53878..71d006f0 100644 --- a/docs/src/content/docs/zh/docs/reference/remote-protocol.mdx +++ b/docs/src/content/docs/zh/docs/reference/remote-protocol.mdx @@ -132,6 +132,15 @@ encoding 必须被拒绝。服务可以公布 key/body 限制,并在超限时 `application/octet-stream`。mutation 成功响应没有 body。 成功的 `HEAD` 响应必须通过 `Content-Length` 返回对应完整 `GET` 的字节数。 +version 1 服务仍必须支持 `HEAD /raw/`。为了兼容明确返回 `405 Method Not +Allowed` 或 `501 Not Implemented` 的旧 gateway,client 可以用 +`GET /raw/` 和 `Range: bytes=0-0` 重试存在性探测。`200` 或 `206` 表示对象 +存在,`404` 表示不存在;只有同时包含 `Content-Range: bytes */0` 时,`416` 才 +表示一个已存在的空对象。当 gateway 忽略 `Range` 时,client 必须释放成功响应且 +不得继续读取完整对象;鉴权失败、其他状态码和 transport error 不得触发该 fallback。 +fallback 响应本身仍必须包含有效的 `Graft-Protocol` header;初始 `405` 或 `501` +可能由 protocol service 前的 gateway 生成,因此可以不包含该 header。 + 服务必须为不可变对象支持 `raw-if-not-exists`,并应该拒绝不可变对象的无条件覆盖 或删除。Graft 的发布顺序是:先发布不可变对象,pack 成功后发布 index,最后 CAS 更新 ref。ref 更新失败可以留下不可达对象,但不会暴露不完整 commit。 From 85e1a3d55693d4f75ae48993fd08814a5b7a94e4 Mon Sep 17 00:00:00 2001 From: Mayne Date: Fri, 31 Jul 2026 03:33:54 +0800 Subject: [PATCH 2/2] chore(sdk): prepare 0.3.2 release --- Cargo.lock | 4 ++-- crates/graft-sdk-node/Cargo.toml | 4 ++-- crates/graft-sdk/Cargo.toml | 2 +- packages/graft-sdk/package.json | 2 +- packages/graft-sdk/test/repository-session.test.js | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fac68f1a..204ebfcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1100,7 +1100,7 @@ dependencies = [ [[package]] name = "graft-sdk" -version = "0.3.1" +version = "0.3.2" dependencies = [ "blake3", "graft", @@ -1115,7 +1115,7 @@ dependencies = [ [[package]] name = "graft-sdk-node" -version = "0.3.1" +version = "0.3.2" dependencies = [ "graft-sdk", "napi", diff --git a/crates/graft-sdk-node/Cargo.toml b/crates/graft-sdk-node/Cargo.toml index 04c0194f..bb581660 100644 --- a/crates/graft-sdk-node/Cargo.toml +++ b/crates/graft-sdk-node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft-sdk-node" -version = "0.3.1" +version = "0.3.2" edition = "2024" authors.workspace = true license.workspace = true @@ -18,7 +18,7 @@ crate-type = ["cdylib"] workspace = true [dependencies] -graft-sdk = { path = "../graft-sdk", version = "0.3.1", features = ["bundled-sqlite"] } +graft-sdk = { path = "../graft-sdk", version = "0.3.2", features = ["bundled-sqlite"] } napi = { workspace = true } napi-derive = { workspace = true } serde_json = "1.0" diff --git a/crates/graft-sdk/Cargo.toml b/crates/graft-sdk/Cargo.toml index 33315c73..e4231d99 100644 --- a/crates/graft-sdk/Cargo.toml +++ b/crates/graft-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft-sdk" -version = "0.3.1" +version = "0.3.2" edition = "2024" authors.workspace = true license.workspace = true diff --git a/packages/graft-sdk/package.json b/packages/graft-sdk/package.json index 3ffd5fd5..c86457ea 100644 --- a/packages/graft-sdk/package.json +++ b/packages/graft-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@eidos.space/graft", - "version": "0.3.1", + "version": "0.3.2", "description": "Long-lived in-process Graft repository sessions for Node.js and Electron.", "license": "MIT OR Apache-2.0", "repository": { diff --git a/packages/graft-sdk/test/repository-session.test.js b/packages/graft-sdk/test/repository-session.test.js index 2d2532d4..8cd21ce7 100644 --- a/packages/graft-sdk/test/repository-session.test.js +++ b/packages/graft-sdk/test/repository-session.test.js @@ -24,7 +24,7 @@ const { } = require("..") test("exposes ABI-stable SDK metadata and materialization contract", () => { - assert.equal(sdkVersion(), "0.3.1") + assert.equal(sdkVersion(), "0.3.2") for (const operation of [ "restore", "restorePaths",