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:
MechaCat02
2026-07-13 20:00:37 +02:00
parent bc2538b5b5
commit 3da70a66c7
6 changed files with 348 additions and 27 deletions

View File

@@ -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,

View File

@@ -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(())
}

View 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(())
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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();
}