diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index b732b16..5ac726d 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1318,6 +1318,64 @@ impl Client { Ok(wrapped.value) } + /// §11.6: a group's shared-docs collection (operator read). + /// `GET /api/v1/admin/groups/{id_or_slug}/docs?collection=…&limit=…` + pub async fn group_docs_list( + &self, + group: &str, + collection: &str, + limit: u32, + ) -> Result { + let (group, collection) = (seg(group), seg(collection)); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/groups/{group}/docs?collection={collection}&limit={limit}"), + ) + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/groups/{id_or_slug}/docs/{collection}/{doc_id}` + pub async fn group_docs_get( + &self, + group: &str, + collection: &str, + doc_id: &str, + ) -> Result { + let (group, collection, doc_id) = (seg(group), seg(collection), seg(doc_id)); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/groups/{group}/docs/{collection}/{doc_id}"), + ) + .send() + .await?; + decode(resp).await + } + + /// §11.6 D3: a group's shared-queue dead-letters (operator read; list-only). + /// `GET /api/v1/admin/groups/{id_or_slug}/dead-letters?unresolved=…&limit=…` + pub async fn group_dead_letters_list( + &self, + group: &str, + unresolved: bool, + limit: u32, + ) -> Result> { + let group = seg(group); + let resp = self + .request( + Method::GET, + &format!( + "/api/v1/admin/groups/{group}/dead-letters?unresolved={unresolved}&limit={limit}" + ), + ) + .send() + .await?; + decode(resp).await + } + // --- Queues (G2, read-only admin surface) ----------------------------- /// `GET /api/v1/admin/apps/{id_or_slug}/queues` @@ -2344,6 +2402,37 @@ pub struct KvListPageDto { pub next_cursor: Option, } +/// One document in a group's shared-docs collection (operator listing). The +/// server also sends each doc's `data`, but the `ls` view shows ids only (fetch +/// a document's body with `pic docs get`), so it is not deserialized here. +#[derive(Debug, Deserialize)] +pub struct GroupDocEntryDto { + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct GroupDocsListDto { + pub docs: Vec, + pub next_cursor: Option, +} + +/// One group shared-queue dead-letter (operator listing). Distinct from the +/// per-app `DeadLetterDto`: a group DL carries no `app_id`/`trigger_id` and +/// includes the `collection` it belonged to; timestamps are RFC3339 strings. +#[derive(Debug, Deserialize)] +pub struct GroupDeadLetterDto { + pub id: String, + pub collection: String, + pub source: String, + pub op: String, + pub attempt_count: u32, + pub last_error: String, + pub created_at: String, + pub resolved_at: Option, + #[allow(dead_code)] + pub payload: Value, +} + #[derive(Debug, Deserialize)] struct KvGetResponse { value: Value, diff --git a/crates/picloud-cli/src/cmds/dead_letters.rs b/crates/picloud-cli/src/cmds/dead_letters.rs index a15a7f0..3cfb3bd 100644 --- a/crates/picloud-cli/src/cmds/dead_letters.rs +++ b/crates/picloud-cli/src/cmds/dead_letters.rs @@ -18,33 +18,73 @@ pub async fn count(app: &str, mode: OutputMode) -> Result<()> { Ok(()) } -pub async fn ls(app: &str, unresolved: bool, limit: u32, mode: OutputMode) -> Result<()> { +/// `pic dead-letters ls` — list rows for an app (`--app`) or a group's shared +/// queues (`--group`, §11.6 D3; list-only, no show/replay/resolve — those stay +/// app-only, matching the server's operator surface). +pub async fn ls( + app: Option<&str>, + group: Option<&str>, + unresolved: bool, + limit: u32, + mode: OutputMode, +) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let resp = client.dead_letters_list(app, unresolved, limit).await?; - let mut table = Table::new([ - "id", - "source", - "op", - "attempts", - "resolved", - "last_error", - "created_at", - ]); - for d in resp.dead_letters { - let last_err = truncate(&d.last_error, 60); - let resolved = d.resolution.unwrap_or_else(|| "-".into()); - table.row([ - d.id, - d.source, - d.op, - d.attempt_count.to_string(), - resolved, - last_err, - d.created_at.to_rfc3339(), - ]); + match crate::cmds::require_one_owner(app, group)? { + crate::cmds::OwnerRef::App(a) => { + let resp = client.dead_letters_list(a, unresolved, limit).await?; + let mut table = Table::new([ + "id", + "source", + "op", + "attempts", + "resolved", + "last_error", + "created_at", + ]); + for d in resp.dead_letters { + let resolved = d.resolution.unwrap_or_else(|| "-".into()); + table.row([ + d.id, + d.source, + d.op, + d.attempt_count.to_string(), + resolved, + truncate(&d.last_error, 60), + d.created_at.to_rfc3339(), + ]); + } + table.print(mode); + } + crate::cmds::OwnerRef::Group(g) => { + let rows = client.group_dead_letters_list(g, unresolved, limit).await?; + // A group DL carries the `collection` it belonged to and no trigger. + let mut table = Table::new([ + "id", + "collection", + "source", + "op", + "attempts", + "resolved", + "last_error", + "created_at", + ]); + for d in rows { + let resolved = d.resolved_at.unwrap_or_else(|| "-".into()); + table.row([ + d.id, + d.collection, + d.source, + d.op, + d.attempt_count.to_string(), + resolved, + truncate(&d.last_error, 60), + d.created_at, + ]); + } + table.print(mode); + } } - table.print(mode); Ok(()) } diff --git a/crates/picloud-cli/src/cmds/docs.rs b/crates/picloud-cli/src/cmds/docs.rs new file mode 100644 index 0000000..64adf15 --- /dev/null +++ b/crates/picloud-cli/src/cmds/docs.rs @@ -0,0 +1,38 @@ +//! `pic docs ls | get --group` — read-only inspection of a group's shared-docs +//! collection (§11.6). Group-only: per-app docs have no admin read route yet +//! (unlike kv/files), so there is no `--app` variant here. +//! +//! Read-only by design (matching `pic kv`): docs writes go through +//! `docs::shared_collection(...).create/update` in scripts, which emit the +//! change events shared triggers depend on; an admin write would bypass that. + +use anyhow::Result; + +use crate::client::Client; +use crate::config; +use crate::output::{OutputMode, Table}; + +pub async fn ls(group: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let page = client.group_docs_list(group, collection, limit).await?; + let mut table = Table::new(["id"]); + for d in page.docs { + table.row([d.id]); + } + table.print(mode); + if page.next_cursor.is_some() { + eprintln!("(more documents available — raise --limit to see them)"); + } + Ok(()) +} + +pub async fn get(group: &str, collection: &str, doc_id: &str) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let value = client.group_docs_get(group, collection, doc_id).await?; + // Emit the document JSON (pretty) so it pipes cleanly into jq. + let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); + println!("{pretty}"); + Ok(()) +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index 6213d88..34db67a 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -8,6 +8,7 @@ pub mod apps_domains; pub mod collections; pub mod config; pub mod dead_letters; +pub mod docs; pub mod extension_points; pub mod files; pub mod groups; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 1acbda4..5e0709e 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -214,6 +214,13 @@ enum Cmd { cmd: KvCmd, }, + /// Docs inspection for a group's shared-docs collection (§11.6). + /// Read-only; writes go through `docs::shared_collection(...)` in scripts. + Docs { + #[command(subcommand)] + cmd: DocsCmd, + }, + /// Reconcile the live app to a `picloud.toml` manifest in one /// transaction (creates + updates; `--prune` also deletes resources /// absent from the manifest). @@ -372,6 +379,27 @@ enum KvCmd { }, } +#[derive(Subcommand)] +enum DocsCmd { + /// List document ids in a group's shared-docs collection. + Ls { + #[arg(long)] + group: String, + #[arg(long)] + collection: String, + #[arg(long, default_value_t = 100)] + limit: u32, + }, + /// Fetch one document's data (printed as JSON). + Get { + #[arg(long)] + group: String, + #[arg(long)] + collection: String, + id: String, + }, +} + #[derive(Subcommand)] enum MembersCmd { /// List app members. @@ -1280,10 +1308,13 @@ enum DeadLettersCmd { app: String, }, - /// List dead-letter rows for an app. + /// List dead-letter rows. Exactly one of `--app` (per-app) or `--group` + /// (a group's §11.6 shared queues; list-only). Ls { + #[arg(long, conflicts_with = "group")] + app: Option, #[arg(long)] - app: String, + group: Option, /// Show only unresolved rows. #[arg(long)] unresolved: bool, @@ -2056,10 +2087,13 @@ async fn main() -> ExitCode { cmd: DeadLettersCmd::Ls { app, + group, unresolved, limit, }, - } => cmds::dead_letters::ls(&app, unresolved, limit, mode).await, + } => { + cmds::dead_letters::ls(app.as_deref(), group.as_deref(), unresolved, limit, mode).await + } Cmd::DeadLetters { cmd: DeadLettersCmd::Show { app, dl_id }, } => cmds::dead_letters::show(&app, &dl_id, mode).await, @@ -2219,6 +2253,22 @@ async fn main() -> ExitCode { key, }, } => cmds::kv::get(app.as_deref(), group.as_deref(), &collection, &key).await, + Cmd::Docs { + cmd: + DocsCmd::Ls { + group, + collection, + limit, + }, + } => cmds::docs::ls(&group, &collection, limit, mode).await, + Cmd::Docs { + cmd: + DocsCmd::Get { + group, + collection, + id, + }, + } => cmds::docs::get(&group, &collection, &id).await, }; match result { diff --git a/crates/picloud-cli/tests/collections.rs b/crates/picloud-cli/tests/collections.rs index b8314ab..d2eb37d 100644 --- a/crates/picloud-cli/tests/collections.rs +++ b/crates/picloud-cli/tests/collections.rs @@ -646,3 +646,106 @@ fn operator_reads_shared_kv_via_admin_api() { "kv get --group must return the value:\n{get}" ); } + +/// §11.6: the read-only operator CLI mirrors for a group's shared DOCS +/// collection (`pic docs ls/get --group`) and its shared-queue DEAD-LETTERS +/// (`pic dead-letters ls --group`). A script creates a doc; the operator then +/// lists + fetches it with no script, and the group dead-letters listing +/// succeeds (empty — no failed shared-queue consumer here). +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn operator_reads_shared_docs_and_group_dead_letters_via_cli() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("gblob-grp"); + let app = common::unique_slug("gblob-app"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + // The group declares a docs-kind shared collection. + let dir = manifest_dir(); + let gmanifest = format!( + "[group]\nslug = \"{group}\"\nname = \"GBlob\"\n\n\ + collections = [{{ name = \"articles\", kind = \"docs\" }}]\n" + ); + let gpath = dir.path().join("group.toml"); + fs::write(&gpath, &gmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + let _a = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &group]) + .assert() + .success(); + + // A script creates a document; `create` returns the new doc id. + fs::write( + dir.path().join("scripts/dw.rhai"), + r#"docs::shared_collection("articles").create(#{ title: "hello-ops" })"#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/dw.rhai")) + .args(["--app", &app, "--name", "dw"]) + .assert() + .success(); + let body = invoke_body(&env, &app_script_id(&env, &app, "dw")); + let doc_id = body + .as_str() + .map(str::to_string) + .or_else(|| body.get("id").and_then(|v| v.as_str().map(str::to_string))) + .unwrap_or_else(|| panic!("create should return a doc id, got: {body}")); + + // Operator lists the shared docs collection's ids — no script. + let ls = String::from_utf8( + common::pic_as(&env) + .args(["docs", "ls", "--group", &group, "--collection", "articles"]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + ls.contains(&doc_id), + "docs ls --group must list the new doc id `{doc_id}`:\n{ls}" + ); + + // Operator fetches the document body. + let get = String::from_utf8( + common::pic_as(&env) + .args([ + "docs", + "get", + "--group", + &group, + "--collection", + "articles", + &doc_id, + ]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + get.contains("hello-ops"), + "docs get --group must return the document data:\n{get}" + ); + + // The group dead-letters listing works (empty — no failed shared consumer). + common::pic_as(&env) + .args(["dead-letters", "ls", "--group", &group]) + .assert() + .success(); +}