Add storage namespaces for local message boards (#55971)

## Why

Local message boards use a fixed database filename. Callers need separate storage for boards that share a session identity.

## What changed

- Add `LocalBoardOptions` with a validated `storage_namespace`, and support it when opening, configuring, and permanently deleting boards.
- Allow `LocalBoard::with_options` to select storage for its connections.
- Store custom namespaces in `message_board_<namespace>_1.sqlite`, while preserving `agent_message_board_1.sqlite` for the default namespace.
- Have the agent message board extension select the same namespace for opening, setup, and deletion.

## Testing

Add coverage for namespace isolation, reopening saved posts, and avoiding collisions with other runtime databases. Update setup tests to exercise a custom namespace through reopening and deletion.

GitOrigin-RevId: a6ea8d171809625edeedc3a8ac3ee2e1bbf1682a
This commit is contained in:
felixxia-oai
2026-10-21 19:36:20 +00:00
committed by copyberry
parent a47d1338c1
commit e41e882174
13 changed files with 276 additions and 53 deletions
+8 -1
View File
@@ -13,6 +13,7 @@ use chrono::DateTime;
use chrono::Utc;
use codex_agent_message_board_client::AccessToken;
use codex_agent_message_board_client::RemoteAgentMessageBoard;
use codex_agent_message_board_extension::local_board_options;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadStartInput;
use codex_features::Feature;
@@ -110,7 +111,13 @@ pub fn install_agent_message_board(
Arc::new(in_memory_boards.open(tree, host).await)
} else {
Arc::new(
LocalAgentMessageBoard::open(config.sqlite_config(), tree, host).await?,
LocalAgentMessageBoard::open(
config.sqlite_config(),
&local_board_options(),
tree,
host,
)
.await?,
)
};
Ok(Some(board))
+11 -4
View File
@@ -39,6 +39,7 @@ use crate::tasks::interrupted_turn_history_marker;
use crate::thread_startup_metadata::ThreadStartupMetadata;
use codex_agent_graph_store::AgentGraphStore;
use codex_agent_graph_store::LocalAgentGraphStore;
use codex_agent_message_board_extension::local_board_options;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
@@ -501,13 +502,19 @@ pub fn thread_store_from_config(
let sqlite = sqlite.clone();
Box::pin(async move {
let boards = thread_ids.into_iter().map(Into::into).collect::<Vec<_>>();
LocalAgentMessageBoard::delete_boards(&sqlite, &boards)
.await
.map_err(|err| ThreadStoreError::Internal {
LocalAgentMessageBoard::delete_boards(
&sqlite,
&local_board_options(),
&boards,
)
.await
.map_err(|err| {
ThreadStoreError::Internal {
message: format!(
"failed to delete agent message boards: {err}"
),
})
}
})
})
}),
);
@@ -1,5 +1,6 @@
//! Operator-only setup for local experiments; no administrative agent tools.
use codex_agent_message_board_extension::local_board_options;
use codex_message_board::LocalBoardSetup;
use codex_message_board::configure_local_board;
use codex_protocol::SessionId;
@@ -21,7 +22,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Err("setup file exceeds 64 KiB".into());
}
let setup: LocalBoardSetup = serde_json::from_slice(&input)?;
configure_local_board(&sqlite, board, setup).await?;
configure_local_board(&sqlite, &local_board_options(), board, setup).await?;
println!("Configured local board {board}");
Ok(())
}
@@ -4,3 +4,10 @@
mod extension;
pub use extension::install;
/// Storage selected by the agent consumer for opening, setup and permanent deletion.
pub fn local_board_options() -> codex_message_board::LocalBoardOptions {
codex_message_board::LocalBoardOptions {
storage_namespace: "agent_message_board".into(),
}
}
+1
View File
@@ -34,6 +34,7 @@ pub use in_memory::InMemoryMessageBoards;
pub use local::LocalAgentMessageBoard;
pub use local::connection::LocalBoard;
pub use local::connection::LocalBoardConnection;
pub use local::options::LocalBoardOptions;
pub use local::setup::LocalBoardSetup;
pub use local::setup::configure_local_board;
pub use permissions::BoardPermissions;
+7 -3
View File
@@ -8,6 +8,7 @@
//! Explicit connections can restrict writes; default handles retain legacy access.
use self::connection::Access;
use self::options::LocalBoardOptions;
use crate::ChannelDescription;
use crate::ChannelPermissions;
use crate::ChannelSummary;
@@ -43,6 +44,7 @@ use uuid::Uuid;
pub(crate) mod connection;
mod lifecycle;
pub(crate) mod options;
mod paging;
mod pool;
mod queries;
@@ -73,14 +75,16 @@ struct StoredPost {
impl LocalAgentMessageBoard {
/// Reopens the same board for a root, child or resumed runtime. The shared
/// SQLite configuration preserves the host's connection and journal policy.
/// The caller selects the storage namespace.
pub async fn open(
sqlite: &SqliteConfig,
options: &LocalBoardOptions,
identity: SessionId,
host: Arc<dyn MessageBoardHost>,
) -> Result<Self> {
Ok(Self {
identity,
pool: pool::open(sqlite).await?,
pool: pool::open(sqlite, options).await?,
host,
access: None,
})
@@ -252,11 +256,11 @@ impl LocalAgentMessageBoard {
thread_id: root,
created_at: now,
},
text: request.text.clone(),
text: request.text,
};
sqlx::query("INSERT INTO posts(board,id,channel,root,author,timestamp,body_search,payload,request_id,request) VALUES(?,?,?,?,?,?,?,?,?,?)")
.bind(self.identity.to_string()).bind(id.to_string()).bind(channel).bind(root.to_string())
.bind(author.to_string()).bind(now.timestamp_micros()).bind(default_case_fold_str(&request.text))
.bind(author.to_string()).bind(now.timestamp_micros()).bind(default_case_fold_str(&post.text))
.bind(serde_json::to_string(&post).map_err(storage_error)?).bind(request_id).bind(request_json)
.execute(&mut *tx).await.map_err(storage_error)?;
// Participation subscribes by default, without overriding an explicit opt-out.
+19 -3
View File
@@ -2,6 +2,7 @@
//! independent of the board's storage or the agent's tree identity.
use super::LocalAgentMessageBoard;
use super::options::LocalBoardOptions;
use crate::MessageBoardHost;
use crate::PermissionDenied;
use codex_protocol::AgentPath;
@@ -19,11 +20,21 @@ use std::sync::OnceLock;
pub struct LocalBoard {
sqlite: SqliteConfig,
identity: SessionId,
options: LocalBoardOptions,
}
impl LocalBoard {
pub fn new(sqlite: SqliteConfig, identity: SessionId) -> Self {
Self { sqlite, identity }
Self {
sqlite,
identity,
options: LocalBoardOptions::default(),
}
}
pub fn with_options(mut self, options: LocalBoardOptions) -> Self {
self.options = options;
self
}
pub fn read_only(&self) -> LocalBoardConnection {
@@ -77,8 +88,13 @@ impl LocalBoardConnection {
}
pub async fn open(&self, host: Arc<dyn MessageBoardHost>) -> Result<LocalAgentMessageBoard> {
let mut board =
LocalAgentMessageBoard::open(&self.board.sqlite, self.identity(), host).await?;
let mut board = LocalAgentMessageBoard::open(
&self.board.sqlite,
&self.board.options,
self.identity(),
host,
)
.await?;
// Pool initialization canonicalizes the directory before opening SQLite.
let options = board.pool.connect_options();
let database = options.get_filename();
@@ -6,7 +6,7 @@
use super::LocalAgentMessageBoard;
use super::invalid;
use super::pool::DATABASE_FILE;
use super::options::LocalBoardOptions;
use super::pool::POOLS;
use super::pool::SCHEMA;
use super::storage_error;
@@ -27,14 +27,17 @@ impl LocalAgentMessageBoard {
clippy::await_holding_invalid_type,
reason = "recovery excludes new handles until replacement deletion tombstones are saved"
)]
pub async fn delete_boards(sqlite: &SqliteConfig, roots: &[SessionId]) -> Result<()> {
let path = sqlite.home().join(DATABASE_FILE);
pub async fn delete_boards(
sqlite: &SqliteConfig,
options: &LocalBoardOptions,
roots: &[SessionId],
) -> Result<()> {
let filename = options.database_file()?;
let path = sqlite.home().join(&filename);
if roots.is_empty() || !tokio::fs::try_exists(&path).await? {
return Ok(());
}
let path = tokio::fs::canonicalize(sqlite.home())
.await?
.join(DATABASE_FILE);
let path = tokio::fs::canonicalize(sqlite.home()).await?.join(filename);
let mut pools = POOLS.lock().await;
let pool = match sqlite.open_read_write_pool(&path).await {
Ok(pool) => pool,
@@ -0,0 +1,43 @@
//! Storage namespace selected by the board's owner.
//! Owners supply the same namespace when reopening storage.
use super::invalid;
use codex_protocol::error::Result;
pub(super) const DEFAULT_STORAGE_NAMESPACE: &str = "agent_message_board";
/// Host-selected storage, independent of tool selection and connection permissions.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LocalBoardOptions {
/// A namespace isolates boards with the same session identity in separate databases.
/// Use 1–64 lowercase ASCII letters, digits, underscores or hyphens.
pub storage_namespace: String,
}
impl Default for LocalBoardOptions {
fn default() -> Self {
Self {
storage_namespace: DEFAULT_STORAGE_NAMESPACE.into(),
}
}
}
impl LocalBoardOptions {
pub(super) fn database_file(&self) -> Result<String> {
if self.storage_namespace.is_empty()
|| self.storage_namespace.len() > 64
|| !self.storage_namespace.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
})
{
return Err(invalid(
"board storage namespaces require 1–64 lowercase ASCII letters, digits, underscores or hyphens",
));
}
if self.storage_namespace == DEFAULT_STORAGE_NAMESPACE {
return Ok("agent_message_board_1.sqlite".into());
}
// Custom boards must not share filenames with other runtime databases.
Ok(format!("message_board_{}_1.sqlite", self.storage_namespace))
}
}
+7 -6
View File
@@ -1,6 +1,7 @@
//! Owns the local database schema and pool registry shared by runtime handles and setup.
//! Initialization and deletion recovery share the registry lock to exclude stale pools.
use super::options::LocalBoardOptions;
use super::storage_error;
use codex_protocol::error::Result;
use codex_state::SqliteConfig;
@@ -13,8 +14,6 @@ use std::sync::LazyLock;
use std::sync::Weak;
use tokio::sync::Mutex;
pub(super) const DATABASE_FILE: &str = "agent_message_board_1.sqlite";
// Weak entries let the last board handle release its pool. Initialization and
// recovery share one lock so concurrent starts cannot open duplicate or stale pools.
pub(super) static POOLS: LazyLock<Mutex<HashMap<PathBuf, Weak<SqlitePool>>>> =
@@ -53,11 +52,13 @@ CREATE TABLE IF NOT EXISTS subscription_opt_outs (
clippy::await_holding_invalid_type,
reason = "serialize shared pool initialization"
)]
pub(super) async fn open(sqlite: &SqliteConfig) -> Result<Arc<SqlitePool>> {
pub(super) async fn open(
sqlite: &SqliteConfig,
options: &LocalBoardOptions,
) -> Result<Arc<SqlitePool>> {
let filename = options.database_file()?;
tokio::fs::create_dir_all(sqlite.home()).await?;
let path = tokio::fs::canonicalize(sqlite.home())
.await?
.join(DATABASE_FILE);
let path = tokio::fs::canonicalize(sqlite.home()).await?.join(filename);
let mut pools = POOLS.lock().await;
pools.retain(|_, pool| pool.strong_count() > 0);
let pool = if let Some(pool) = pools
@@ -34,9 +34,10 @@ impl MessageBoardHost for UnusedHost {
async fn concurrent_handles_share_and_release_the_pool() {
let dir = tempfile::tempdir().unwrap();
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
let options = LocalBoardOptions::default();
let (first, second) = tokio::join!(
LocalAgentMessageBoard::open(&sqlite, SessionId::new(), Arc::new(UnusedHost)),
LocalAgentMessageBoard::open(&sqlite, SessionId::new(), Arc::new(UnusedHost)),
LocalAgentMessageBoard::open(&sqlite, &options, SessionId::new(), Arc::new(UnusedHost)),
LocalAgentMessageBoard::open(&sqlite, &options, SessionId::new(), Arc::new(UnusedHost)),
);
let first = first.unwrap();
let second = second.unwrap();
@@ -47,9 +48,10 @@ async fn concurrent_handles_share_and_release_the_pool() {
drop(second);
assert!(original.upgrade().is_none());
let reopened = LocalAgentMessageBoard::open(&sqlite, SessionId::new(), Arc::new(UnusedHost))
.await
.unwrap();
let reopened =
LocalAgentMessageBoard::open(&sqlite, &options, SessionId::new(), Arc::new(UnusedHost))
.await
.unwrap();
assert_ne!(original.as_ptr(), Arc::as_ptr(&reopened.pool));
reopened.pool.close().await;
}
+3 -1
View File
@@ -2,6 +2,7 @@
use super::insert_channel;
use super::invalid;
use super::options::LocalBoardOptions;
use super::storage_error;
use super::validate_channel;
use crate::ChannelDescription;
@@ -43,6 +44,7 @@ pub struct LocalBoardSetup {
/// safe to retry; replacing it or initializing an already-used board is rejected.
pub async fn configure_local_board(
sqlite: &SqliteConfig,
options: &LocalBoardOptions,
board: SessionId,
setup: LocalBoardSetup,
) -> Result<()> {
@@ -56,7 +58,7 @@ pub async fn configure_local_board(
for name in template.channels.keys() {
validate_channel(name)?;
}
let pool = super::pool::open(sqlite).await?;
let pool = super::pool::open(sqlite, options).await?;
let mut tx = pool
.begin_with("BEGIN IMMEDIATE")
.await
+152 -23
View File
@@ -91,6 +91,7 @@ impl MessageBoardHost for Host {
async fn opening_board_preserves_posts_when_corruption_has_no_startup_recovery() {
let dir = tempfile::tempdir().unwrap();
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
let options = LocalBoardOptions::default();
let root = ThreadId::new();
let host = Arc::new(Host {
clock: AtomicI64::default(),
@@ -131,7 +132,7 @@ async fn opening_board_preserves_posts_when_corruption_has_no_startup_recovery()
pool.close().await;
let tree = SessionId::from(root);
let board = LocalAgentMessageBoard::open(&sqlite, tree, host.clone())
let board = LocalAgentMessageBoard::open(&sqlite, &options, tree, host.clone())
.await
.unwrap();
let request = PostRequest {
@@ -142,7 +143,7 @@ async fn opening_board_preserves_posts_when_corruption_has_no_startup_recovery()
};
let metadata = board.post(root, request.clone()).await.unwrap();
drop(board);
let resumed = LocalAgentMessageBoard::open(&sqlite, tree, host)
let resumed = LocalAgentMessageBoard::open(&sqlite, &options, tree, host)
.await
.unwrap();
assert_eq!(resumed.post(root, request).await.unwrap(), metadata);
@@ -152,6 +153,7 @@ async fn opening_board_preserves_posts_when_corruption_has_no_startup_recovery()
async fn shared_handles_resume_posts_and_preserve_subscription_rules() {
let dir = tempfile::tempdir().unwrap();
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
let options = LocalBoardOptions::default();
let root = ThreadId::new();
let child = ThreadId::new();
let child_path = AgentPath::root().join("worker").unwrap();
@@ -164,7 +166,7 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() {
notifications: Mutex::default(),
});
let tree = SessionId::from(root);
let first = LocalAgentMessageBoard::open(&sqlite, tree, host.clone())
let first = LocalAgentMessageBoard::open(&sqlite, &options, tree, host.clone())
.await
.unwrap();
first
@@ -186,7 +188,7 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() {
// The creator receives this through its channel subscription.
agents_to_notify: vec![AgentPath::root()],
};
let second = LocalAgentMessageBoard::open(&sqlite, tree, host.clone())
let second = LocalAgentMessageBoard::open(&sqlite, &options, tree, host.clone())
.await
.unwrap();
let (metadata, duplicate) = tokio::join!(
@@ -213,7 +215,7 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() {
.unwrap();
legacy_pool.close().await;
let resumed = LocalAgentMessageBoard::open(&sqlite, tree, host.clone())
let resumed = LocalAgentMessageBoard::open(&sqlite, &options, tree, host.clone())
.await
.unwrap();
assert_eq!(resumed.post(root, request).await.unwrap(), metadata);
@@ -337,7 +339,7 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() {
.await
.unwrap();
drop(resumed);
let resumed = LocalAgentMessageBoard::open(&sqlite, tree, host.clone())
let resumed = LocalAgentMessageBoard::open(&sqlite, &options, tree, host.clone())
.await
.unwrap();
resumed
@@ -418,7 +420,7 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() {
.unwrap();
assert_eq!(*host.notifications.lock().unwrap(), vec![(root, reply)]);
let other = LocalAgentMessageBoard::open(&sqlite, SessionId::new(), host.clone())
let other = LocalAgentMessageBoard::open(&sqlite, &options, SessionId::new(), host.clone())
.await
.unwrap();
assert!(
@@ -434,7 +436,7 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() {
.await
.is_err()
);
LocalAgentMessageBoard::delete_boards(&sqlite, &[tree])
LocalAgentMessageBoard::delete_boards(&sqlite, &options, &[tree])
.await
.unwrap();
let pool = sqlite
@@ -519,7 +521,7 @@ impl Backend {
) -> Arc<dyn AgentMessageBoard> {
match self {
Self::Local => Arc::new(
LocalAgentMessageBoard::open(sqlite, identity, host)
LocalAgentMessageBoard::open(sqlite, &LocalBoardOptions::default(), identity, host)
.await
.unwrap(),
),
@@ -1427,6 +1429,7 @@ fn channel_descriptions_validate_construction_and_serialization() {
async fn channel_descriptions_migrate_legacy_storage_and_survive_reopen() {
let dir = tempfile::tempdir().unwrap();
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
let options = LocalBoardOptions::default();
let root = ThreadId::new();
let host = Arc::new(Host {
clock: AtomicI64::default(),
@@ -1449,7 +1452,7 @@ async fn channel_descriptions_migrate_legacy_storage_and_survive_reopen() {
.await
.unwrap();
pool.close().await;
let board = LocalAgentMessageBoard::open(&sqlite, root.into(), host.clone())
let board = LocalAgentMessageBoard::open(&sqlite, &options, root.into(), host.clone())
.await
.unwrap();
let legacy = ChannelSummary {
@@ -1478,7 +1481,7 @@ async fn channel_descriptions_migrate_legacy_storage_and_survive_reopen() {
.await
.unwrap();
drop(board);
let reopened = LocalAgentMessageBoard::open(&sqlite, root.into(), host)
let reopened = LocalAgentMessageBoard::open(&sqlite, &options, root.into(), host)
.await
.unwrap();
let listed = reopened
@@ -1506,6 +1509,9 @@ async fn local_board_setup_survives_reopen() -> Result<()> {
use serde_json::json;
let dir = tempfile::tempdir()?;
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
let options = LocalBoardOptions {
storage_namespace: "setup_test".into(),
};
let root = ThreadId::new();
let host = Arc::new(Host {
clock: AtomicI64::default(),
@@ -1516,11 +1522,23 @@ async fn local_board_setup_survives_reopen() -> Result<()> {
notifications: Mutex::default(),
});
let setup = json!({"timestamp":"2026-09-18T12:00:00Z","template":{"version":1,"channels":{"policy":{"description":"Shared policies"}}}});
configure_local_board(&sqlite, root.into(), serde_json::from_value(setup.clone())?).await?;
configure_local_board(&sqlite, root.into(), serde_json::from_value(setup.clone())?).await?;
let board = LocalAgentMessageBoard::open(&sqlite, root.into(), host.clone()).await?;
configure_local_board(
&sqlite,
&options,
root.into(),
serde_json::from_value(setup.clone())?,
)
.await?;
configure_local_board(
&sqlite,
&options,
root.into(),
serde_json::from_value(setup.clone())?,
)
.await?;
let board = LocalAgentMessageBoard::open(&sqlite, &options, root.into(), host.clone()).await?;
drop(board);
let board = LocalAgentMessageBoard::open(&sqlite, root.into(), host.clone()).await?;
let board = LocalAgentMessageBoard::open(&sqlite, &options, root.into(), host.clone()).await?;
let query = ChannelQuery {
query: None,
direction: SortDirection::NewestFirst,
@@ -1537,9 +1555,14 @@ async fn local_board_setup_survives_reopen() -> Result<()> {
let mut changed = setup.clone();
changed["template"]["channels"]["policy"]["description"] = json!("Changed");
assert!(
configure_local_board(&sqlite, root.into(), serde_json::from_value(changed)?)
.await
.is_err()
configure_local_board(
&sqlite,
&options,
root.into(),
serde_json::from_value(changed)?
)
.await
.is_err()
);
board
.create_channel(
@@ -1551,7 +1574,13 @@ async fn local_board_setup_survives_reopen() -> Result<()> {
},
)
.await?;
configure_local_board(&sqlite, root.into(), serde_json::from_value(setup.clone())?).await?;
configure_local_board(
&sqlite,
&options,
root.into(),
serde_json::from_value(setup.clone())?,
)
.await?;
assert_eq!(
board
.list_channels(root, query)
@@ -1561,11 +1590,111 @@ async fn local_board_setup_survives_reopen() -> Result<()> {
.len(),
2
);
LocalAgentMessageBoard::delete_boards(&sqlite, &[root.into()]).await?;
LocalAgentMessageBoard::delete_boards(&sqlite, &options, &[root.into()]).await?;
assert!(
configure_local_board(&sqlite, root.into(), serde_json::from_value(setup)?)
.await
.is_err()
configure_local_board(
&sqlite,
&options,
root.into(),
serde_json::from_value(setup)?
)
.await
.is_err()
);
Ok(())
}
#[tokio::test]
async fn storage_namespaces_preserve_isolation_and_reopen() -> Result<()> {
let dir = tempfile::tempdir()?;
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
let root = ThreadId::new();
let child = ThreadId::new();
let child_path = AgentPath::root().join("worker").unwrap();
let host = Arc::new(Host {
clock: AtomicI64::default(),
agent_path_calls: AtomicUsize::default(),
members: [(root, AgentPath::root()), (child, child_path)].into(),
active: AtomicBool::new(true),
fail_notifications: AtomicBool::new(false),
notifications: Mutex::default(),
});
for storage_namespace in ["queue", "goals"] {
// Opening a board must not modify another runtime database.
let runtime_database = sqlite
.open_read_write_pool(&dir.path().join(format!("{storage_namespace}_1.sqlite")))
.await
.unwrap();
sqlx::query("CREATE TABLE runtime_data (value TEXT NOT NULL)")
.execute(&runtime_database)
.await
.unwrap();
let identity = SessionId::new();
let options = LocalBoardOptions {
storage_namespace: storage_namespace.into(),
};
let descriptor = LocalBoard::new(sqlite.clone(), identity).with_options(options);
let writer = descriptor.read_write().open(host.clone()).await?;
let seed = PostRequest {
request_id: "seed".into(),
destination: PostDestination::NewChannel("records".into()),
text: "Saved evidence".into(),
agents_to_notify: Vec::new(),
};
let metadata = writer.post(root, seed.clone()).await?;
let read = ReadPostRequest {
message_id: metadata.message_id,
offset_chars: 0,
limit_chars: NonZeroU32::MAX,
};
let legacy = LocalAgentMessageBoard::open(
&sqlite,
&LocalBoardOptions::default(),
identity,
host.clone(),
)
.await?;
assert!(legacy.read_post(root, read.clone()).await.is_err());
let legacy_metadata = legacy.post(root, seed.clone()).await?;
// Release the writer before reopening its saved posts.
drop(writer);
let reopened = descriptor.read_write().open(host.clone()).await?;
assert_eq!(
reopened.read_post(child, read).await?,
PostContent {
metadata,
text: seed.text.clone(),
n_chars: seed.text.chars().count(),
next_offset_chars: seed.text.chars().count(),
}
);
assert_eq!(
legacy
.read_post(
root,
ReadPostRequest {
message_id: legacy_metadata.message_id,
offset_chars: 0,
limit_chars: NonZeroU32::MAX,
},
)
.await?,
PostContent {
metadata: legacy_metadata,
text: seed.text.clone(),
n_chars: seed.text.chars().count(),
next_offset_chars: seed.text.chars().count(),
}
);
assert_eq!(
sqlx::query_scalar::<_, String>(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
)
.fetch_all(&runtime_database)
.await
.unwrap(),
vec!["runtime_data".to_owned()]
);
}
Ok(())
}