lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 963dd8dc99fc5f16ef7d7eea11f8d964770a696e
parent c9d75ca260a9191dd91a44581c1daa0460e366ab
Author: triesap <tyson@radroots.org>
Date:   Mon, 14 Sep 2026 20:43:08 +0000

storage: add bounded author-wide draft inventory

- Select an explicit author and schema across application scopes
- Bind distinct continuations while preserving exact-scope cursor bytes
- Retain query bounds and corruption isolation across SQLite restart
- Verify API compatibility and unchanged owner coverage gates

Diffstat:
Mcontracts/api_baselines/radroots_storage.txt | 3+++
Acontracts/architecture/decisions/authored_draft_inventory.v1.json | 13+++++++++++++
Mcrates/storage/README.md | 8++++++++
Mcrates/storage/src/authored_draft_query.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/storage/tests/authored_draft_query.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/src/authored_draft_query.rs | 3++-
Mcrates/storage_sqlite/src/authored_draft_query_tests.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 397 insertions(+), 14 deletions(-)

diff --git a/contracts/api_baselines/radroots_storage.txt b/contracts/api_baselines/radroots_storage.txt @@ -526,6 +526,8 @@ impl radroots_storage::authored_draft_query::AuthoredDraftQuery pub const fn radroots_storage::authored_draft_query::AuthoredDraftQuery::after(&self) -> core::option::Option<[u8; 16]> pub const fn radroots_storage::authored_draft_query::AuthoredDraftQuery::author(&self) -> &[u8; 32] pub fn radroots_storage::authored_draft_query::AuthoredDraftQuery::cursor_after(&self, [u8; 16]) -> radroots_storage::authored_draft_query::AuthoredDraftCursor +pub fn radroots_storage::authored_draft_query::AuthoredDraftQuery::for_author([u8; 32], impl core::convert::AsRef<str>, u16) -> core::result::Result<Self, radroots_storage::Error> +pub const fn radroots_storage::authored_draft_query::AuthoredDraftQuery::is_author_wide(&self) -> bool pub const fn radroots_storage::authored_draft_query::AuthoredDraftQuery::limit(&self) -> u16 pub fn radroots_storage::authored_draft_query::AuthoredDraftQuery::matches(&self, &radroots_storage::authored_draft::AuthoredDraft) -> bool pub fn radroots_storage::authored_draft_query::AuthoredDraftQuery::new([u8; 32], impl core::convert::AsRef<str>, core::option::Option<radroots_storage::authored_draft_query::AuthoredDraftScope>, u16) -> core::result::Result<Self, radroots_storage::Error> @@ -541,6 +543,7 @@ pub fn [u8; 32]::from(radroots_storage::authored_draft_query::AuthoredDraftScope impl core::convert::TryFrom<[u8; 32]> for radroots_storage::authored_draft_query::AuthoredDraftScope pub type radroots_storage::authored_draft_query::AuthoredDraftScope::Error = radroots_storage::Error pub fn radroots_storage::authored_draft_query::AuthoredDraftScope::try_from([u8; 32]) -> core::result::Result<Self, radroots_storage::Error> +pub const radroots_storage::authored_draft_query::AUTHORED_DRAFT_AUTHOR_CURSOR_SCHEMA_VERSION: u16 pub const radroots_storage::authored_draft_query::AUTHORED_DRAFT_CURSOR_SCHEMA_VERSION: u16 pub const radroots_storage::authored_draft_query::AUTHORED_DRAFT_PAGE_PAYLOAD_MAX_BYTES: usize pub const radroots_storage::authored_draft_query::AUTHORED_DRAFT_PAGE_SNAPSHOT_MAX_BYTES: usize diff --git a/contracts/architecture/decisions/authored_draft_inventory.v1.json b/contracts/architecture/decisions/authored_draft_inventory.v1.json @@ -0,0 +1,13 @@ +{ + "schema": "radroots.authored-draft-inventory.v1", + "status": "implemented", + "owners": ["radroots_storage", "radroots_storage_sqlite"], + "authority": "Supplement authored_draft_submission.v1 query mechanics without changing its exact optional-scope behavior or historical serialized records.", + "selection": "AuthoredDraftQuery::for_author explicitly selects one independently supplied author and exact payload schema across scoped and unscoped current heads. AuthoredDraftQuery::new still selects one exact optional scope; None means unscoped only. Neither constructor infers or changes author or schema from a continuation.", + "cursor": "Exact-scope cursors retain byte-identical schema version 1. Author-wide cursors require schema version 2, null scope and the explicit selection marker author_schema. Reject missing or conflicting selection markers, scope on an author-wide cursor, unknown fields, unsupported versions and invalid existing fields. Continuations cannot move between exact-scope and author-wide queries, authors or schemas. Callers retain independent authority selection on every page.", + "bounds": {"page_records": 256, "metadata_lookahead": 1, "decoded_page_payload_bytes": 4194304, "serialized_page_snapshot_bytes": 16777216}, + "backend": "Retain stable ascending draft ID ordering, latest revisions, one released SQLite read snapshot per page, bounded memory retention and explicit continuation after page or byte exhaustion. Existing corrupt-row locators and unknown historical schema policy remain author-bound. Known foreign authors and payload schemas do not enter results. No migration, SQL ownership, dependency, private snapshot or signed operation identity changes.", + "consistency": "This is a live bounded traversal, not a frozen multi-page inventory. Updates do not move stable IDs. Callers must serialize their own application mutation admission or resweep for new earlier IDs; storage installs no task, lock outside the read snapshot, or application policy.", + "verification": ["old exact-scope and cursor assertions unchanged", "new cursor roundtrip and authority rejection", "memory and SQLite multi-scope parity", "1,000 records across page boundaries with concurrent revisions", "corrupt and foreign records and SQLite reopen", "existing byte bounds", "public API, full coverage thresholds, workspace and release preflight"], + "non_goals": ["publication policy", "application schema interpretation", "author enumeration", "unbounded result collections", "new database", "signing or transport", "release qualification"] +} diff --git a/crates/storage/README.md b/crates/storage/README.md @@ -175,6 +175,14 @@ SQLite status requires its governed lock, WAL, and busy-timeout contract. ## Serialization +`AuthoredDraftQuery::new` selects an exact author, schema and optional scope; +an absent scope selects only unscoped drafts. `AuthoredDraftQuery::for_author` +explicitly traverses that author's schema across all scopes using the same +bounded pages. Its version-2 continuation includes an explicit selection marker +and cannot be used for an exact-scope query or another author/schema. Existing +version-1 continuations keep their original bytes and meaning. Both traversals +are live views: callers must revisit earlier IDs when new work can be inserted. + The optional `serde` feature serializes passive identities, requests, records, receipts, status values, manifests, and coordination metadata. Deserialization revalidates invariants rather than trusting encoded revisions, digests, paths, diff --git a/crates/storage/src/authored_draft_query.rs b/crates/storage/src/authored_draft_query.rs @@ -12,6 +12,8 @@ pub const AUTHORED_DRAFT_PAGE_PAYLOAD_MAX_BYTES: usize = 4 * 1024 * 1024; /// Maximum serialized snapshot bytes read by one native query page. pub const AUTHORED_DRAFT_PAGE_SNAPSHOT_MAX_BYTES: usize = 16 * 1024 * 1024; pub const AUTHORED_DRAFT_CURSOR_SCHEMA_VERSION: u16 = 1; +/// Explicit author/schema traversal has a distinct continuation authority. +pub const AUTHORED_DRAFT_AUTHOR_CURSOR_SCHEMA_VERSION: u16 = 2; /// An opaque, stable application-selected scope digest; never a credential. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -48,6 +50,7 @@ pub struct AuthoredDraftQuery { author: [u8; 32], payload_schema: String, scope: Option<AuthoredDraftScope>, + author_wide: bool, limit: u16, after: Option<[u8; 16]>, } @@ -73,12 +76,38 @@ impl AuthoredDraftQuery { author, payload_schema: schema.to_owned(), scope, + author_wide: false, limit, after: None, }) } + /// Selects this author's exact payload schema across all application scopes. + /// This does not broaden `new(..., None, ...)`, which remains unscoped only. + pub fn for_author( + author: [u8; 32], + payload_schema: impl AsRef<str>, + limit: u16, + ) -> Result<Self, Error> { + let mut query = Self::new(author, payload_schema, None, limit)?; + query.author_wide = true; + Ok(query) + } + + /// Whether scope filtering was explicitly omitted by `for_author`. + pub const fn is_author_wide(&self) -> bool { + self.author_wide + } + + const fn cursor_version(&self) -> u16 { + if self.author_wide { + AUTHORED_DRAFT_AUTHOR_CURSOR_SCHEMA_VERSION + } else { + AUTHORED_DRAFT_CURSOR_SCHEMA_VERSION + } + } + pub fn with_cursor(mut self, cursor: &AuthoredDraftCursor) -> Result<Self, Error> { - if cursor.schema_version != AUTHORED_DRAFT_CURSOR_SCHEMA_VERSION + if cursor.schema_version != self.cursor_version() || cursor.author != self.author || cursor.payload_schema != self.payload_schema || cursor.scope != self.scope @@ -94,6 +123,8 @@ impl AuthoredDraftQuery { pub fn payload_schema(&self) -> &str { &self.payload_schema } + /// Exact optional scope for ordinary queries. Author-wide queries return + /// `None`; backends must also honor `is_author_wide` when selecting rows. pub const fn scope(&self) -> Option<AuthoredDraftScope> { self.scope } @@ -106,11 +137,11 @@ impl AuthoredDraftQuery { pub fn matches(&self, draft: &AuthoredDraft) -> bool { draft.author() == &self.author && draft.payload_schema() == self.payload_schema - && draft.scope() == self.scope + && (self.author_wide || draft.scope() == self.scope) } pub fn cursor_after(&self, after_id: [u8; 16]) -> AuthoredDraftCursor { AuthoredDraftCursor { - schema_version: AUTHORED_DRAFT_CURSOR_SCHEMA_VERSION, + schema_version: self.cursor_version(), author: self.author, payload_schema: self.payload_schema.clone(), scope: self.scope, @@ -133,8 +164,16 @@ pub struct AuthoredDraftCursor { } #[cfg(feature = "serde")] #[derive(serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +enum CursorWire { + Exact(ExactCursorWire), + Author(AuthorCursorWire), +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] -struct CursorWire { +struct ExactCursorWire { schema_version: u16, author: [u8; 32], payload_schema: String, @@ -142,28 +181,68 @@ struct CursorWire { after_id: [u8; 16], } #[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthorCursorWire { + schema_version: u16, + author: [u8; 32], + payload_schema: String, + scope: (), + after_id: [u8; 16], + selection: CursorSelection, +} +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +enum CursorSelection { + #[serde(rename = "author_schema")] + AuthorSchema, +} +#[cfg(feature = "serde")] impl TryFrom<CursorWire> for AuthoredDraftCursor { type Error = Error; fn try_from(value: CursorWire) -> Result<Self, Error> { - if value.schema_version != AUTHORED_DRAFT_CURSOR_SCHEMA_VERSION { - return Err(Error::InvalidAuthoredDraft); - } - Ok( - AuthoredDraftQuery::new(value.author, value.payload_schema, value.scope, 1)? - .cursor_after(value.after_id), - ) + let (query, after) = match value { + CursorWire::Exact(value) + if value.schema_version == AUTHORED_DRAFT_CURSOR_SCHEMA_VERSION => + { + ( + AuthoredDraftQuery::new(value.author, value.payload_schema, value.scope, 1)?, + value.after_id, + ) + } + CursorWire::Author(value) + if value.schema_version == AUTHORED_DRAFT_AUTHOR_CURSOR_SCHEMA_VERSION => + { + ( + AuthoredDraftQuery::for_author(value.author, value.payload_schema, 1)?, + value.after_id, + ) + } + _ => return Err(Error::InvalidAuthoredDraft), + }; + Ok(query.cursor_after(after)) } } #[cfg(feature = "serde")] impl From<AuthoredDraftCursor> for CursorWire { fn from(value: AuthoredDraftCursor) -> Self { - Self { + if value.schema_version == AUTHORED_DRAFT_AUTHOR_CURSOR_SCHEMA_VERSION { + return Self::Author(AuthorCursorWire { + schema_version: value.schema_version, + author: value.author, + payload_schema: value.payload_schema, + scope: (), + after_id: value.after_id, + selection: CursorSelection::AuthorSchema, + }); + } + Self::Exact(ExactCursorWire { schema_version: value.schema_version, author: value.author, payload_schema: value.payload_schema, scope: value.scope, after_id: value.after_id, - } + }) } } diff --git a/crates/storage/tests/authored_draft_query.rs b/crates/storage/tests/authored_draft_query.rs @@ -35,6 +35,159 @@ fn query(scope: Option<AuthoredDraftScope>, limit: u16) -> AuthoredDraftQuery { } #[test] +fn author_wide_cursor_requires_explicit_selection_and_independent_authority() { + let q = AuthoredDraftQuery::for_author([9; 32], "fixture.composer.v1", 256).unwrap(); + assert!(q.is_author_wide()); + assert_eq!(q.scope(), None); + assert!(!query(None, 1).is_author_wide()); + assert!(AuthoredDraftQuery::for_author([0; 32], "fixture.composer.v1", 1).is_err()); + let cursor = q.cursor_after([3; 16]); + let bytes = serde_json::to_vec(&cursor).unwrap(); + let decoded: AuthoredDraftCursor = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(decoded, cursor); + assert_eq!( + q.clone().with_cursor(&decoded).unwrap().after(), + Some([3; 16]) + ); + assert_eq!(serde_json::to_vec(&decoded).unwrap(), bytes); + let wire = serde_json::to_value(&cursor).unwrap(); + assert_eq!(wire["schema_version"], 2); + assert_eq!(wire["selection"], "author_schema"); + assert!(wire["scope"].is_null()); + for changed in [ + query(None, 1), + query(Some(AuthoredDraftScope::new([7; 32]).unwrap()), 1), + AuthoredDraftQuery::for_author([8; 32], q.payload_schema(), 1).unwrap(), + AuthoredDraftQuery::for_author([9; 32], "fixture.other.v1", 1).unwrap(), + ] { + assert!(changed.with_cursor(&decoded).is_err()); + } + assert!( + q.with_cursor(&query(None, 1).cursor_after([3; 16])) + .is_err() + ); + for (field, value) in [ + ("schema_version", serde_json::json!(1)), + ("schema_version", serde_json::json!(3)), + ("selection", serde_json::Value::Null), + ("selection", serde_json::json!("other")), + ("scope", serde_json::json!([7; 32].to_vec())), + ("author", serde_json::json!([0; 32].to_vec())), + ("payload_schema", serde_json::json!("")), + ("unknown", serde_json::json!(true)), + ] { + let mut forged = wire.clone(); + forged[field] = value; + assert!( + serde_json::from_value::<AuthoredDraftCursor>(forged).is_err(), + "{field}" + ); + } + let mut absent = wire; + let mut absent_scope = absent.clone(); + absent_scope.as_object_mut().unwrap().remove("scope"); + assert!(serde_json::from_value::<AuthoredDraftCursor>(absent_scope).is_err()); + absent.as_object_mut().unwrap().remove("selection"); + assert!(serde_json::from_value::<AuthoredDraftCursor>(absent).is_err()); + let old = query(None, 1).cursor_after([3; 16]); + let mut forbidden = serde_json::to_value(&old).unwrap(); + forbidden["selection"] = serde_json::Value::Null; + assert!(serde_json::from_value::<AuthoredDraftCursor>(forbidden).is_err()); + let expected = format!( + "{{\"schema_version\":1,\"author\":{},\"payload_schema\":\"fixture.composer.v1\",\"scope\":null,\"after_id\":{}}}", + serde_json::to_string(&[9; 32]).unwrap(), + serde_json::to_string(&[3; 16]).unwrap() + ); + assert_eq!(serde_json::to_string(&old).unwrap(), expected); +} + +#[test] +fn author_wide_memory_pages_cover_a_thousand_scopes_and_preserve_bounds() { + let store = MemoryStorage::default(); + let scope = AuthoredDraftScope::new([7; 32]).unwrap(); + for id in 1_u128..=1000 { + let value = AuthoredDraft::initial( + AuthoredDraftId::new(id.to_be_bytes()).unwrap(), + [9; 32], + "fixture.composer.v1", + vec![1], + AuthoredDraftStage::Draft, + None, + 10, + ) + .unwrap(); + let value = if id % 2 == 0 { + value.with_scope(scope).unwrap() + } else { + value + }; + block_on(store.append_authored_draft(value, None)).unwrap(); + } + for (id, author, schema) in [ + (1001_u128, [8; 32], "fixture.composer.v1"), + (1002, [9; 32], "fixture.other.v1"), + ] { + let value = AuthoredDraft::initial( + AuthoredDraftId::new(id.to_be_bytes()).unwrap(), + author, + schema, + vec![1], + AuthoredDraftStage::Draft, + None, + 10, + ) + .unwrap(); + block_on(store.append_authored_draft(value, None)).unwrap(); + } + let q = AuthoredDraftQuery::for_author([9; 32], "fixture.composer.v1", 37).unwrap(); + let mut cursor = None; + let mut expected = 1_u128; + loop { + let query = cursor.as_ref().map_or_else( + || q.clone(), + |cursor| q.clone().with_cursor(cursor).unwrap(), + ); + let page = block_on(store.query_authored_drafts(query)).unwrap(); + assert!(page.records().len() <= 37); + for record in page.records() { + assert_eq!(record.draft_key(), expected.to_be_bytes()); + expected += 1; + } + cursor = page.next_cursor().cloned(); + if cursor.is_none() { + break; + } + } + assert_eq!(expected, 1001); + for id in 1003_u128..=1005 { + let value = AuthoredDraft::initial( + AuthoredDraftId::new(id.to_be_bytes()).unwrap(), + [9; 32], + "fixture.large.v1", + vec![1; 2 * 1024 * 1024], + AuthoredDraftStage::Draft, + None, + 10, + ) + .unwrap(); + let value = if id % 2 == 0 { + value.with_scope(scope).unwrap() + } else { + value + }; + block_on(store.append_authored_draft(value, None)).unwrap(); + } + let q = AuthoredDraftQuery::for_author([9; 32], "fixture.large.v1", 256).unwrap(); + let first = block_on(store.query_authored_drafts(q.clone())).unwrap(); + assert_eq!(first.records().len(), 2); + let second = + block_on(store.query_authored_drafts(q.with_cursor(first.next_cursor().unwrap()).unwrap())) + .unwrap(); + assert_eq!(second.records().len(), 1); + assert!(second.next_cursor().is_none()); +} + +#[test] fn scoped_pages_preserve_revisions_and_do_not_mix_other_schemas_or_scopes() { let store = MemoryStorage::default(); let scope = AuthoredDraftScope::new([7; 32]).unwrap(); diff --git a/crates/storage_sqlite/src/authored_draft_query.rs b/crates/storage_sqlite/src/authored_draft_query.rs @@ -36,13 +36,14 @@ async fn read_page( revisions.payload_schema = '' AS unknown_schema FROM radroots_runtime_authored_draft_revisions AS revisions WHERE revisions.author = ? - AND ((revisions.payload_schema = ? AND revisions.payload_scope IS ?) + AND ((revisions.payload_schema = ? AND (? OR revisions.payload_scope IS ?)) OR revisions.payload_schema = '') AND (? IS NULL OR revisions.draft_id > ?) AND revisions.revision = (SELECT MAX(head.revision) FROM radroots_runtime_authored_draft_revisions AS head WHERE head.draft_id = revisions.draft_id) ORDER BY revisions.draft_id LIMIT ?" ).bind(query.author().as_slice()).bind(query.payload_schema()) + .bind(query.is_author_wide()) .bind(query.scope().map(|value| value.as_bytes().to_vec())) .bind(&after).bind(after).bind(i64::from(query.limit()) + 1) .fetch_all(&mut **transaction).await.map_err(map_backend)?; diff --git a/crates/storage_sqlite/src/authored_draft_query_tests.rs b/crates/storage_sqlite/src/authored_draft_query_tests.rs @@ -30,6 +30,132 @@ fn draft( fn query(scope: Option<AuthoredDraftScope>, limit: u16) -> AuthoredDraftQuery { AuthoredDraftQuery::new([7; 32], "fixture.composer.v1", scope, limit).unwrap() } + +#[tokio::test] +async fn author_wide_sqlite_pages_preserve_scope_isolation_corruption_and_restart() { + let temp = TempDir::new().unwrap(); + let store = open_store(&temp).await; + let scope = AuthoredDraftScope::new([9; 32]).unwrap(); + for id in 1_u128..=1000 { + let value = AuthoredDraft::initial( + AuthoredDraftId::new(id.to_be_bytes()).unwrap(), + [7; 32], + "fixture.composer.v1", + vec![1], + AuthoredDraftStage::Draft, + None, + 10, + ) + .unwrap(); + let value = if id % 2 == 0 { + value.with_scope(scope).unwrap() + } else { + value + }; + store.append_authored_draft(value, None).await.unwrap(); + } + corrupt(&store, 0, 7, "fixture.composer.v1", Some(scope)).await; + corrupt(&store, 2, 8, "fixture.composer.v1", Some(scope)).await; + corrupt(&store, 3, 7, "fixture.other.v1", Some(scope)).await; + let q = AuthoredDraftQuery::for_author([7; 32], "fixture.composer.v1", 37).unwrap(); + let first = store.query_authored_drafts(q.clone()).await.unwrap(); + assert_eq!(first.records().len(), 37); + assert!(matches!( + first.records()[0], + AuthoredDraftQueryRecord::Corrupt { + draft_key: [0, ..], + .. + } + )); + for (index, record) in first.records().iter().enumerate().skip(1) { + assert_eq!(record.draft_key(), (index as u128).to_be_bytes()); + } + let mut cursor = first.next_cursor().cloned(); + // Revisions of both a visited and an unvisited ID do not move position. + for id in [1_u128, 999] { + let original = store + .authored_draft_head(AuthoredDraftId::new(id.to_be_bytes()).unwrap()) + .await + .unwrap() + .unwrap(); + let next = original + .successor(vec![2], AuthoredDraftStage::Draft, None, 11) + .unwrap(); + store + .append_authored_draft(next, Some(original.revision())) + .await + .unwrap(); + } + store.close().await.unwrap(); + let store = open_store(&temp).await; + let mut expected = 37_u128; + while let Some(current) = cursor { + let bytes = serde_json::to_vec(&current).unwrap(); + let decoded = serde_json::from_slice(&bytes).unwrap(); + let page = store + .query_authored_drafts(q.clone().with_cursor(&decoded).unwrap()) + .await + .unwrap(); + assert!(page.records().len() <= 37); + for record in page.records() { + assert_eq!(record.draft_key(), expected.to_be_bytes()); + if expected == 999 { + assert_eq!(record.revision().get(), 2); + } + expected += 1; + } + cursor = page.next_cursor().cloned(); + } + assert_eq!(expected, 1001); + let unscoped = store.query_authored_drafts(query(None, 256)).await.unwrap(); + assert!( + unscoped + .records() + .iter() + .all(|record| u128::from_be_bytes(record.draft_key()) % 2 == 1) + ); + let fresh = store.query_authored_drafts(q).await.unwrap(); + assert_eq!(fresh.records()[1].revision().get(), 2); + store.close().await.unwrap(); +} + +#[tokio::test] +async fn author_wide_sqlite_pages_preserve_snapshot_and_payload_budgets() { + for byte in [0, 99] { + let temp = TempDir::new().unwrap(); + let store = open_store(&temp).await; + let scope = AuthoredDraftScope::new([9; 32]).unwrap(); + for (id, scope) in [(1, None), (2, Some(scope))] { + store + .append_authored_draft( + draft( + id, + "fixture.composer.v1", + scope, + vec![byte; 3 * 1024 * 1024], + ), + None, + ) + .await + .unwrap(); + } + let q = AuthoredDraftQuery::for_author([7; 32], "fixture.composer.v1", 256).unwrap(); + let first = store.query_authored_drafts(q.clone()).await.unwrap(); + assert_eq!(first.records().len(), 1); + let next = store + .query_authored_drafts(q.with_cursor(first.next_cursor().unwrap()).unwrap()) + .await + .unwrap(); + assert_eq!(next.records().len(), 1); + assert_eq!(next.records()[0].draft_key(), [2; 16]); + assert!(matches!( + next.records()[0], + AuthoredDraftQueryRecord::Draft(_) + )); + assert!(next.next_cursor().is_none()); + store.close().await.unwrap(); + } +} async fn corrupt( store: &SqliteStorage, id: u8,