feat(cli): per-app docs admin read (pic docs ls/get --app)
Fills the one per-app admin-read gap kv/files already covered. New
docs_api.rs mirrors kv_api (GET /apps/{id}/docs[/{collection}/{doc_id}],
AppDocsRead capability, DocsRepo::list/get) with the group_blobs_api docs
response shape so the CLI shares one deserialize. DocsCmd gains optional
--app/--group (mutually exclusive, like KvCmd) dispatched via
require_one_owner; new client docs_list/docs_get. Read-only by design,
matching kv/files. Pinned by a collections journey: a script writes the
app's own docs collection, the operator lists + fetches it with no script.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1285,6 +1285,39 @@ impl Client {
|
||||
Ok(wrapped.value)
|
||||
}
|
||||
|
||||
/// Per-app docs (operator read; list-only ids).
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/docs?collection=…&limit=…`
|
||||
/// Reuses `GroupDocsListDto` — the server sends the same `{ docs, next_cursor }` shape.
|
||||
pub async fn docs_list(
|
||||
&self,
|
||||
app: &str,
|
||||
collection: &str,
|
||||
limit: u32,
|
||||
) -> Result<GroupDocsListDto> {
|
||||
let (app, collection) = (seg(app), seg(collection));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/docs?collection={collection}&limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/docs/{collection}/{doc_id}`
|
||||
pub async fn docs_get(&self, app: &str, collection: &str, doc_id: &str) -> Result<Value> {
|
||||
let (app, collection, doc_id) = (seg(app), seg(collection), seg(doc_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/docs/{collection}/{doc_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// §11.6 M4: a group's shared-KV collection.
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}/kv?collection=…`
|
||||
pub async fn group_kv_list(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! `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.
|
||||
//! `pic docs ls | get --app <slug> | --group <slug>` — read-only inspection of a
|
||||
//! docs collection. Both owners are supported: `--app` browses an app's own docs
|
||||
//! collection, `--group` a group's shared-docs collection (§11.6).
|
||||
//!
|
||||
//! 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.
|
||||
//! `docs::create/update` (or `docs::shared_collection(...).create/update`) in
|
||||
//! scripts, which emit the change events triggers depend on; an admin write would
|
||||
//! bypass that.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -12,10 +13,19 @@ 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<()> {
|
||||
pub async fn ls(
|
||||
app: Option<&str>,
|
||||
group: Option<&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 page = match crate::cmds::require_one_owner(app, group)? {
|
||||
crate::cmds::OwnerRef::App(a) => client.docs_list(a, collection, limit).await?,
|
||||
crate::cmds::OwnerRef::Group(g) => client.group_docs_list(g, collection, limit).await?,
|
||||
};
|
||||
let mut table = Table::new(["id"]);
|
||||
for d in page.docs {
|
||||
table.row([d.id]);
|
||||
@@ -27,10 +37,18 @@ pub async fn ls(group: &str, collection: &str, limit: u32, mode: OutputMode) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(group: &str, collection: &str, doc_id: &str) -> Result<()> {
|
||||
pub async fn get(
|
||||
app: Option<&str>,
|
||||
group: Option<&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?;
|
||||
let value = match crate::cmds::require_one_owner(app, group)? {
|
||||
crate::cmds::OwnerRef::App(a) => client.docs_get(a, collection, doc_id).await?,
|
||||
crate::cmds::OwnerRef::Group(g) => client.group_docs_get(g, 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}");
|
||||
|
||||
@@ -381,19 +381,24 @@ enum KvCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DocsCmd {
|
||||
/// List document ids in a group's shared-docs collection.
|
||||
/// List document ids in a collection. Exactly one of `--app` (per-app docs)
|
||||
/// or `--group` (a group's §11.6 shared docs).
|
||||
Ls {
|
||||
#[arg(long, conflicts_with = "group")]
|
||||
app: Option<String>,
|
||||
#[arg(long)]
|
||||
group: String,
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: u32,
|
||||
},
|
||||
/// Fetch one document's data (printed as JSON).
|
||||
/// Fetch one document's data (printed as JSON). `--app` or `--group`.
|
||||
Get {
|
||||
#[arg(long, conflicts_with = "group")]
|
||||
app: Option<String>,
|
||||
#[arg(long)]
|
||||
group: String,
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
id: String,
|
||||
@@ -2256,19 +2261,21 @@ async fn main() -> ExitCode {
|
||||
Cmd::Docs {
|
||||
cmd:
|
||||
DocsCmd::Ls {
|
||||
app,
|
||||
group,
|
||||
collection,
|
||||
limit,
|
||||
},
|
||||
} => cmds::docs::ls(&group, &collection, limit, mode).await,
|
||||
} => cmds::docs::ls(app.as_deref(), group.as_deref(), &collection, limit, mode).await,
|
||||
Cmd::Docs {
|
||||
cmd:
|
||||
DocsCmd::Get {
|
||||
app,
|
||||
group,
|
||||
collection,
|
||||
id,
|
||||
},
|
||||
} => cmds::docs::get(&group, &collection, &id).await,
|
||||
} => cmds::docs::get(app.as_deref(), group.as_deref(), &collection, &id).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
|
||||
@@ -749,3 +749,79 @@ fn operator_reads_shared_docs_and_group_dead_letters_via_cli() {
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
/// B4: the per-app docs admin read surface (`pic docs ls/get --app`), the
|
||||
/// per-app counterpart to the group read above. A script writes to the app's
|
||||
/// OWN docs collection (no group); the operator then lists + fetches it with
|
||||
/// no script, mirroring `pic kv --app`.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn operator_reads_per_app_docs_via_cli() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("appdocs");
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A script creates a document in the app's own docs collection.
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/dw.rhai"),
|
||||
r#"docs::collection("notes").create(#{ title: "per-app-hi" })"#,
|
||||
)
|
||||
.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 app's docs ids — no script.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["docs", "ls", "--app", &app, "--collection", "notes"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains(&doc_id),
|
||||
"docs ls --app 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",
|
||||
"--app",
|
||||
&app,
|
||||
"--collection",
|
||||
"notes",
|
||||
&doc_id,
|
||||
])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
get.contains("per-app-hi"),
|
||||
"docs get --app must return the document data:\n{get}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user