feat(group-blobs): pic kv ls/get --group + journey + docs (M4.3+M4.4)
- `pic kv ls --group` / `pic kv get --group` (mutually exclusive with --app) hit the M4 admin API; client group_kv_list/group_kv_get. - collections journey: a script writes a shared KV value, the operator lists + fetches it via the admin API with no script. - docs: §11.6 + CLAUDE.md record M3 quotas + M4 read-only admin API. Completes M4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1141,6 +1141,39 @@ impl Client {
|
|||||||
Ok(wrapped.value)
|
Ok(wrapped.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §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(
|
||||||
|
&self,
|
||||||
|
group: &str,
|
||||||
|
collection: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<KvListPageDto> {
|
||||||
|
let (group, collection) = (seg(group), seg(collection));
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/groups/{group}/kv?collection={collection}&limit={limit}"),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/groups/{id_or_slug}/kv/{collection}/{key}`
|
||||||
|
pub async fn group_kv_get(&self, group: &str, collection: &str, key: &str) -> Result<Value> {
|
||||||
|
let (group, collection, key) = (seg(group), seg(collection), seg(key));
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/groups/{group}/kv/{collection}/{key}"),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let wrapped: KvGetResponse = decode(resp).await?;
|
||||||
|
Ok(wrapped.value)
|
||||||
|
}
|
||||||
|
|
||||||
// --- 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`
|
||||||
|
|||||||
@@ -7,16 +7,27 @@
|
|||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::output::{OutputMode, Table};
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
pub async fn ls(app: &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 creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let page = client.kv_list(app, collection, limit).await?;
|
let page = match (app, group) {
|
||||||
|
(Some(a), None) => client.kv_list(a, collection, limit).await?,
|
||||||
|
// §11.6 M4: a group's shared-KV collection.
|
||||||
|
(None, Some(g)) => client.group_kv_list(g, collection, limit).await?,
|
||||||
|
_ => bail!("provide exactly one of --app or --group"),
|
||||||
|
};
|
||||||
let mut table = Table::new(["key"]);
|
let mut table = Table::new(["key"]);
|
||||||
for k in page.keys {
|
for k in page.keys {
|
||||||
table.row([k]);
|
table.row([k]);
|
||||||
@@ -31,10 +42,19 @@ pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
|
pub async fn get(
|
||||||
|
app: Option<&str>,
|
||||||
|
group: Option<&str>,
|
||||||
|
collection: &str,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let value = client.kv_get(app, collection, key).await?;
|
let value = match (app, group) {
|
||||||
|
(Some(a), None) => client.kv_get(a, collection, key).await?,
|
||||||
|
(None, Some(g)) => client.group_kv_get(g, collection, key).await?,
|
||||||
|
_ => bail!("provide exactly one of --app or --group"),
|
||||||
|
};
|
||||||
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
|
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
|
||||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||||
println!("{pretty}");
|
println!("{pretty}");
|
||||||
|
|||||||
@@ -315,19 +315,24 @@ struct ConfigArgs {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum KvCmd {
|
enum KvCmd {
|
||||||
/// List keys in a collection.
|
/// List keys in a collection. Exactly one of `--app` (per-app KV) or
|
||||||
|
/// `--group` (a group's §11.6 shared KV).
|
||||||
Ls {
|
Ls {
|
||||||
|
#[arg(long, conflicts_with = "group")]
|
||||||
|
app: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
collection: String,
|
collection: String,
|
||||||
#[arg(long, default_value_t = 100)]
|
#[arg(long, default_value_t = 100)]
|
||||||
limit: u32,
|
limit: u32,
|
||||||
},
|
},
|
||||||
/// Fetch one key's value (printed as JSON).
|
/// Fetch one key's value (printed as JSON). `--app` or `--group`.
|
||||||
Get {
|
Get {
|
||||||
|
#[arg(long, conflicts_with = "group")]
|
||||||
|
app: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
collection: String,
|
collection: String,
|
||||||
key: String,
|
key: String,
|
||||||
@@ -2083,18 +2088,20 @@ async fn main() -> ExitCode {
|
|||||||
cmd:
|
cmd:
|
||||||
KvCmd::Ls {
|
KvCmd::Ls {
|
||||||
app,
|
app,
|
||||||
|
group,
|
||||||
collection,
|
collection,
|
||||||
limit,
|
limit,
|
||||||
},
|
},
|
||||||
} => cmds::kv::ls(&app, &collection, limit, mode).await,
|
} => cmds::kv::ls(app.as_deref(), group.as_deref(), &collection, limit, mode).await,
|
||||||
Cmd::Kv {
|
Cmd::Kv {
|
||||||
cmd:
|
cmd:
|
||||||
KvCmd::Get {
|
KvCmd::Get {
|
||||||
app,
|
app,
|
||||||
|
group,
|
||||||
collection,
|
collection,
|
||||||
key,
|
key,
|
||||||
},
|
},
|
||||||
} => cmds::kv::get(&app, &collection, &key).await,
|
} => cmds::kv::get(app.as_deref(), group.as_deref(), &collection, &key).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
@@ -556,3 +556,93 @@ fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
|||||||
);
|
);
|
||||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §11.6 M4: the read-only operator admin API for a group's shared collections.
|
||||||
|
/// A script writes to a shared KV collection; the operator then lists + fetches
|
||||||
|
/// it through `pic kv ls --group` / `pic kv get --group` (no script).
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn operator_reads_shared_kv_via_admin_api() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let group = common::unique_slug("m4-grp");
|
||||||
|
let app = common::unique_slug("m4-app");
|
||||||
|
|
||||||
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let dir = manifest_dir();
|
||||||
|
let gmanifest =
|
||||||
|
format!("[group]\nslug = \"{group}\"\nname = \"M4\"\n\ncollections = [\"catalog\"]\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 writes a value to the shared collection.
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/w.rhai"),
|
||||||
|
r#"kv::shared_collection("catalog").set("op-key", #{ v: 42 }); "ok""#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/w.rhai"))
|
||||||
|
.args(["--app", &app, "--name", "w"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &app_script_id(&env, &app, "w")])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Operator lists the shared collection's keys via the admin API — no script.
|
||||||
|
let ls = String::from_utf8(
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["kv", "ls", "--group", &group, "--collection", "catalog"])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
ls.contains("op-key"),
|
||||||
|
"kv ls --group must list the key:\n{ls}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Operator fetches the value.
|
||||||
|
let get = String::from_utf8(
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"kv",
|
||||||
|
"get",
|
||||||
|
"--group",
|
||||||
|
&group,
|
||||||
|
"--collection",
|
||||||
|
"catalog",
|
||||||
|
"op-key",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
get.contains("42"),
|
||||||
|
"kv get --group must return the value:\n{get}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user