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)
|
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) -----------------------------
|
// --- Queues (G2, read-only admin surface) -----------------------------
|
||||||
|
|
||||||
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
|
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
|
||||||
@@ -2344,6 +2402,37 @@ pub struct KvListPageDto {
|
|||||||
pub next_cursor: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct KvGetResponse {
|
struct KvGetResponse {
|
||||||
value: Value,
|
value: Value,
|
||||||
|
|||||||
@@ -18,33 +18,73 @@ pub async fn count(app: &str, mode: OutputMode) -> Result<()> {
|
|||||||
Ok(())
|
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 creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let resp = client.dead_letters_list(app, unresolved, limit).await?;
|
match crate::cmds::require_one_owner(app, group)? {
|
||||||
let mut table = Table::new([
|
crate::cmds::OwnerRef::App(a) => {
|
||||||
"id",
|
let resp = client.dead_letters_list(a, unresolved, limit).await?;
|
||||||
"source",
|
let mut table = Table::new([
|
||||||
"op",
|
"id",
|
||||||
"attempts",
|
"source",
|
||||||
"resolved",
|
"op",
|
||||||
"last_error",
|
"attempts",
|
||||||
"created_at",
|
"resolved",
|
||||||
]);
|
"last_error",
|
||||||
for d in resp.dead_letters {
|
"created_at",
|
||||||
let last_err = truncate(&d.last_error, 60);
|
]);
|
||||||
let resolved = d.resolution.unwrap_or_else(|| "-".into());
|
for d in resp.dead_letters {
|
||||||
table.row([
|
let resolved = d.resolution.unwrap_or_else(|| "-".into());
|
||||||
d.id,
|
table.row([
|
||||||
d.source,
|
d.id,
|
||||||
d.op,
|
d.source,
|
||||||
d.attempt_count.to_string(),
|
d.op,
|
||||||
resolved,
|
d.attempt_count.to_string(),
|
||||||
last_err,
|
resolved,
|
||||||
d.created_at.to_rfc3339(),
|
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(())
|
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 collections;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
|
pub mod docs;
|
||||||
pub mod extension_points;
|
pub mod extension_points;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
|
|||||||
@@ -214,6 +214,13 @@ enum Cmd {
|
|||||||
cmd: KvCmd,
|
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
|
/// Reconcile the live app to a `picloud.toml` manifest in one
|
||||||
/// transaction (creates + updates; `--prune` also deletes resources
|
/// transaction (creates + updates; `--prune` also deletes resources
|
||||||
/// absent from the manifest).
|
/// 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)]
|
#[derive(Subcommand)]
|
||||||
enum MembersCmd {
|
enum MembersCmd {
|
||||||
/// List app members.
|
/// List app members.
|
||||||
@@ -1280,10 +1308,13 @@ enum DeadLettersCmd {
|
|||||||
app: String,
|
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 {
|
Ls {
|
||||||
|
#[arg(long, conflicts_with = "group")]
|
||||||
|
app: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
/// Show only unresolved rows.
|
/// Show only unresolved rows.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
unresolved: bool,
|
unresolved: bool,
|
||||||
@@ -2056,10 +2087,13 @@ async fn main() -> ExitCode {
|
|||||||
cmd:
|
cmd:
|
||||||
DeadLettersCmd::Ls {
|
DeadLettersCmd::Ls {
|
||||||
app,
|
app,
|
||||||
|
group,
|
||||||
unresolved,
|
unresolved,
|
||||||
limit,
|
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::DeadLetters {
|
||||||
cmd: DeadLettersCmd::Show { app, dl_id },
|
cmd: DeadLettersCmd::Show { app, dl_id },
|
||||||
} => cmds::dead_letters::show(&app, &dl_id, mode).await,
|
} => cmds::dead_letters::show(&app, &dl_id, mode).await,
|
||||||
@@ -2219,6 +2253,22 @@ async fn main() -> ExitCode {
|
|||||||
key,
|
key,
|
||||||
},
|
},
|
||||||
} => cmds::kv::get(app.as_deref(), group.as_deref(), &collection, &key).await,
|
} => 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 {
|
match result {
|
||||||
|
|||||||
@@ -646,3 +646,106 @@ fn operator_reads_shared_kv_via_admin_api() {
|
|||||||
"kv get --group must return the value:\n{get}"
|
"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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user