//! Tests for the appview read layer. //! //! The read helpers are exercised against an in-memory SQLite database with the //! migrations applied and representative rows seeded directly (the projection is //! tested separately in `indexing::projection`). The bsky profile fetch is never //! hit here: the polymodel union variant comes from seeded SQLite, and the bsky //! variant's union discrimination is covered by a serde round-trip. #![cfg(test)] pub(crate) use axum::body::Body; pub(crate) use axum::http::{Method, Request, StatusCode}; pub(crate) use dioxus::prelude::dioxus_fullstack::FullstackContext; pub(crate) use http_body_util::BodyExt; use jacquard::identity::PublicResolver; use jacquard::identity::resolver::{DidStep, PlcSource, ResolverOptions}; use jacquard::oauth::authstore::ClientAuthStore; pub(crate) use jacquard::oauth::client::OAuthSession; use jacquard::oauth::scopes::Scopes; use jacquard::oauth::session::{ClientSessionData, DpopClientData}; use jacquard::oauth::types::{OAuthTokenType, TokenSet}; use jacquard::oauth::utils::generate_key; use jacquard_common::deps::fluent_uri::Uri; use jacquard_common::deps::smol_str::SmolStr; pub(crate) use jacquard_common::session::SessionKey; pub(crate) use jacquard_common::types::datetime::Datetime; pub(crate) use jacquard_common::types::string::Did; pub(crate) use polymodel_api::space_polymodel::actor::ProfileViewUnion; pub(crate) use polymodel_api::space_polymodel::actor::get_session::{ GetSessionOutput, SessionView, }; pub(crate) use serde_json::json; pub(crate) use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; pub(crate) use std::collections::HashSet; use std::str::FromStr; pub(crate) use tower::ServiceExt; pub(crate) use super::error::AppError; pub(crate) use super::state::AppState; pub(crate) const DID_A: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; pub(crate) const DID_B: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; pub(crate) fn did(s: &str) -> Did { Did::new_owned(s).unwrap() } pub(crate) fn client_session(account_did: &str, session_id: &str) -> ClientSessionData { let dpop_key = generate_key(&["ES256"]).unwrap(); ClientSessionData { account_did: did(account_did), session_id: SmolStr::new(session_id), host_url: Uri::parse("https://pds.example.com").unwrap().to_owned(), authserver_url: SmolStr::new("https://issuer.example.com"), authserver_token_endpoint: SmolStr::new("https://issuer.example.com/token"), authserver_revocation_endpoint: None, scopes: Scopes::empty(), dpop_data: DpopClientData { dpop_key, dpop_authserver_nonce: SmolStr::default(), dpop_host_nonce: SmolStr::default(), }, token_set: TokenSet { iss: SmolStr::new("https://issuer.example.com"), sub: did(account_did), aud: SmolStr::new("https://pds.example.com"), scope: None, refresh_token: None, access_token: SmolStr::new("token"), token_type: OAuthTokenType::DPoP, expires_at: Some(Datetime::raw_str("2099-01-01T00:00:00.000Z")), }, resolved_scopes: None, } } pub(crate) async fn seed_oauth_session( state: &AppState, account_did: &str, session_id: &str, ) -> SessionKey { seed_oauth_session_at(state, account_did, session_id, None).await } pub(crate) async fn seed_oauth_session_at( state: &AppState, account_did: &str, session_id: &str, host_url: Option<&str>, ) -> SessionKey { let mut data = client_session(account_did, session_id); if let Some(host_url) = host_url { data.host_url = Uri::parse(host_url).unwrap().to_owned(); data.token_set.aud = SmolStr::new(host_url); } let key = SessionKey::new(data.account_did.clone(), data.session_id.clone()); state .oauth .registry .store .upsert_session(data) .await .unwrap(); key } pub(crate) async fn pool() -> SqlitePool { let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .unwrap(); sqlx::migrate!("./migrations").run(&pool).await.unwrap(); pool } pub(crate) async fn state() -> AppState { let pool = pool().await; let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) .expect("ephemeral OAuth bootstrap for tests"); AppState::new(pool, bootstrap) } pub(crate) async fn loopback_pds( fail_blob: bool, fail_cid: Option, ) -> (String, tokio::task::JoinHandle<()>) { use axum::extract::Query; use axum::response::IntoResponse; use axum::routing::any; let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await .unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); let did_doc_endpoint = endpoint.clone(); let app = axum::Router::new() .route( "/xrpc/com.atproto.identity.resolveDid", any(move || { let service_endpoint = did_doc_endpoint.clone(); async move { axum::Json(json!({ "didDoc": { "@context": ["https://www.w3.org/ns/did/v1"], "id": DID_A, "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": service_endpoint }] } })) } }), ) .route( "/xrpc/com.atproto.sync.getBlob", any( move |Query(params): Query>| async move { let cid = params.get("cid").map(String::as_str).unwrap_or_default(); if fail_blob || fail_cid.as_deref() == Some(cid) || cid == "bafkreih7uy2yhx5gobvypuuexbvq22j2cypeqqfk2lc46225e7b3syq7pu" { return ( StatusCode::SERVICE_UNAVAILABLE, Body::from("blob unavailable"), ) .into_response(); } match cid { "bafkreif2nycdsf3vvit7hhpxegmlmoknx6m7yuvdt4tkzpk7hv4jqo3ulq" | "bafkreih3ryqpylsmh4siyygdtplff46bgrzjro4xpofu2widxbifkyqgam" => { (StatusCode::OK, Body::from("ab")).into_response() } "bafkreiape5vm4aofohlvq72uc7nw6golsxa44ser6photbjne6gkhboeii" | "bafkreibb44q4gwsyep63iux2f6pquyjmot5zklqgsj2itrvspjb3qf562q" => { (StatusCode::OK, Body::from("cd")).into_response() } _ => (StatusCode::NOT_FOUND, Body::from("unknown blob")).into_response(), } }, ), ); let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); (endpoint, task) } pub(crate) async fn loopback_pds_with_upload( expected_mime: &'static str, ) -> (String, tokio::task::JoinHandle<()>) { use axum::extract::Query; use axum::response::IntoResponse; use axum::routing::any; let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await .unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); let did_doc_endpoint = endpoint.clone(); let app = axum::Router::new() .route( "/xrpc/com.atproto.identity.resolveDid", any(move || { let service_endpoint = did_doc_endpoint.clone(); async move { axum::Json(json!({ "didDoc": { "@context": ["https://www.w3.org/ns/did/v1"], "id": DID_A, "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": service_endpoint }] } })) } }), ) .route( "/xrpc/com.atproto.repo.getRecord", any(|| async { ( StatusCode::BAD_REQUEST, axum::Json(json!({"error": "RecordNotFound", "message": "record not found"})), ) }), ) .route( "/xrpc/com.atproto.repo.uploadBlob", any( move |headers: axum::http::HeaderMap, body: Body| async move { let mime = headers .get(axum::http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or_default(); if mime != expected_mime { return (StatusCode::UNSUPPORTED_MEDIA_TYPE, Body::from("wrong mime")) .into_response(); } let bytes = match axum::body::to_bytes(body, 1 << 20).await { Ok(bytes) => bytes, Err(_) => { return (StatusCode::BAD_REQUEST, Body::from("body read failed")) .into_response(); } }; let (cid, _) = test_blob(&bytes); ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], axum::Json(json!({ "blob": { "$type": "blob", "ref": { "$link": cid }, "mimeType": mime, "size": bytes.len() } })), ) .into_response() }, ), ) .route( "/xrpc/com.atproto.sync.getBlob", any( move |Query(params): Query>| async move { let cid = params.get("cid").map(String::as_str).unwrap_or_default(); ( StatusCode::NOT_FOUND, Body::from(format!("unknown blob {cid}")), ) .into_response() }, ), ); let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); (endpoint, task) } pub(crate) type RecordedCreateRecords = std::sync::Arc>>; pub(crate) async fn loopback_pds_for_publish( fail_create: bool, ) -> (String, RecordedCreateRecords, tokio::task::JoinHandle<()>) { use axum::response::IntoResponse; use axum::routing::any; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await .unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); let did_doc_endpoint = endpoint.clone(); let sequence = Arc::new(AtomicU64::new(0)); let create_records: RecordedCreateRecords = Arc::new(tokio::sync::Mutex::new(Vec::new())); let app = axum::Router::new() .route( "/xrpc/com.atproto.identity.resolveDid", any(move || { let service_endpoint = did_doc_endpoint.clone(); async move { axum::Json(json!({ "didDoc": { "@context": ["https://www.w3.org/ns/did/v1"], "id": DID_A, "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": service_endpoint }] } })) } }), ) .route( "/xrpc/com.atproto.repo.getRecord", any(|| async { ( StatusCode::BAD_REQUEST, axum::Json(json!({"error": "RecordNotFound", "message": "record not found"})), ) }), ) .route( "/xrpc/com.atproto.repo.uploadBlob", any(|headers: axum::http::HeaderMap, body: Body| async move { let mime = headers .get(axum::http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("application/octet-stream"); let bytes = axum::body::to_bytes(body, 1 << 20).await.unwrap(); let (cid, _) = test_blob(&bytes); axum::Json(json!({ "blob": { "$type": "blob", "ref": { "$link": cid }, "mimeType": mime, "size": bytes.len() } })) .into_response() }), ) .route( "/xrpc/com.atproto.repo.createRecord", any({ let sequence = sequence.clone(); let create_records = create_records.clone(); move |body: Body| { let sequence = sequence.clone(); let create_records = create_records.clone(); async move { let body = axum::body::to_bytes(body, 1 << 20).await.unwrap(); let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); create_records.lock().await.push(request.clone()); if fail_create { return ( StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({ "error": "UpstreamFailure", "message": "simulated create failure" })), ) .into_response(); } let collection = request["collection"].as_str().unwrap(); let ordinal = sequence.fetch_add(1, Ordering::Relaxed); let rkey = request["rkey"] .as_str() .map(ToOwned::to_owned) .unwrap_or_else(|| format!("record-{ordinal}")); // Derive a distinct CID per createRecord call from the // request body (repo + collection + record) so that // strong-ref assertions cannot be satisfied by a // misbound or partial publish. let request_bytes = serde_json::to_vec(&request).unwrap(); let (cid, _) = test_blob(&request_bytes); axum::Json(json!({ "uri": format!("at://{DID_A}/{collection}/{rkey}"), "cid": cid })) .into_response() } } }), ); let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); (endpoint, create_records, task) } pub(crate) async fn loopback_pds_with_blobs( blobs: std::collections::HashMap>, ) -> (String, tokio::task::JoinHandle<()>) { use axum::extract::Query; use axum::response::IntoResponse; use axum::routing::any; let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await .unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); let did_doc_endpoint = endpoint.clone(); let app = axum::Router::new() .route( "/xrpc/com.atproto.identity.resolveDid", any(move || { let service_endpoint = did_doc_endpoint.clone(); async move { axum::Json(json!({ "didDoc": { "@context": ["https://www.w3.org/ns/did/v1"], "id": DID_A, "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": service_endpoint }] } })) } }), ) .route( "/xrpc/com.atproto.sync.getBlob", any( move |Query(params): Query>| { let blobs = blobs.clone(); async move { let cid = params.get("cid").map(String::as_str).unwrap_or_default(); match blobs.get(cid) { Some(bytes) => { (StatusCode::OK, Body::from(bytes.clone())).into_response() } None => { (StatusCode::NOT_FOUND, Body::from("unknown blob")).into_response() } } } }, ), ); let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); (endpoint, task) } pub(crate) fn test_blob(bytes: &[u8]) -> (String, Vec) { use sha2::Digest; let digest = sha2::Sha256::digest(bytes); let mut cid_bytes = vec![0x01, 0x55, 0x12, 0x20]; cid_bytes.extend_from_slice(&digest); let alphabet = b"abcdefghijklmnopqrstuvwxyz234567"; let mut cid = String::from("b"); let mut buffer = 0_u16; let mut bits = 0_u8; for byte in cid_bytes { buffer = (buffer << 8) | u16::from(byte); bits += 8; while bits >= 5 { bits -= 5; cid.push(alphabet[((buffer >> bits) & 0x1f) as usize] as char); buffer &= (1_u16 << bits).wrapping_sub(1); } } if bits != 0 { cid.push(alphabet[((buffer << (5 - bits)) & 0x1f) as usize] as char); } (cid, digest.to_vec()) } pub(crate) async fn seed_compound_resource( pool: &SqlitePool, project: &str, uri: &str, root: &str, path: &str, bytes: &[u8], cid: &str, ) { use sha2::Digest; let digest = sha2::Sha256::digest(bytes).to_vec(); let source_identity = crate::ldraw::source_identity_for( &jacquard_common::types::string::AtUri::new_owned(uri).unwrap(), &[cid.to_owned()], &digest, bytes.len() as i64, ); sqlx::query( "INSERT INTO ldraw_resources (resource_uri, owner_did, project_uri, record_cid, rkey, root, canonical_path, mime_type, byte_length, sha256, ordered_blob_cids, provenance, licence, notices_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(uri) .bind(DID_A) .bind(project) .bind("record-cid") .bind(path.rsplit('/').next().unwrap()) .bind(root) .bind(path) .bind("application/x-ldraw") .bind(bytes.len() as i64) .bind(digest) .bind(serde_json::to_string(&vec![cid]).unwrap()) .bind("test") .bind("CC-BY-4.0") .bind("[]") .bind(1_i64) .bind(1_i64) .execute(pool) .await .unwrap(); sqlx::query( "INSERT INTO ldraw_verification (resource_uri, source_identity, state, diagnostic, updated_at) VALUES (?, ?, 'verified', NULL, ?)", ) .bind(uri) .bind(source_identity) .bind(1_i64) .execute(pool) .await .unwrap(); sqlx::query( "INSERT INTO ldraw_resource_memberships (resource_uri, project_uri, root, canonical_path, provenance) VALUES (?, ?, ?, ?, ?)", ) .bind(uri) .bind(project) .bind(root) .bind(path) .bind("test") .execute(pool) .await .unwrap(); } pub(crate) struct ManifestEdge<'a> { pub(crate) source_uri: &'a str, pub(crate) root: &'a str, pub(crate) path: &'a str, pub(crate) target_uri: &'a str, pub(crate) target_cid: &'a str, pub(crate) bytes: &'a [u8], pub(crate) ordinal: i64, } pub(crate) async fn seed_manifest_edge(pool: &SqlitePool, edge: ManifestEdge<'_>) { use sha2::Digest; sqlx::query( "INSERT INTO ldraw_manifest_files (resource_uri, root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type, ordinal) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(edge.source_uri) .bind(edge.root) .bind(edge.path) .bind(edge.target_uri) .bind(edge.target_cid) .bind(edge.bytes.len() as i64) .bind(sha2::Sha256::digest(edge.bytes).to_vec()) .bind("application/x-ldraw") .bind(edge.ordinal) .execute(pool) .await .unwrap(); } pub(crate) fn loopback_resolver(endpoint: &str) -> PublicResolver { let options = ResolverOptions { plc_source: PlcSource::PlcDirectory { base: Uri::parse(format!("{endpoint}/")).unwrap().to_owned(), }, pds_fallback: Some(Uri::parse(endpoint.to_owned()).unwrap().to_owned()), did_order: vec![DidStep::PdsResolveDid], ..Default::default() }; PublicResolver::new(reqwest::Client::new(), options) } pub(crate) async fn body_text(resp: axum::response::Response) -> String { let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) .await .unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } // Seeding uses runtime queries so the committed .sqlx cache stays focused on production SQL. pub(crate) fn blob(cid: &str) -> serde_json::Value { json!({ "$type": "blob", "ref": { "$link": cid }, "mimeType": "image/jpeg", "size": 1234 }) } pub(crate) fn image(cid: &str, alt: &str) -> serde_json::Value { json!({ "alt": alt, "aspectRatio": { "width": 4, "height": 3 }, "image": blob(cid) }) } pub(crate) fn strong_ref(uri: &str) -> serde_json::Value { json!({ "uri": uri, "cid": "bafyreicid" }) } pub(crate) async fn seed_thing( pool: &SqlitePool, did: &str, rkey: &str, name: &str, models: &[&str], like_count: i64, ) -> String { let uri = format!("at://{did}/space.polymodel.library.thing/{rkey}"); let now = chrono::Utc::now() .timestamp_nanos_opt() .expect("current timestamp must fit in i64 nanoseconds"); let created = now - 86_400_000_000_000; // 1 day ago by default sqlx::query( r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, NULL, 'CC-BY-4.0', NULL, NULL, NULL, NULL, NULL, ?, ?, ?)"#, ) .bind(did) .bind(rkey) .bind(&uri) .bind("cid") .bind(name) .bind(created) .bind(now) .bind(json!({ "name": name, "license": "CC-BY-4.0", "cover": [image("bafkreiathingcover", "Thing cover")], "previews": [image("bafkreiathingpreview", "Thing preview")], "models": models.iter().map(|m| strong_ref(m)).collect::>(), "createdAt": "2024-01-01T00:00:00.000Z" }).to_string()) .execute(pool) .await .unwrap(); for (pos, m) in models.iter().enumerate() { sqlx::query( "INSERT OR REPLACE INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", ) .bind(&uri) .bind(*m) .bind(pos as i64) .execute(pool) .await .unwrap(); } sqlx::query( "INSERT OR IGNORE INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, 0, 0)", ) .bind(&uri) .bind(like_count) .execute(pool) .await .unwrap(); uri } pub(crate) async fn set_thing_created_at(pool: &SqlitePool, uri: &str, created_at: i64) { sqlx::query("UPDATE things SET created_at = ? WHERE uri = ?") .bind(created_at) .bind(uri) .execute(pool) .await .unwrap(); } pub(crate) async fn seed_model( pool: &SqlitePool, did: &str, rkey: &str, name: &str, parts: &[&str], ) -> String { let uri = format!("at://{did}/space.polymodel.library.model/{rkey}"); let now = chrono::Utc::now() .timestamp_nanos_opt() .expect("current timestamp must fit in i64 nanoseconds"); sqlx::query( r#"INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)"#, ) .bind(did) .bind(rkey) .bind(&uri) .bind("cid") .bind(name) .bind(now) .bind(now) .bind(json!({ "name": name, "cover": [image("bafkreiamodelcover", "Model cover")], "previews": [image("bafkreiamodelpreview", "Model preview")], "parts": parts.iter().map(|p| strong_ref(p)).collect::>(), "createdAt": "2024-01-01T00:00:00.000Z" }).to_string()) .execute(pool) .await .unwrap(); for (pos, p) in parts.iter().enumerate() { sqlx::query( "INSERT OR REPLACE INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)", ) .bind(&uri) .bind(*p) .bind(pos as i64) .execute(pool) .await .unwrap(); } uri } pub(crate) async fn seed_part(pool: &SqlitePool, did: &str, rkey: &str, name: &str) -> String { let uri = format!("at://{did}/space.polymodel.library.part/{rkey}"); let now = chrono::Utc::now() .timestamp_nanos_opt() .expect("current timestamp must fit in i64 nanoseconds"); sqlx::query( r#"INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)"#, ) .bind(did) .bind(rkey) .bind(&uri) .bind("cid") .bind(name) .bind(json!({"mimeType": "model/stl", "size": 1, "chunks": []}).to_string()) .bind(now) .bind(now) .bind(json!({ "name": name, "file": {"mimeType": "model/stl", "size": 1, "chunks": []}, "previews": [image("bafkreiapartpreview", "Part preview")], "createdAt": "2024-01-01T00:00:00.000Z" }).to_string()) .execute(pool) .await .unwrap(); uri } pub(crate) async fn seed_identity(pool: &SqlitePool, did: &str, handle: &str) { sqlx::query( "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?) \ ON CONFLICT(did) DO UPDATE SET handle = excluded.handle", ) .bind(did) .bind(handle) .bind( chrono::Utc::now() .timestamp_nanos_opt() .expect("current timestamp must fit in i64 nanoseconds"), ) .execute(pool) .await .unwrap(); } pub(crate) async fn seed_profile(pool: &SqlitePool, did: &str, display: &str) { let avatar = blob("bafkreiavatar").to_string(); sqlx::query( r#"INSERT INTO profiles (did, display_name, description, avatar_json, default_license, pronouns, printers_json, links_json, record_json, indexed_at) VALUES (?, ?, NULL, ?, NULL, NULL, NULL, NULL, ?, ?) ON CONFLICT(did) DO UPDATE SET display_name = excluded.display_name, avatar_json = excluded.avatar_json, record_json = excluded.record_json"#, ) .bind(did) .bind(display) .bind(avatar) .bind(json!({"displayName": display, "avatar": blob("bafkreiavatar")}).to_string()) .bind( chrono::Utc::now() .timestamp_nanos_opt() .expect("current timestamp must fit in i64 nanoseconds"), ) .execute(pool) .await .unwrap(); } pub(crate) async fn seed_list(pool: &SqlitePool, did: &str, rkey: &str, name: &str) -> String { let uri = format!("at://{did}/space.polymodel.graph.list/{rkey}"); let now = chrono::Utc::now() .timestamp_nanos_opt() .expect("current timestamp must fit in i64 nanoseconds"); sqlx::query( r#"INSERT INTO lists (did, rkey, uri, cid, name, description, purpose, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, 'Print farm staples', 'curation', ?, ?, ?)"#, ) .bind(did) .bind(rkey) .bind(&uri) .bind("bafyreilistcid") .bind(name) .bind(now) .bind(now) .bind(json!({ "name": name, "description": "Print farm staples", "purpose": "curation", "createdAt": "2024-01-01T00:00:00.000Z" }).to_string()) .execute(pool) .await .unwrap(); uri }