feat(cli): add pic docs/dead-letters ls group read mirrors
Two read-only operator surfaces existed server-side but the CLI was app-only:
- `pic dead-letters ls --group <slug>` — mirrors the app listing onto a group's
shared-queue dead-letters (§11.6 D3, `GET /groups/{id}/dead-letters`), via the
existing `require_one_owner`/`OwnerRef` dispatch. List-only (show/replay/
resolve stay app-only, matching the server's operator surface); a group DL row
shows its `collection` and carries no trigger, so it renders its own columns.
- `pic docs ls/get --group <slug>` — a new `Docs` command (`cmds/docs.rs`)
wrapping `GET /groups/{id}/docs[/{collection}/{id}]`, mirroring `pic kv`.
Group-only: per-app docs have no admin read route yet (unlike kv/files), so
there is no `--app` variant — noted for a later pass.
Read-only by design (writes go through the SDK). New client methods +
`GroupDocsListDto`/`GroupDeadLetterDto`. Pinned by a new
`operator_reads_shared_docs_and_group_dead_letters_via_cli` journey
(152/152 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<GroupDocsListDto> {
|
||||
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<Value> {
|
||||
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<Vec<GroupDeadLetterDto>> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<GroupDocEntryDto>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[allow(dead_code)]
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KvGetResponse {
|
||||
value: Value,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
38
crates/picloud-cli/src/cmds/docs.rs
Normal file
38
crates/picloud-cli/src/cmds/docs.rs
Normal file
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// 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 {
|
||||
|
||||
Reference in New Issue
Block a user