From df064dcf43ef3042e994a6fbd2ecaa67d598db8c Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 02:55:36 +1100 Subject: [PATCH 1/9] Generate keyset reads over complete composite primary keys --- CHANGELOG.md | 2 + MIGRATION.md | 4 + crates/graphql-orm-macros/README.md | 4 + crates/graphql-orm-macros/src/operations.rs | 70 +++++++++--- crates/graphql-orm/README.md | 4 + .../tests/composite_keyset_pagination.rs | 101 ++++++++++++++++++ 6 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 crates/graphql-orm/tests/composite_keyset_pagination.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0810262a..fd80ffdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Companion runtime/macros version: **0.58.0**. - Extend explicit `timestamp = "host"` to public GraphQLEntity fields and ordinary String timestamps (including optional forms). Caller-supplied source timestamps stay unchanged instead of receiving the ORM mutation clock. Existing automatic timestamp behavior remains the default. Policies, hooks and field restrictions still apply. +- Generate static keyset reads and authorized scans for entities with composite primary keys. Configured order must end with the complete primary-key tuple in declaration order, with no duplicate order fields. Every component is retained in cursor ordering; existing authorization, limits and read-only schema posture remain in force. + ## 0.57.0 (2026-10-22) Companion macros crate: `graphql-orm-macros` **0.57.0**. diff --git a/MIGRATION.md b/MIGRATION.md index 7213fd1f..38fecec2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -19,6 +19,10 @@ published to crates.io. Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity timestamp fields may explicitly use `#[graphql_orm(timestamp = "host")]` on ordinary `i64`, `String`, or optional forms whose Rust or physical name is `created_at` or `updated_at`. For an external source timestamp mapped to physical `updated_at`, opt in on the mapped field to prevent automatic epoch assignment. Values are bound unchanged; String content is caller-owned and is not parsed or normalized as a date. Existing automatic behavior remains unchanged without the annotation. The annotation does not change stored type/default metadata; conventional Rust names still need `default = false` to suppress their implicit default when appropriate. Schema-only, generated/version/key/relation fields and date/JSON/boolean adapters remain rejected. No data or schema migration is required merely to opt in; preserve field/row authorization and distinguish source observation time from collection and mutation clocks. +## 0.57.0 to 0.58.0: composite-primary-key keysets + +Upgrade runtime/macros together. Composite-key entities may now declare `keyset = "rank desc, partition_key asc, item_no asc"` when `(partition_key, item_no)` is the primary key in declaration order. Every primary-key member must appear as the final unique tiebreaker tuple; order fields may not repeat. The generated keyset and scan read surfaces use the existing bounded, authorized cursor implementation. Existing valid single-key orders/cursors are unchanged. No generated GraphQL writes or subscriptions are enabled for composite-key entities by this change. No data or schema migration is required, and an externally managed source stays externally managed. + ## 0.56.0 to 0.57.0: exact SQL Server MONEY reads Upgrade `graphql-orm` and `graphql-orm-macros` together to 0.57.0 using one diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index 03de127d..f4152602 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -14,6 +14,10 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +Version 0.58.0 supports static keyset reads with a complete composite-primary-key +tiebreaker tuple. Schema ownership and write posture are unchanged. See the +[migration guide](../../MIGRATION.md#0570-to-0580-composite-primary-key-keysets). + Version 0.57.0 adds the SQL Server-only `#[graphql_orm(mssql_money)]` field annotation and identifier-safe exact read projections for native `MONEY`. It pairs with runtime 0.57.0; see the diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 46c80711..3b049ac3 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -1534,12 +1534,6 @@ pub(crate) fn generate_graphql_operations( } } let keyset_parts = if let Some(config) = entity_meta.keyset.as_deref() { - if has_composite_primary_key { - return Err(syn::Error::new_spanned( - struct_name, - "keyset pagination currently requires a single-column primary key", - )); - } let mut parts = Vec::new(); for raw in config.split(',') { let tokens = raw.split_whitespace().collect::>(); @@ -1597,16 +1591,32 @@ pub(crate) fn generate_graphql_operations( nulls_first, )); } - if parts.is_empty() - || parts - .last() - .is_none_or(|(field, _, _, _, _)| field != &pk_field) - { + let complete_key_suffix = parts.len() >= primary_key_fields.len() + && parts[parts.len().saturating_sub(primary_key_fields.len())..] + .iter() + .zip(&primary_key_fields) + .all(|((field, _, _, _, _), key)| field == &key.field_name); + if !complete_key_suffix { return Err(syn::Error::new_spanned( struct_name, - "keyset order must end with the primary key as a unique tiebreaker", + if has_composite_primary_key { + "keyset order must end with every primary-key field in declaration order as a unique tiebreaker" + } else { + "keyset order must end with the primary key as a unique tiebreaker" + }, )); } + if has_composite_primary_key { + let mut ordered_fields = std::collections::BTreeSet::new(); + for (field, _, _, _, _) in &parts { + if !ordered_fields.insert(field.to_string()) { + return Err(syn::Error::new_spanned( + field, + "composite keyset order must not repeat a field", + )); + } + } + } Some(parts) } else { None @@ -9321,3 +9331,39 @@ pub(crate) fn generate_graphql_operations( } }) } + +#[cfg(test)] +mod composite_keyset_tests { + use super::generate_graphql_operations; + + fn entity(order: &str) -> syn::DeriveInput { + let order = syn::LitStr::new(order, proc_macro2::Span::call_site()); + syn::parse_quote! { + #[graphql_entity(table = "composite_keyset_fixture", keyset = #order)] + struct CompositeKeysetFixture { + #[primary_key] + #[graphql_orm(auto_generated = false)] + partition_key: String, + #[primary_key] + #[graphql_orm(auto_generated = false)] + item_no: i64, + rank: i64, + } + } + } + + #[test] + fn complete_tuple_is_required_as_the_unique_order_suffix() { + assert!(generate_graphql_operations(&entity("rank desc, partition_key asc, item_no asc")).is_ok()); + for order in ["rank desc, partition_key asc", "rank desc, item_no asc", "rank desc, item_no asc, partition_key asc"] { + let error = generate_graphql_operations(&entity(order)).unwrap_err(); + assert!(error.to_string().contains("every primary-key field"), "{error}"); + } + } + + #[test] + fn duplicate_composite_order_fields_are_rejected() { + let error = generate_graphql_operations(&entity("partition_key asc, rank desc, partition_key asc, item_no asc")).unwrap_err(); + assert!(error.to_string().contains("must not repeat a field"), "{error}"); + } +} diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index c8ad373a..ed5f2e31 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -14,6 +14,10 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +Version 0.58.0 supports static keyset reads with a complete composite-primary-key +tiebreaker tuple. Schema ownership and write posture are unchanged. See the +[migration guide](../../MIGRATION.md#0570-to-0580-composite-primary-key-keysets). + Version 0.57.0 adds exact SQL Server `MONEY` typed reads through `#[graphql_orm(mssql_money)]`. The generated read projection casts the native column to `DECIMAL(19,4)` under its original alias before decoding; ordinary diff --git a/crates/graphql-orm/tests/composite_keyset_pagination.rs b/crates/graphql-orm/tests/composite_keyset_pagination.rs new file mode 100644 index 00000000..be491ba6 --- /dev/null +++ b/crates/graphql-orm/tests/composite_keyset_pagination.rs @@ -0,0 +1,101 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::prelude::*; +use std::collections::BTreeSet; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + table = "composite_keyset_records", + plural = "CompositeKeysetRecords", + keyset = "rank desc nulls last, partition_key asc, item_no asc" +)] +struct CompositeRecord { + #[primary_key] + #[graphql_orm(auto_generated = false, db_column = "partition")] + #[filterable(type = "string")] + partition_key: String, + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "number")] + item_no: i64, + #[filterable(type = "number")] + rank: Option, +} + +schema_roots! { + query_custom_ops: [], + generated_mutations: "none", + entities: [CompositeRecord], +} + +async fn setup() -> graphql_orm::Result> { + let database = Database::::connect_sqlite("sqlite::memory:").await?; + // This database and schema are disposable fixture-owned state, never an external source. + sqlx::query("CREATE TABLE composite_keyset_records (\"partition\" TEXT NOT NULL, item_no INTEGER NOT NULL, rank INTEGER, PRIMARY KEY (\"partition\", item_no))") + .execute(database.pool()).await?; + for (partition, item, rank) in [ + ("a", 1, Some(3_i64)), + ("a", 2, Some(3)), + ("b", 1, Some(3)), + ("b", 2, Some(2)), + ("a", 3, None), + ("b", 3, None), + ] { + sqlx::query("INSERT INTO composite_keyset_records (\"partition\", item_no, rank) VALUES (?, ?, ?)") + .bind(partition).bind(item).bind(rank).execute(database.pool()).await?; + } + Ok(database) +} + +#[tokio::test] +async fn forward_pages_keep_complete_composite_identity_and_nullable_order() -> graphql_orm::Result<()> { + let database = setup().await?; + let mut after = None; + let mut keys = Vec::new(); + for page_index in 0..4 { + let page = CompositeRecord::keyset_page( + &database, + CompositeRecordWhereInput::default(), + KeysetPageInput { after, limit: Some(2), include_total_count: false }, + ).await.expect("composite keyset page"); + keys.extend(page.edges.into_iter().map(|edge| (edge.node.partition_key, edge.node.item_no))); + if page_index == 0 { + sqlx::query("INSERT INTO composite_keyset_records (\"partition\", item_no, rank) VALUES ('new-before-cursor', 1, 99)") + .execute(database.pool()).await?; + } + if !page.page_info.has_next_page { break; } + after = page.page_info.end_cursor; + } + assert_eq!(keys, vec![("a".into(), 1), ("a".into(), 2), ("b".into(), 1), ("b".into(), 2), ("a".into(), 3), ("b".into(), 3)]); + assert_eq!(keys.iter().collect::>().len(), 6); + Ok(()) +} + +#[tokio::test] +async fn backward_pages_keep_the_complete_key_tiebreaker() -> graphql_orm::Result<()> { + let database = setup().await?; + let tail = CompositeRecord::keyset_connection_page( + &database, CompositeRecordWhereInput::default(), + graphql_orm::graphql::pagination::KeysetConnectionInput { last: Some(2), ..Default::default() }, + ).await.expect("composite tail"); + assert_eq!(tail.edges.iter().map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)).collect::>(), [("a", 3), ("b", 3)]); + let older = CompositeRecord::keyset_connection_page( + &database, CompositeRecordWhereInput::default(), + graphql_orm::graphql::pagination::KeysetConnectionInput { last: Some(2), before: tail.page_info.start_cursor, ..Default::default() }, + ).await.expect("composite older window"); + assert_eq!(older.edges.iter().map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)).collect::>(), [("b", 1), ("b", 2)]); + Ok(()) +} + +#[tokio::test] +async fn generated_composite_keyset_is_admitted_without_graphql_writes() -> graphql_orm::Result<()> { + let schema = schema_builder(setup().await?).data("test-user".to_string()).finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("compositeKeysetRecordsKeyset(")); + assert!(sdl.contains("compositeKeysetRecordsScan(")); + assert!(!sdl.contains("createCompositeRecord(")); + let response = schema.execute("{ compositeKeysetRecordsKeyset(page: {limit: 2}) { edges { node { partitionKey itemNo } } } }").await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!(response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"]["edges"].as_array().unwrap().len(), 2); + Ok(()) +} From b339f8976130e1ea014f8f95e09b6bd626a402cc Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 03:02:13 +1100 Subject: [PATCH 2/9] Format composite keyset implementation with Rust 1.97.1 --- crates/graphql-orm-macros/src/operations.rs | 26 ++++- .../tests/composite_keyset_pagination.rs | 102 ++++++++++++++---- 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 3b049ac3..1a72ea0b 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -9354,16 +9354,32 @@ mod composite_keyset_tests { #[test] fn complete_tuple_is_required_as_the_unique_order_suffix() { - assert!(generate_graphql_operations(&entity("rank desc, partition_key asc, item_no asc")).is_ok()); - for order in ["rank desc, partition_key asc", "rank desc, item_no asc", "rank desc, item_no asc, partition_key asc"] { + assert!( + generate_graphql_operations(&entity("rank desc, partition_key asc, item_no asc")) + .is_ok() + ); + for order in [ + "rank desc, partition_key asc", + "rank desc, item_no asc", + "rank desc, item_no asc, partition_key asc", + ] { let error = generate_graphql_operations(&entity(order)).unwrap_err(); - assert!(error.to_string().contains("every primary-key field"), "{error}"); + assert!( + error.to_string().contains("every primary-key field"), + "{error}" + ); } } #[test] fn duplicate_composite_order_fields_are_rejected() { - let error = generate_graphql_operations(&entity("partition_key asc, rank desc, partition_key asc, item_no asc")).unwrap_err(); - assert!(error.to_string().contains("must not repeat a field"), "{error}"); + let error = generate_graphql_operations(&entity( + "partition_key asc, rank desc, partition_key asc, item_no asc", + )) + .unwrap_err(); + assert!( + error.to_string().contains("must not repeat a field"), + "{error}" + ); } } diff --git a/crates/graphql-orm/tests/composite_keyset_pagination.rs b/crates/graphql-orm/tests/composite_keyset_pagination.rs index be491ba6..44e77ec2 100644 --- a/crates/graphql-orm/tests/composite_keyset_pagination.rs +++ b/crates/graphql-orm/tests/composite_keyset_pagination.rs @@ -41,14 +41,21 @@ async fn setup() -> graphql_orm::Result> { ("a", 3, None), ("b", 3, None), ] { - sqlx::query("INSERT INTO composite_keyset_records (\"partition\", item_no, rank) VALUES (?, ?, ?)") - .bind(partition).bind(item).bind(rank).execute(database.pool()).await?; + sqlx::query( + "INSERT INTO composite_keyset_records (\"partition\", item_no, rank) VALUES (?, ?, ?)", + ) + .bind(partition) + .bind(item) + .bind(rank) + .execute(database.pool()) + .await?; } Ok(database) } #[tokio::test] -async fn forward_pages_keep_complete_composite_identity_and_nullable_order() -> graphql_orm::Result<()> { +async fn forward_pages_keep_complete_composite_identity_and_nullable_order() +-> graphql_orm::Result<()> { let database = setup().await?; let mut after = None; let mut keys = Vec::new(); @@ -56,17 +63,39 @@ async fn forward_pages_keep_complete_composite_identity_and_nullable_order() -> let page = CompositeRecord::keyset_page( &database, CompositeRecordWhereInput::default(), - KeysetPageInput { after, limit: Some(2), include_total_count: false }, - ).await.expect("composite keyset page"); - keys.extend(page.edges.into_iter().map(|edge| (edge.node.partition_key, edge.node.item_no))); + KeysetPageInput { + after, + limit: Some(2), + include_total_count: false, + }, + ) + .await + .expect("composite keyset page"); + keys.extend( + page.edges + .into_iter() + .map(|edge| (edge.node.partition_key, edge.node.item_no)), + ); if page_index == 0 { sqlx::query("INSERT INTO composite_keyset_records (\"partition\", item_no, rank) VALUES ('new-before-cursor', 1, 99)") .execute(database.pool()).await?; } - if !page.page_info.has_next_page { break; } + if !page.page_info.has_next_page { + break; + } after = page.page_info.end_cursor; } - assert_eq!(keys, vec![("a".into(), 1), ("a".into(), 2), ("b".into(), 1), ("b".into(), 2), ("a".into(), 3), ("b".into(), 3)]); + assert_eq!( + keys, + vec![ + ("a".into(), 1), + ("a".into(), 2), + ("b".into(), 1), + ("b".into(), 2), + ("a".into(), 3), + ("b".into(), 3) + ] + ); assert_eq!(keys.iter().collect::>().len(), 6); Ok(()) } @@ -75,27 +104,62 @@ async fn forward_pages_keep_complete_composite_identity_and_nullable_order() -> async fn backward_pages_keep_the_complete_key_tiebreaker() -> graphql_orm::Result<()> { let database = setup().await?; let tail = CompositeRecord::keyset_connection_page( - &database, CompositeRecordWhereInput::default(), - graphql_orm::graphql::pagination::KeysetConnectionInput { last: Some(2), ..Default::default() }, - ).await.expect("composite tail"); - assert_eq!(tail.edges.iter().map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)).collect::>(), [("a", 3), ("b", 3)]); + &database, + CompositeRecordWhereInput::default(), + graphql_orm::graphql::pagination::KeysetConnectionInput { + last: Some(2), + ..Default::default() + }, + ) + .await + .expect("composite tail"); + assert_eq!( + tail.edges + .iter() + .map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)) + .collect::>(), + [("a", 3), ("b", 3)] + ); let older = CompositeRecord::keyset_connection_page( - &database, CompositeRecordWhereInput::default(), - graphql_orm::graphql::pagination::KeysetConnectionInput { last: Some(2), before: tail.page_info.start_cursor, ..Default::default() }, - ).await.expect("composite older window"); - assert_eq!(older.edges.iter().map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)).collect::>(), [("b", 1), ("b", 2)]); + &database, + CompositeRecordWhereInput::default(), + graphql_orm::graphql::pagination::KeysetConnectionInput { + last: Some(2), + before: tail.page_info.start_cursor, + ..Default::default() + }, + ) + .await + .expect("composite older window"); + assert_eq!( + older + .edges + .iter() + .map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)) + .collect::>(), + [("b", 1), ("b", 2)] + ); Ok(()) } #[tokio::test] -async fn generated_composite_keyset_is_admitted_without_graphql_writes() -> graphql_orm::Result<()> { - let schema = schema_builder(setup().await?).data("test-user".to_string()).finish(); +async fn generated_composite_keyset_is_admitted_without_graphql_writes() -> graphql_orm::Result<()> +{ + let schema = schema_builder(setup().await?) + .data("test-user".to_string()) + .finish(); let sdl = schema.sdl(); assert!(sdl.contains("compositeKeysetRecordsKeyset(")); assert!(sdl.contains("compositeKeysetRecordsScan(")); assert!(!sdl.contains("createCompositeRecord(")); let response = schema.execute("{ compositeKeysetRecordsKeyset(page: {limit: 2}) { edges { node { partitionKey itemNo } } } }").await; assert!(response.errors.is_empty(), "{:?}", response.errors); - assert_eq!(response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"]["edges"].as_array().unwrap().len(), 2); + assert_eq!( + response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"]["edges"] + .as_array() + .unwrap() + .len(), + 2 + ); Ok(()) } From ba64ca191f0ca0b446951ce59448a380e611872f Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 05:15:27 +1100 Subject: [PATCH 3/9] fix: provide sortable public fields for generated order inputs --- crates/graphql-orm/tests/composite_keyset_pagination.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/graphql-orm/tests/composite_keyset_pagination.rs b/crates/graphql-orm/tests/composite_keyset_pagination.rs index 44e77ec2..c7baeab6 100644 --- a/crates/graphql-orm/tests/composite_keyset_pagination.rs +++ b/crates/graphql-orm/tests/composite_keyset_pagination.rs @@ -11,6 +11,7 @@ use std::collections::BTreeSet; )] struct CompositeRecord { #[primary_key] + #[sortable] #[graphql_orm(auto_generated = false, db_column = "partition")] #[filterable(type = "string")] partition_key: String, From 87cf7139702a8fceeaf1ae79330b4bfe8c731fb3 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 07:22:45 +1100 Subject: [PATCH 4/9] fix(orm): authorize complete composite cursor ordering fields --- CHANGELOG.md | 4 + MIGRATION.md | 7 + crates/graphql-orm-macros/README.md | 5 + crates/graphql-orm-macros/src/operations.rs | 14 ++ crates/graphql-orm/README.md | 5 + .../src/graphql/orm/authorized_pagination.rs | 47 +++++ .../tests/composite_keyset_pagination.rs | 27 +++ .../tests/keyset_cursor_field_authority.rs | 187 ++++++++++++++++++ 8 files changed, 296 insertions(+) create mode 100644 crates/graphql-orm/tests/keyset_cursor_field_authority.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fd80ffdc..231a1714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ Companion runtime/macros version: **0.58.0**. - Extend explicit `timestamp = "host"` to public GraphQLEntity fields and ordinary String timestamps (including optional forms). Caller-supplied source timestamps stay unchanged instead of receiving the ORM mutation clock. Existing automatic timestamp behavior remains the default. Policies, hooks and field restrictions still apply. +- Revalidate configured cursor ordering and all composite key fields before SQL + and before release, independently of the selected payload. Reject hidden raw + cursor values on GraphQL keyset and scan surfaces. + - Generate static keyset reads and authorized scans for entities with composite primary keys. Configured order must end with the complete primary-key tuple in declaration order, with no duplicate order fields. Every component is retained in cursor ordering; existing authorization, limits and read-only schema posture remain in force. ## 0.57.0 (2026-10-22) diff --git a/MIGRATION.md b/MIGRATION.md index 38fecec2..8a5debc6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -21,6 +21,13 @@ Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity times ## 0.57.0 to 0.58.0: composite-primary-key keysets +Configured cursors carry raw ordering values, so their fields now require current +read authority even when clients select only `cursor`. GraphQL rejects private, +skipped and transformed cursor fields. Repository policies remain independent. +Manual `AuthorizedKeysetEntity` implementations must supply `KEYSET_CURSOR_FIELDS`; +missing field metadata fails closed. Generated derives populate it automatically. +Ordering, fingerprints and cursor bytes remain unchanged for authorized reads. + Upgrade runtime/macros together. Composite-key entities may now declare `keyset = "rank desc, partition_key asc, item_no asc"` when `(partition_key, item_no)` is the primary key in declaration order. Every primary-key member must appear as the final unique tiebreaker tuple; order fields may not repeat. The generated keyset and scan read surfaces use the existing bounded, authorized cursor implementation. Existing valid single-key orders/cursors are unchanged. No generated GraphQL writes or subscriptions are enabled for composite-key entities by this change. No data or schema migration is required, and an externally managed source stays externally managed. ## 0.56.0 to 0.57.0: exact SQL Server MONEY reads diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index f4152602..ccefac92 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -14,6 +14,11 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +Configured keyset cursors require current read authority for every order field +and complete primary-key suffix, even for cursor-only selections. Checks run +before queries and again against returned rows. Private, skipped or transformed +GraphQL order fields fail closed; repository field policy remains independent. + Version 0.58.0 supports static keyset reads with a complete composite-primary-key tiebreaker tuple. Schema ownership and write posture are unchanged. See the [migration guide](../../MIGRATION.md#0570-to-0580-composite-primary-key-keysets). diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 1a72ea0b..431f2863 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -4273,9 +4273,22 @@ pub(crate) fn generate_graphql_operations( let keyset_generated = if let Some(parts) = &keyset_parts { let mut order_tokens = Vec::new(); let mut cursor_tokens = Vec::new(); + let mut cursor_field_metadata = Vec::new(); let mut fingerprint_parts = Vec::new(); for (field, ty, column, ascending, nulls_first) in parts { let quoted_column = backend_quote_identifier_path(backend, column); + let source = fields.iter().find(|candidate| candidate.ident.as_ref() == Some(field)) + .expect("keyset field was resolved during parsing"); + let meta = parse_field_metadata(source)?; + let api_name = graphql_field_name(&meta, &field.to_string(), graphql_rename_fields, serde_rename_all); + let graphql_readable = meta.read && !meta.input_only && !meta.graphql_skip + && meta.transform_read.is_none() && meta.transform_write.is_none(); + let policy = match meta.read_policy.as_deref() { + Some(policy) => quote! { Some(#policy) }, None => quote! { None }, + }; + cursor_field_metadata.push(quote! { ::graphql_orm::graphql::orm::KeysetCursorField { + column: #quoted_column, api_name: #api_name, read_policy: #policy, graphql_readable: #graphql_readable, + } }); let direction = if *ascending { quote! { ::graphql_orm::graphql::orm::KeysetOrderColumn::asc(#quoted_column) } } else { @@ -4343,6 +4356,7 @@ pub(crate) fn generate_graphql_operations( } impl ::graphql_orm::graphql::orm::AuthorizedKeysetEntity for #struct_name { + const KEYSET_CURSOR_FIELDS: &'static [::graphql_orm::graphql::orm::KeysetCursorField] = &[#(#cursor_field_metadata),*]; const KEYSET_ORDER: &'static [::graphql_orm::graphql::orm::KeysetOrderColumn] = &[#(#order_tokens),*]; const KEYSET_FINGERPRINT: &'static str = #fingerprint; fn keyset_values(&self) -> Vec<::graphql_orm::graphql::pagination::KeysetValue> { diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index ed5f2e31..3a30c3dc 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -14,6 +14,11 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +Configured keyset cursors require current read authority for every order field +and complete primary-key suffix, even for cursor-only selections. Checks run +before queries and again against returned rows. Private, skipped or transformed +GraphQL order fields fail closed; repository field policy remains independent. + Version 0.58.0 supports static keyset reads with a complete composite-primary-key tiebreaker tuple. Schema ownership and write posture are unchanged. See the [migration guide](../../MIGRATION.md#0570-to-0580-composite-primary-key-keysets). diff --git a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs index 79bf7e7a..779a05e2 100644 --- a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs +++ b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs @@ -168,9 +168,22 @@ impl ReadVisibility { pub trait AuthorizedKeysetEntity: Entity { const KEYSET_ORDER: &'static [KeysetOrderColumn]; const KEYSET_FINGERPRINT: &'static str; + /// Authority for every configured ordering value carried by a cursor. + /// Missing metadata fails closed; generated derives supply it. + const KEYSET_CURSOR_FIELDS: &'static [KeysetCursorField] = &[]; fn keyset_values(&self) -> Vec; } +/// Configured ordering metadata, independent of GraphQL payload selection. +#[derive(Clone, Copy, Debug)] +pub struct KeysetCursorField { + pub column: &'static str, + pub api_name: &'static str, + pub read_policy: Option<&'static str>, + /// Raw cursor values may be public only for readable, untransformed scalars. + pub graphql_readable: bool, +} + /// Diagnostics for actual fetched database rows, before entity decoding. /// SQL contains placeholders; parameter values are deliberately omitted. pub trait ReadQueryObserver: Send + Sync { @@ -367,6 +380,30 @@ where B: OrmBackend, T: AuthorizedKeysetEntity + FromSqlRow + Clone + Send + Sync + 'static, { + async fn authorize_keyset_order_fields( + db: &Database, + ctx: Option<&async_graphql::Context<'_>>, + order: &[KeysetOrderColumn], + row: Option<&T>, + ) -> async_graphql::Result<()> { + for part in order { + let field = T::KEYSET_CURSOR_FIELDS.iter().find(|field| field.column == part.column) + .ok_or_else(invalid)?; + let (api_name, policy, graphql_readable) = (field.api_name, field.read_policy, field.graphql_readable); + let record = row.map(|row| row as &(dyn std::any::Any + Send + Sync)); + if let Some(ctx) = ctx { + if !graphql_readable { + return Err(OrmPublicError::forbidden().into_graphql_error()); + } + db.ensure_readable_field(ctx, T::entity_name(), api_name, policy, record).await?; + } else { + db.ensure_repository_readable_field(None, T::entity_name(), api_name, policy, record) + .await.map_err(|error| error.into_graphql_error())?; + } + } + Ok(()) + } + fn keyset_bound( mut self, values: &[KeysetValue], @@ -475,6 +512,7 @@ where .map_err(|e| e.into_graphql_error())?; let backward = page.direction == KeysetWindowDirection::Backward; let visibility = self.authorize_pagination(db, ctx).await?; + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; if visibility.requires_residual_checks() && (ctx.is_some() || page.include_total_count) { return Err(invalid()); } @@ -547,6 +585,10 @@ where (has_more, opposite) }; Self::authorize_page_fields(db, ctx, &nodes).await?; + for node in &nodes { + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, Some(node)).await?; + } + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; let edges = nodes .into_iter() .map(|node| Edge { @@ -577,6 +619,7 @@ where page: AuthorizedScanInput, ) -> async_graphql::Result> { let visibility = self.authorize_pagination(db, ctx).await?; + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; let config = db.authorized_scan_config().ok_or_else(invalid)?; if config.batch_size == 0 || config.max_scanned_rows == 0 { return Err(invalid()); @@ -659,6 +702,10 @@ where } }; Self::authorize_page_fields(db, ctx, &nodes).await?; + for node in &nodes { + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, Some(node)).await?; + } + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; let continuation = if status == ScanStatus::Exhausted { None } else { diff --git a/crates/graphql-orm/tests/composite_keyset_pagination.rs b/crates/graphql-orm/tests/composite_keyset_pagination.rs index c7baeab6..468b184b 100644 --- a/crates/graphql-orm/tests/composite_keyset_pagination.rs +++ b/crates/graphql-orm/tests/composite_keyset_pagination.rs @@ -164,3 +164,30 @@ async fn generated_composite_keyset_is_admitted_without_graphql_writes() -> grap ); Ok(()) } + + +struct CompositeCursorAuthority(&'static str); +impl FieldPolicy for CompositeCursorAuthority { + fn can_read_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, field: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(field != self.0) }) + } + fn can_write_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, _: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } +} +struct CompositeReadCounter(std::sync::Arc); +impl ReadQueryObserver for CompositeReadCounter { + fn on_read(&self, _: &str, _: usize) { self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } +} +#[tokio::test] +async fn cursor_only_composite_reads_require_rank_and_both_key_components_before_select() { + for denied in ["rank", "partitionKey", "itemNo"] { + let mut database = setup().await.unwrap(); + database.set_field_policy(CompositeCursorAuthority(denied)); + let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let database = database.with_read_query_observer(CompositeReadCounter(reads.clone())); + let schema = schema_builder(database).data(AuthSubject::new("fixture-principal")).finish(); + let response = schema.execute("{ compositeKeysetRecordsKeyset(page: {limit: 2}) { edges { cursor } } }").await; + assert!(!response.errors.is_empty(), "unselected cursor component {denied} was not checked"); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "denied cursor field reached SELECT"); + assert!(response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"].is_null()); + } +} diff --git a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs new file mode 100644 index 00000000..501e49bc --- /dev/null +++ b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs @@ -0,0 +1,187 @@ +#![cfg(feature = "sqlite")] +use graphql_orm::prelude::*; +use graphql_orm::async_graphql::Request; +use std::sync::{Arc, atomic::{AtomicBool, AtomicUsize, Ordering}}; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity(table = "cursor_policy_records", plural = "CursorPolicyRecords", keyset = "protected_rank asc, id asc")] +struct CursorPolicyRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[sortable] + id: String, + #[graphql_orm(read_policy = "rank.read")] + #[sortable] + protected_rank: i64, +} +schema_roots! { query_custom_ops: [], generated_mutations: "none", entities: [CursorPolicyRecord], } + +#[derive(Clone)] +struct RankAuthority { + allowed: Arc, + deny_rows: Arc, + deny_keys: Arc, +} +impl RankAuthority { + fn new() -> Self { Self { allowed: Arc::new(AtomicBool::new(true)), deny_rows: Arc::new(AtomicBool::new(false)), deny_keys: Arc::new(AtomicBool::new(false)) } } +} +impl FieldPolicy for RankAuthority { + fn can_read_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, field: &'static str, _: Option<&'static str>, record: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(match field { "protectedRank" => self.allowed.load(Ordering::SeqCst) && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)), "id" => !self.deny_keys.load(Ordering::SeqCst), _ => true }) }) + } + fn can_read_repository_field<'a>(&'a self, _: Option>, _: &'a Database, _: &'static str, field: &'static str, _: Option<&'static str>, record: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(match field { "protectedRank" => self.allowed.load(Ordering::SeqCst) && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)), "id" => !self.deny_keys.load(Ordering::SeqCst), _ => true }) }) + } + fn can_write_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, _: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } +} + +#[tokio::test] +async fn configured_keyset_cursor_only_selection_requires_unselected_order_field_authority() { + let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + let authority = RankAuthority::new(); + db.set_field_policy(authority.clone()); + let schema = schema_builder(db).finish(); + let request = || Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }").data(AuthSubject::new("fixture-principal")); + let before = schema.execute(request()).await; + assert!(before.errors.is_empty(), "{:?}", before.errors); + let value = before.data.into_json().unwrap(); + let cursor = value["cursorPolicyRecordsKeyset"]["edges"][0]["cursor"].as_str().unwrap(); + assert!(cursor.starts_with("gomk1.")); + authority.allowed.store(false, Ordering::SeqCst); + let denied = schema.execute(request()).await; + assert!(!denied.errors.is_empty(), "a cursor-only selection must not bypass the configured ordering field's current read policy"); + assert!(denied.data.into_json().unwrap()["cursorPolicyRecordsKeyset"].is_null()); +} + +struct Observe { + reads: Arc, + revoke: Option>, +} +impl ReadQueryObserver for Observe { + fn on_read(&self, _: &str, _: usize) { + self.reads.fetch_add(1, Ordering::SeqCst); + if let Some(allowed) = &self.revoke { allowed.store(false, Ordering::SeqCst); } + } +} +async fn setup() -> (Database, RankAuthority, Arc) { + let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + let authority = RankAuthority::new(); + db.set_field_policy(authority.clone()); + let reads = Arc::new(AtomicUsize::new(0)); + let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }); + (db, authority, reads) +} +#[tokio::test] +async fn configured_orders_deny_before_select_even_for_cursor_only_payload() { + for denied in ["rank", "key"] { + let (db, authority, reads) = setup().await; + if denied == "rank" { authority.allowed.store(false, Ordering::SeqCst); } + else { authority.deny_keys.store(true, Ordering::SeqCst); } + let schema = schema_builder(db).finish(); + let response = schema.execute(Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }").data(AuthSubject::new("fixture-principal"))).await; + assert!(!response.errors.is_empty()); + assert_eq!(reads.load(Ordering::SeqCst), 0); + } +} +#[tokio::test] +async fn row_field_denial_and_permission_change_during_read_never_release_cursor() { + for during_read in [false, true] { + let (db, authority, reads) = setup().await; + let db = if during_read { + db.with_read_query_observer(Observe { reads: reads.clone(), revoke: Some(authority.allowed.clone()) }) + } else { authority.deny_rows.store(true, Ordering::SeqCst); db }; + let schema = schema_builder(db).finish(); + let response = schema.execute(Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }").data(AuthSubject::new("fixture-principal"))).await; + assert!(!response.errors.is_empty()); + assert!(reads.load(Ordering::SeqCst) > 0); + assert!(response.data.into_json().unwrap()["cursorPolicyRecordsKeyset"].is_null()); + } +} +#[tokio::test] +async fn repository_backward_and_scan_paths_retain_current_field_checks() { + let (db, authority, reads) = setup().await; + let configured = CursorPolicyRecord::keyset_page(&db, Default::default(), Default::default()).await.unwrap(); + assert!(configured.page_info.start_cursor.unwrap().starts_with("gomk1.")); + authority.allowed.store(false, Ordering::SeqCst); + reads.store(0, Ordering::SeqCst); + assert!(CursorPolicyRecord::keyset_connection_page(&db, Default::default(), KeysetConnectionInput { last: Some(1), ..Default::default() }).await.is_err()); + let db = db.with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); + assert!(CursorPolicyRecord::authorized_scan(&db, Default::default(), Default::default()).await.is_err()); + assert_eq!(reads.load(Ordering::SeqCst), 0); + authority.allowed.store(true, Ordering::SeqCst); + let scan = CursorPolicyRecord::authorized_scan(&db, Default::default(), AuthorizedScanInput { limit: Some(1), ..Default::default() }).await.unwrap(); + assert_eq!(scan.nodes.len(), 1); + assert!(scan.page_info.continuation.unwrap().starts_with("goms1.")); +} + +mod skipped_order { + use super::*; + #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] + #[graphql_entity(table = "skipped_cursor_records", plural = "SkippedCursorRecords", keyset = "hidden_rank asc, id asc")] + struct HiddenOrderRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[sortable] + id: String, + #[graphql(skip)] + hidden_rank: i64, + } + schema_roots! { query_custom_ops: [], generated_mutations: "none", entities: [HiddenOrderRecord], } + #[tokio::test] + async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { + let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE skipped_cursor_records (id TEXT PRIMARY KEY, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query("INSERT INTO skipped_cursor_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }) + .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); + let schema = schema_builder(db.clone()).finish(); + for operation in ["{ skippedCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", "{ skippedCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }"] { + let response = schema.execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))).await; + assert!(!response.errors.is_empty(), "a hidden raw ordering value must be denied independently of selection"); + } + assert_eq!(reads.load(Ordering::SeqCst), 0); + // Trusted repository reads retain their ordinary independent field policy. + let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()).await.unwrap(); + assert_eq!(page.edges.len(), 2); + } +} + +mod private_order { + use super::*; + #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] + #[graphql_entity(table = "private_cursor_records", plural = "PrivateCursorRecords", keyset = "hidden_rank asc, id asc")] + struct HiddenOrderRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[sortable] + id: String, + #[graphql_orm(private)] + hidden_rank: i64, + } + schema_roots! { query_custom_ops: [], generated_mutations: "none", entities: [HiddenOrderRecord], } + #[tokio::test] + async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { + let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE private_cursor_records (id TEXT PRIMARY KEY, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query("INSERT INTO private_cursor_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }) + .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); + let schema = schema_builder(db.clone()).finish(); + for operation in ["{ privateCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", "{ privateCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }"] { + let response = schema.execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))).await; + assert!(!response.errors.is_empty(), "a hidden raw ordering value must be denied independently of selection"); + } + assert_eq!(reads.load(Ordering::SeqCst), 0); + // Trusted repository reads retain their ordinary independent field policy. + let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()).await.unwrap(); + assert_eq!(page.edges.len(), 2); + } +} From d000f72eae11882acef8db7b999a2cf291619e64 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 12:13:57 +1100 Subject: [PATCH 5/9] fix(macros): keep private configured cursor fields closed --- CHANGELOG.md | 3 +++ MIGRATION.md | 4 ++++ crates/graphql-orm-macros/README.md | 4 ++++ crates/graphql-orm-macros/src/operations.rs | 2 +- crates/graphql-orm/README.md | 4 ++++ crates/graphql-orm/tests/keyset_cursor_field_authority.rs | 4 +++- 6 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 231a1714..ce9887fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ Companion runtime/macros version: **0.58.0**. ## 0.57.0 (2026-10-22) +- Reject private configured cursor fields despite explicit read/order surface + overrides, before GraphQL keyset or scan reads; preserve repository policy. + Companion macros crate: `graphql-orm-macros` **0.57.0**. - Add the SQL Server-only `#[graphql_orm(mssql_money)]` field domain for exact diff --git a/MIGRATION.md b/MIGRATION.md index 8a5debc6..5a006647 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -21,6 +21,10 @@ Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity times ## 0.57.0 to 0.58.0: composite-primary-key keysets +Explicit `read = true` or `order = true` never overrides a private configured +GraphQL cursor field. Keyset and scan queries reject it before the first read; +trusted repository pagination retains its independent field policy. + Configured cursors carry raw ordering values, so their fields now require current read authority even when clients select only `cursor`. GraphQL rejects private, skipped and transformed cursor fields. Repository policies remain independent. diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index ccefac92..63c489fd 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -14,6 +14,10 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +GraphQL-private configured ordering fields remain unavailable even when +`read = true` or `order = true` is explicitly set. Repository field policy and +permitted repository pagination retain their independent behavior. + Configured keyset cursors require current read authority for every order field and complete primary-key suffix, even for cursor-only selections. Checks run before queries and again against returned rows. Private, skipped or transformed diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 431f2863..3c167314 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -4281,7 +4281,7 @@ pub(crate) fn generate_graphql_operations( .expect("keyset field was resolved during parsing"); let meta = parse_field_metadata(source)?; let api_name = graphql_field_name(&meta, &field.to_string(), graphql_rename_fields, serde_rename_all); - let graphql_readable = meta.read && !meta.input_only && !meta.graphql_skip + let graphql_readable = meta.read && !meta.is_private && !meta.input_only && !meta.graphql_skip && meta.transform_read.is_none() && meta.transform_write.is_none(); let policy = match meta.read_policy.as_deref() { Some(policy) => quote! { Some(#policy) }, None => quote! { None }, diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index 3a30c3dc..ff8f583c 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -14,6 +14,10 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +GraphQL-private configured ordering fields remain unavailable even when +`read = true` or `order = true` is explicitly set. Repository field policy and +permitted repository pagination retain their independent behavior. + Configured keyset cursors require current read authority for every order field and complete primary-key suffix, even for cursor-only selections. Checks run before queries and again against returned rows. Private, skipped or transformed diff --git a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs index 501e49bc..84f0d3b4 100644 --- a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs +++ b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs @@ -162,7 +162,9 @@ mod private_order { #[graphql_orm(auto_generated = false)] #[sortable] id: String, - #[graphql_orm(private)] + // Explicit repository surface overrides cannot publish a private cursor value. + #[graphql_orm(private, read = true, order = true)] + #[sortable] hidden_rank: i64, } schema_roots! { query_custom_ops: [], generated_mutations: "none", entities: [HiddenOrderRecord], } From 19728383b7ce424159337abe2495ec8b9f0a7cb8 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 12:45:53 +1100 Subject: [PATCH 6/9] test: retain writable fields in hidden cursor fixtures --- .../tests/keyset_cursor_field_authority.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs index 84f0d3b4..a0d0ebca 100644 --- a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs +++ b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs @@ -129,6 +129,8 @@ mod skipped_order { #[graphql_orm(auto_generated = false)] #[sortable] id: String, + // Keep the generated update input valid while testing hidden cursor fields. + label: String, #[graphql(skip)] hidden_rank: i64, } @@ -136,8 +138,8 @@ mod skipped_order { #[tokio::test] async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE skipped_cursor_records (id TEXT PRIMARY KEY, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); - sqlx::query("INSERT INTO skipped_cursor_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + sqlx::query("CREATE TABLE skipped_cursor_records (id TEXT PRIMARY KEY, label TEXT NOT NULL, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query("INSERT INTO skipped_cursor_records VALUES ('one','First',17),('two','Second',29)").execute(db.pool()).await.unwrap(); let reads = Arc::new(AtomicUsize::new(0)); let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }) .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); @@ -162,6 +164,8 @@ mod private_order { #[graphql_orm(auto_generated = false)] #[sortable] id: String, + // Keep the generated update input valid while testing hidden cursor fields. + label: String, // Explicit repository surface overrides cannot publish a private cursor value. #[graphql_orm(private, read = true, order = true)] #[sortable] @@ -171,8 +175,8 @@ mod private_order { #[tokio::test] async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE private_cursor_records (id TEXT PRIMARY KEY, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); - sqlx::query("INSERT INTO private_cursor_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + sqlx::query("CREATE TABLE private_cursor_records (id TEXT PRIMARY KEY, label TEXT NOT NULL, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query("INSERT INTO private_cursor_records VALUES ('one','First',17),('two','Second',29)").execute(db.pool()).await.unwrap(); let reads = Arc::new(AtomicUsize::new(0)); let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }) .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); From 5eedb72d0430993a6b8ad6294ae3caefea169a57 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 13:07:12 +1100 Subject: [PATCH 7/9] style: apply hosted keyset rustfmt suggestions --- crates/graphql-orm-macros/src/operations.rs | 22 +- .../src/graphql/orm/authorized_pagination.rs | 21 +- .../tests/composite_keyset_pagination.rs | 47 ++- .../tests/keyset_cursor_field_authority.rs | 280 +++++++++++++++--- 4 files changed, 307 insertions(+), 63 deletions(-) diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 3c167314..cbd29793 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -4277,14 +4277,26 @@ pub(crate) fn generate_graphql_operations( let mut fingerprint_parts = Vec::new(); for (field, ty, column, ascending, nulls_first) in parts { let quoted_column = backend_quote_identifier_path(backend, column); - let source = fields.iter().find(|candidate| candidate.ident.as_ref() == Some(field)) + let source = fields + .iter() + .find(|candidate| candidate.ident.as_ref() == Some(field)) .expect("keyset field was resolved during parsing"); let meta = parse_field_metadata(source)?; - let api_name = graphql_field_name(&meta, &field.to_string(), graphql_rename_fields, serde_rename_all); - let graphql_readable = meta.read && !meta.is_private && !meta.input_only && !meta.graphql_skip - && meta.transform_read.is_none() && meta.transform_write.is_none(); + let api_name = graphql_field_name( + &meta, + &field.to_string(), + graphql_rename_fields, + serde_rename_all, + ); + let graphql_readable = meta.read + && !meta.is_private + && !meta.input_only + && !meta.graphql_skip + && meta.transform_read.is_none() + && meta.transform_write.is_none(); let policy = match meta.read_policy.as_deref() { - Some(policy) => quote! { Some(#policy) }, None => quote! { None }, + Some(policy) => quote! { Some(#policy) }, + None => quote! { None }, }; cursor_field_metadata.push(quote! { ::graphql_orm::graphql::orm::KeysetCursorField { column: #quoted_column, api_name: #api_name, read_policy: #policy, graphql_readable: #graphql_readable, diff --git a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs index 779a05e2..02bc3125 100644 --- a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs +++ b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs @@ -387,18 +387,29 @@ where row: Option<&T>, ) -> async_graphql::Result<()> { for part in order { - let field = T::KEYSET_CURSOR_FIELDS.iter().find(|field| field.column == part.column) + let field = T::KEYSET_CURSOR_FIELDS + .iter() + .find(|field| field.column == part.column) .ok_or_else(invalid)?; - let (api_name, policy, graphql_readable) = (field.api_name, field.read_policy, field.graphql_readable); + let (api_name, policy, graphql_readable) = + (field.api_name, field.read_policy, field.graphql_readable); let record = row.map(|row| row as &(dyn std::any::Any + Send + Sync)); if let Some(ctx) = ctx { if !graphql_readable { return Err(OrmPublicError::forbidden().into_graphql_error()); } - db.ensure_readable_field(ctx, T::entity_name(), api_name, policy, record).await?; + db.ensure_readable_field(ctx, T::entity_name(), api_name, policy, record) + .await?; } else { - db.ensure_repository_readable_field(None, T::entity_name(), api_name, policy, record) - .await.map_err(|error| error.into_graphql_error())?; + db.ensure_repository_readable_field( + None, + T::entity_name(), + api_name, + policy, + record, + ) + .await + .map_err(|error| error.into_graphql_error())?; } } Ok(()) diff --git a/crates/graphql-orm/tests/composite_keyset_pagination.rs b/crates/graphql-orm/tests/composite_keyset_pagination.rs index 468b184b..f28dc7cc 100644 --- a/crates/graphql-orm/tests/composite_keyset_pagination.rs +++ b/crates/graphql-orm/tests/composite_keyset_pagination.rs @@ -165,17 +165,37 @@ async fn generated_composite_keyset_is_admitted_without_graphql_writes() -> grap Ok(()) } - struct CompositeCursorAuthority(&'static str); impl FieldPolicy for CompositeCursorAuthority { - fn can_read_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, field: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + field: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async move { Ok(field != self.0) }) } - fn can_write_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, _: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } + fn can_write_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } } struct CompositeReadCounter(std::sync::Arc); impl ReadQueryObserver for CompositeReadCounter { - fn on_read(&self, _: &str, _: usize) { self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } + fn on_read(&self, _: &str, _: usize) { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } } #[tokio::test] async fn cursor_only_composite_reads_require_rank_and_both_key_components_before_select() { @@ -184,10 +204,21 @@ async fn cursor_only_composite_reads_require_rank_and_both_key_components_before database.set_field_policy(CompositeCursorAuthority(denied)); let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let database = database.with_read_query_observer(CompositeReadCounter(reads.clone())); - let schema = schema_builder(database).data(AuthSubject::new("fixture-principal")).finish(); - let response = schema.execute("{ compositeKeysetRecordsKeyset(page: {limit: 2}) { edges { cursor } } }").await; - assert!(!response.errors.is_empty(), "unselected cursor component {denied} was not checked"); - assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "denied cursor field reached SELECT"); + let schema = schema_builder(database) + .data(AuthSubject::new("fixture-principal")) + .finish(); + let response = schema + .execute("{ compositeKeysetRecordsKeyset(page: {limit: 2}) { edges { cursor } } }") + .await; + assert!( + !response.errors.is_empty(), + "unselected cursor component {denied} was not checked" + ); + assert_eq!( + reads.load(std::sync::atomic::Ordering::SeqCst), + 0, + "denied cursor field reached SELECT" + ); assert!(response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"].is_null()); } } diff --git a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs index a0d0ebca..4fe3cb36 100644 --- a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs +++ b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs @@ -1,10 +1,17 @@ #![cfg(feature = "sqlite")] -use graphql_orm::prelude::*; use graphql_orm::async_graphql::Request; -use std::sync::{Arc, atomic::{AtomicBool, AtomicUsize, Ordering}}; +use graphql_orm::prelude::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] -#[graphql_entity(table = "cursor_policy_records", plural = "CursorPolicyRecords", keyset = "protected_rank asc, id asc")] +#[graphql_entity( + table = "cursor_policy_records", + plural = "CursorPolicyRecords", + keyset = "protected_rank asc, id asc" +)] struct CursorPolicyRecord { #[primary_key] #[graphql_orm(auto_generated = false)] @@ -23,16 +30,65 @@ struct RankAuthority { deny_keys: Arc, } impl RankAuthority { - fn new() -> Self { Self { allowed: Arc::new(AtomicBool::new(true)), deny_rows: Arc::new(AtomicBool::new(false)), deny_keys: Arc::new(AtomicBool::new(false)) } } + fn new() -> Self { + Self { + allowed: Arc::new(AtomicBool::new(true)), + deny_rows: Arc::new(AtomicBool::new(false)), + deny_keys: Arc::new(AtomicBool::new(false)), + } + } } impl FieldPolicy for RankAuthority { - fn can_read_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, field: &'static str, _: Option<&'static str>, record: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { - Box::pin(async move { Ok(match field { "protectedRank" => self.allowed.load(Ordering::SeqCst) && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)), "id" => !self.deny_keys.load(Ordering::SeqCst), _ => true }) }) + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + field: &'static str, + _: Option<&'static str>, + record: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + Ok(match field { + "protectedRank" => { + self.allowed.load(Ordering::SeqCst) + && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)) + } + "id" => !self.deny_keys.load(Ordering::SeqCst), + _ => true, + }) + }) } - fn can_read_repository_field<'a>(&'a self, _: Option>, _: &'a Database, _: &'static str, field: &'static str, _: Option<&'static str>, record: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { - Box::pin(async move { Ok(match field { "protectedRank" => self.allowed.load(Ordering::SeqCst) && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)), "id" => !self.deny_keys.load(Ordering::SeqCst), _ => true }) }) + fn can_read_repository_field<'a>( + &'a self, + _: Option>, + _: &'a Database, + _: &'static str, + field: &'static str, + _: Option<&'static str>, + record: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + Ok(match field { + "protectedRank" => { + self.allowed.load(Ordering::SeqCst) + && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)) + } + "id" => !self.deny_keys.load(Ordering::SeqCst), + _ => true, + }) + }) } - fn can_write_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, _: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + fn can_write_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } } @@ -40,20 +96,36 @@ impl FieldPolicy for RankAuthority { #[tokio::test] async fn configured_keyset_cursor_only_selection_requires_unselected_order_field_authority() { let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); - sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + sqlx::query( + "CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)", + ) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)") + .execute(db.pool()) + .await + .unwrap(); let authority = RankAuthority::new(); db.set_field_policy(authority.clone()); let schema = schema_builder(db).finish(); - let request = || Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }").data(AuthSubject::new("fixture-principal")); + let request = || { + Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }") + .data(AuthSubject::new("fixture-principal")) + }; let before = schema.execute(request()).await; assert!(before.errors.is_empty(), "{:?}", before.errors); let value = before.data.into_json().unwrap(); - let cursor = value["cursorPolicyRecordsKeyset"]["edges"][0]["cursor"].as_str().unwrap(); + let cursor = value["cursorPolicyRecordsKeyset"]["edges"][0]["cursor"] + .as_str() + .unwrap(); assert!(cursor.starts_with("gomk1.")); authority.allowed.store(false, Ordering::SeqCst); let denied = schema.execute(request()).await; - assert!(!denied.errors.is_empty(), "a cursor-only selection must not bypass the configured ordering field's current read policy"); + assert!( + !denied.errors.is_empty(), + "a cursor-only selection must not bypass the configured ordering field's current read policy" + ); assert!(denied.data.into_json().unwrap()["cursorPolicyRecordsKeyset"].is_null()); } @@ -64,27 +136,50 @@ struct Observe { impl ReadQueryObserver for Observe { fn on_read(&self, _: &str, _: usize) { self.reads.fetch_add(1, Ordering::SeqCst); - if let Some(allowed) = &self.revoke { allowed.store(false, Ordering::SeqCst); } + if let Some(allowed) = &self.revoke { + allowed.store(false, Ordering::SeqCst); + } } } async fn setup() -> (Database, RankAuthority, Arc) { let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); - sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)").execute(db.pool()).await.unwrap(); + sqlx::query( + "CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)", + ) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)") + .execute(db.pool()) + .await + .unwrap(); let authority = RankAuthority::new(); db.set_field_policy(authority.clone()); let reads = Arc::new(AtomicUsize::new(0)); - let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }); + let db = db.with_read_query_observer(Observe { + reads: reads.clone(), + revoke: None, + }); (db, authority, reads) } #[tokio::test] async fn configured_orders_deny_before_select_even_for_cursor_only_payload() { for denied in ["rank", "key"] { let (db, authority, reads) = setup().await; - if denied == "rank" { authority.allowed.store(false, Ordering::SeqCst); } - else { authority.deny_keys.store(true, Ordering::SeqCst); } + if denied == "rank" { + authority.allowed.store(false, Ordering::SeqCst); + } else { + authority.deny_keys.store(true, Ordering::SeqCst); + } let schema = schema_builder(db).finish(); - let response = schema.execute(Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }").data(AuthSubject::new("fixture-principal"))).await; + let response = schema + .execute( + Request::new( + "{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + ) + .data(AuthSubject::new("fixture-principal")), + ) + .await; assert!(!response.errors.is_empty()); assert_eq!(reads.load(Ordering::SeqCst), 0); } @@ -94,10 +189,23 @@ async fn row_field_denial_and_permission_change_during_read_never_release_cursor for during_read in [false, true] { let (db, authority, reads) = setup().await; let db = if during_read { - db.with_read_query_observer(Observe { reads: reads.clone(), revoke: Some(authority.allowed.clone()) }) - } else { authority.deny_rows.store(true, Ordering::SeqCst); db }; + db.with_read_query_observer(Observe { + reads: reads.clone(), + revoke: Some(authority.allowed.clone()), + }) + } else { + authority.deny_rows.store(true, Ordering::SeqCst); + db + }; let schema = schema_builder(db).finish(); - let response = schema.execute(Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }").data(AuthSubject::new("fixture-principal"))).await; + let response = schema + .execute( + Request::new( + "{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + ) + .data(AuthSubject::new("fixture-principal")), + ) + .await; assert!(!response.errors.is_empty()); assert!(reads.load(Ordering::SeqCst) > 0); assert!(response.data.into_json().unwrap()["cursorPolicyRecordsKeyset"].is_null()); @@ -106,24 +214,62 @@ async fn row_field_denial_and_permission_change_during_read_never_release_cursor #[tokio::test] async fn repository_backward_and_scan_paths_retain_current_field_checks() { let (db, authority, reads) = setup().await; - let configured = CursorPolicyRecord::keyset_page(&db, Default::default(), Default::default()).await.unwrap(); - assert!(configured.page_info.start_cursor.unwrap().starts_with("gomk1.")); + let configured = CursorPolicyRecord::keyset_page(&db, Default::default(), Default::default()) + .await + .unwrap(); + assert!( + configured + .page_info + .start_cursor + .unwrap() + .starts_with("gomk1.") + ); authority.allowed.store(false, Ordering::SeqCst); reads.store(0, Ordering::SeqCst); - assert!(CursorPolicyRecord::keyset_connection_page(&db, Default::default(), KeysetConnectionInput { last: Some(1), ..Default::default() }).await.is_err()); + assert!( + CursorPolicyRecord::keyset_connection_page( + &db, + Default::default(), + KeysetConnectionInput { + last: Some(1), + ..Default::default() + } + ) + .await + .is_err() + ); let db = db.with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); - assert!(CursorPolicyRecord::authorized_scan(&db, Default::default(), Default::default()).await.is_err()); + assert!( + CursorPolicyRecord::authorized_scan(&db, Default::default(), Default::default()) + .await + .is_err() + ); assert_eq!(reads.load(Ordering::SeqCst), 0); authority.allowed.store(true, Ordering::SeqCst); - let scan = CursorPolicyRecord::authorized_scan(&db, Default::default(), AuthorizedScanInput { limit: Some(1), ..Default::default() }).await.unwrap(); + let scan = CursorPolicyRecord::authorized_scan( + &db, + Default::default(), + AuthorizedScanInput { + limit: Some(1), + ..Default::default() + }, + ) + .await + .unwrap(); assert_eq!(scan.nodes.len(), 1); assert!(scan.page_info.continuation.unwrap().starts_with("goms1.")); } mod skipped_order { use super::*; - #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] - #[graphql_entity(table = "skipped_cursor_records", plural = "SkippedCursorRecords", keyset = "hidden_rank asc, id asc")] + #[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize, + )] + #[graphql_entity( + table = "skipped_cursor_records", + plural = "SkippedCursorRecords", + keyset = "hidden_rank asc, id asc" + )] struct HiddenOrderRecord { #[primary_key] #[graphql_orm(auto_generated = false)] @@ -139,26 +285,51 @@ mod skipped_order { async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); sqlx::query("CREATE TABLE skipped_cursor_records (id TEXT PRIMARY KEY, label TEXT NOT NULL, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); - sqlx::query("INSERT INTO skipped_cursor_records VALUES ('one','First',17),('two','Second',29)").execute(db.pool()).await.unwrap(); + sqlx::query( + "INSERT INTO skipped_cursor_records VALUES ('one','First',17),('two','Second',29)", + ) + .execute(db.pool()) + .await + .unwrap(); let reads = Arc::new(AtomicUsize::new(0)); - let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }) + let db = db + .with_read_query_observer(Observe { + reads: reads.clone(), + revoke: None, + }) .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); let schema = schema_builder(db.clone()).finish(); - for operation in ["{ skippedCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", "{ skippedCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }"] { - let response = schema.execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))).await; - assert!(!response.errors.is_empty(), "a hidden raw ordering value must be denied independently of selection"); + for operation in [ + "{ skippedCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + "{ skippedCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }", + ] { + let response = schema + .execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))) + .await; + assert!( + !response.errors.is_empty(), + "a hidden raw ordering value must be denied independently of selection" + ); } assert_eq!(reads.load(Ordering::SeqCst), 0); // Trusted repository reads retain their ordinary independent field policy. - let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()).await.unwrap(); + let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()) + .await + .unwrap(); assert_eq!(page.edges.len(), 2); } } mod private_order { use super::*; - #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] - #[graphql_entity(table = "private_cursor_records", plural = "PrivateCursorRecords", keyset = "hidden_rank asc, id asc")] + #[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize, + )] + #[graphql_entity( + table = "private_cursor_records", + plural = "PrivateCursorRecords", + keyset = "hidden_rank asc, id asc" + )] struct HiddenOrderRecord { #[primary_key] #[graphql_orm(auto_generated = false)] @@ -176,18 +347,37 @@ mod private_order { async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); sqlx::query("CREATE TABLE private_cursor_records (id TEXT PRIMARY KEY, label TEXT NOT NULL, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); - sqlx::query("INSERT INTO private_cursor_records VALUES ('one','First',17),('two','Second',29)").execute(db.pool()).await.unwrap(); + sqlx::query( + "INSERT INTO private_cursor_records VALUES ('one','First',17),('two','Second',29)", + ) + .execute(db.pool()) + .await + .unwrap(); let reads = Arc::new(AtomicUsize::new(0)); - let db = db.with_read_query_observer(Observe { reads: reads.clone(), revoke: None }) + let db = db + .with_read_query_observer(Observe { + reads: reads.clone(), + revoke: None, + }) .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); let schema = schema_builder(db.clone()).finish(); - for operation in ["{ privateCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", "{ privateCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }"] { - let response = schema.execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))).await; - assert!(!response.errors.is_empty(), "a hidden raw ordering value must be denied independently of selection"); + for operation in [ + "{ privateCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + "{ privateCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }", + ] { + let response = schema + .execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))) + .await; + assert!( + !response.errors.is_empty(), + "a hidden raw ordering value must be denied independently of selection" + ); } assert_eq!(reads.load(Ordering::SeqCst), 0); // Trusted repository reads retain their ordinary independent field policy. - let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()).await.unwrap(); + let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()) + .await + .unwrap(); assert_eq!(page.edges.len(), 2); } } From 326b5a7281e6a43d2d88ae0d78cf9deafd5851a9 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 15:37:15 +1100 Subject: [PATCH 8/9] style(tests): align hosted cursor fixture indentation --- .../tests/keyset_cursor_field_authority.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs index 4fe3cb36..bfb0931c 100644 --- a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs +++ b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs @@ -288,9 +288,9 @@ mod skipped_order { sqlx::query( "INSERT INTO skipped_cursor_records VALUES ('one','First',17),('two','Second',29)", ) - .execute(db.pool()) - .await - .unwrap(); + .execute(db.pool()) + .await + .unwrap(); let reads = Arc::new(AtomicUsize::new(0)); let db = db .with_read_query_observer(Observe { @@ -350,9 +350,9 @@ mod private_order { sqlx::query( "INSERT INTO private_cursor_records VALUES ('one','First',17),('two','Second',29)", ) - .execute(db.pool()) - .await - .unwrap(); + .execute(db.pool()) + .await + .unwrap(); let reads = Arc::new(AtomicUsize::new(0)); let db = db .with_read_query_observer(Observe { From dbb82f29c5f5b99bb19489d99a61addbbe531b15 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 17:39:33 +1100 Subject: [PATCH 9/9] release: advance composite keyset pair after host timestamps --- CHANGELOG.md | 2 +- Cargo.lock | 4 ++-- Cargo.toml | 2 +- MIGRATION.md | 2 +- crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 4 ++-- .../fixtures/cross-crate-relations/Cargo.lock | 4 ++-- .../fixtures/relationship-capability/Cargo.lock | 4 ++-- crates/graphql-orm/Cargo.toml | 4 ++-- crates/graphql-orm/README.md | 4 ++-- .../graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock | 4 ++-- .../tests/fixtures/federation-end-to-end/Cargo.lock | 4 ++-- .../tests/fixtures/migration-api-compatibility/Cargo.lock | 4 ++-- .../tests/fixtures/repository-aggregate-consumer/Cargo.lock | 4 ++-- docs/reference/workspace-packages.md | 4 ++-- 15 files changed, 26 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9887fc..5e130ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ for historical context. ## Unreleased -Companion runtime/macros version: **0.58.0**. +Companion runtime/macros version: **0.59.0**. - Extend explicit `timestamp = "host"` to public GraphQLEntity fields and ordinary String timestamps (including optional forms). Caller-supplied source timestamps stay unchanged instead of receiving the ORM mutation clock. Existing automatic timestamp behavior remains the default. Policies, hooks and field restrictions still apply. diff --git a/Cargo.lock b/Cargo.lock index 230ad5cb..bf0e13a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "agql-auth", "async-graphql", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index f7c33caa..0cc96316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.58.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.59.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.1" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.10.0", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.1" } diff --git a/MIGRATION.md b/MIGRATION.md index 5a006647..7ace34b1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -19,7 +19,7 @@ published to crates.io. Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity timestamp fields may explicitly use `#[graphql_orm(timestamp = "host")]` on ordinary `i64`, `String`, or optional forms whose Rust or physical name is `created_at` or `updated_at`. For an external source timestamp mapped to physical `updated_at`, opt in on the mapped field to prevent automatic epoch assignment. Values are bound unchanged; String content is caller-owned and is not parsed or normalized as a date. Existing automatic behavior remains unchanged without the annotation. The annotation does not change stored type/default metadata; conventional Rust names still need `default = false` to suppress their implicit default when appropriate. Schema-only, generated/version/key/relation fields and date/JSON/boolean adapters remain rejected. No data or schema migration is required merely to opt in; preserve field/row authorization and distinguish source observation time from collection and mutation clocks. -## 0.57.0 to 0.58.0: composite-primary-key keysets +## 0.58.0 to 0.59.0: composite-primary-key keysets Explicit `read = true` or `order = true` never overrides a private configured GraphQL cursor field. Keyset and scan queries reject it before the first read; diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 02b043d4..d284b97e 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" edition = "2024" authors = ["Toby Martin"] description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations." diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index 63c489fd..ce6965f4 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -23,9 +23,9 @@ and complete primary-key suffix, even for cursor-only selections. Checks run before queries and again against returned rows. Private, skipped or transformed GraphQL order fields fail closed; repository field policy remains independent. -Version 0.58.0 supports static keyset reads with a complete composite-primary-key +Version 0.59.0 supports static keyset reads with a complete composite-primary-key tiebreaker tuple. Schema ownership and write posture are unchanged. See the -[migration guide](../../MIGRATION.md#0570-to-0580-composite-primary-key-keysets). +[migration guide](../../MIGRATION.md#0580-to-0590-composite-primary-key-keysets). Version 0.57.0 adds the SQL Server-only `#[graphql_orm(mssql_money)]` field annotation and identifier-safe exact read projections for native `MONEY`. diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index fd0a9df1..e29956ae 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock index c72ad0a2..5713ab16 100644 --- a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 12339f34..e586774d 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -73,7 +73,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.58.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.59.0", default-features = false } graphql-orm-operation-catalog = { workspace = true } rust_decimal = { workspace = true } serde = { version = "1", features = ["derive"] } diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index ff8f583c..14d1ebe8 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -23,9 +23,9 @@ and complete primary-key suffix, even for cursor-only selections. Checks run before queries and again against returned rows. Private, skipped or transformed GraphQL order fields fail closed; repository field policy remains independent. -Version 0.58.0 supports static keyset reads with a complete composite-primary-key +Version 0.59.0 supports static keyset reads with a complete composite-primary-key tiebreaker tuple. Schema ownership and write posture are unchanged. See the -[migration guide](../../MIGRATION.md#0570-to-0580-composite-primary-key-keysets). +[migration guide](../../MIGRATION.md#0580-to-0590-composite-primary-key-keysets). Version 0.57.0 adds exact SQL Server `MONEY` typed reads through `#[graphql_orm(mssql_money)]`. The generated read projection casts the native diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index f62f7e78..ba2cf541 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "agql-auth", "async-graphql", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock index 3595f604..03dcba9e 100644 --- a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2532,7 +2532,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock index b01570a0..dd6f87c3 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock index 0e435f5e..6d4572ed 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "async-trait", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 5299741f..e88bd6eb 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,11 +18,11 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.58.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.59.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.109.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm` (dev-only), `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.16.1` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.10.0` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.58.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.59.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.1` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.6.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |