feat(cli): kind-aware shared-collection reconcile + string-or-table manifest (§11.6 docs C4)
Make the declarative collection surface carry a `kind` so docs (and future
kinds) are declarable. Back-compatible: the shipped `collections = ["catalog"]`
form still means kv.
- manifest: `collections` entries are now `CollectionDecl` — an untagged enum of
a bare string (kv shorthand) or a `{ name, kind }` table (kind ∈ kv/docs).
`Manifest::collections()` normalizes to `(name, kind)` pairs; build_bundle
emits `[{name, kind}]`. Still `[group]`-only. Unit test covers the
string-or-table mix + app rejection.
- apply: `Bundle.collections: Vec<CollectionSpec>` ({name, kind=default "kv"});
`CurrentState.collections: Vec<(name,kind)>` via list_all_for_owner;
`diff_collections` keys each change by name with `detail = kind` and identity
`(LOWER(name), kind)` (so a kv and a docs collection of the same name are
distinct); reconcile reads the kind from `detail`; state_token folds
`coll|{kind}|{name}`; validate_bundle rejects unknown kinds + dups by
(name,kind); `collection_report`/CollectionInfo + `pic collections ls` gain a
kind column.
Existing kv journeys + nearest-wins still green (the bare-string form normalizes
to kind=kv end to end).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1352,6 +1352,8 @@ impl Client {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CollectionInfoDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
@@ -14,9 +14,9 @@ pub async fn ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = client.collections_list(group).await?;
|
||||
|
||||
let mut table = Table::new(["name"]);
|
||||
let mut table = Table::new(["name", "kind"]);
|
||||
for c in &items {
|
||||
table.row([c.name.clone()]);
|
||||
table.row([c.name.clone(), c.kind.clone()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
|
||||
@@ -163,7 +163,11 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
"extension_points": manifest.extension_points(),
|
||||
"collections": manifest.collections(),
|
||||
"collections": manifest
|
||||
.collections()
|
||||
.into_iter()
|
||||
.map(|(name, kind)| json!({ "name": name, "kind": kind }))
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -111,14 +111,19 @@ impl Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
/// This node's declared shared group-collection names (§11.6). Authored on
|
||||
/// `[group]` nodes only — an `[app]` manifest carrying `collections` is a
|
||||
/// hard parse error (`ManifestApp` has no such field + `deny_unknown_fields`).
|
||||
/// This node's declared shared group collections (§11.6), normalized to
|
||||
/// `(name, kind)` pairs. Authored on `[group]` nodes only — an `[app]`
|
||||
/// manifest carrying `collections` is a hard parse error (`ManifestApp` has
|
||||
/// no such field + `deny_unknown_fields`).
|
||||
#[must_use]
|
||||
pub fn collections(&self) -> &[String] {
|
||||
pub fn collections(&self) -> Vec<(String, String)> {
|
||||
match &self.group {
|
||||
Some(g) => &g.collections,
|
||||
None => &[],
|
||||
Some(g) => g
|
||||
.collections
|
||||
.iter()
|
||||
.map(CollectionDecl::normalized)
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,13 +254,60 @@ pub struct ManifestGroup {
|
||||
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
/// `collections = ["catalog", …]` (§11.6) — shared KV collection names this
|
||||
/// group offers as cross-app-shared (read by any app in the subtree,
|
||||
/// written by an authenticated editor+). Group-only: there is no
|
||||
/// `collections` key on `[app]`. The data lives in `group_kv_entries`;
|
||||
/// pruning a name removes the declaration, not the data.
|
||||
/// `collections = [...]` (§11.6) — shared collections this group offers as
|
||||
/// cross-app-shared (read by any app in the subtree, written by an
|
||||
/// authenticated editor+). Each entry is either a bare string (a `kv`
|
||||
/// collection) or a `{ name, kind }` table (`kind` ∈ `kv`/`docs`).
|
||||
/// Group-only: there is no `collections` key on `[app]`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub collections: Vec<String>,
|
||||
pub collections: Vec<CollectionDecl>,
|
||||
}
|
||||
|
||||
/// The store kind of a shared collection (§11.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CollectionKind {
|
||||
Kv,
|
||||
Docs,
|
||||
}
|
||||
|
||||
impl CollectionKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Kv => "kv",
|
||||
Self::Docs => "docs",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `collections` entry: a bare string (`kv` shorthand) or a `{ name, kind }`
|
||||
/// table. Untagged so the shipped `collections = ["catalog"]` form keeps working
|
||||
/// alongside `{ name = "articles", kind = "docs" }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CollectionDecl {
|
||||
Name(String),
|
||||
Full {
|
||||
name: String,
|
||||
#[serde(default = "kv_kind")]
|
||||
kind: CollectionKind,
|
||||
},
|
||||
}
|
||||
|
||||
fn kv_kind() -> CollectionKind {
|
||||
CollectionKind::Kv
|
||||
}
|
||||
|
||||
impl CollectionDecl {
|
||||
/// `(name, kind-as-string)` — a bare string normalizes to `kind = "kv"`.
|
||||
#[must_use]
|
||||
pub fn normalized(&self) -> (String, String) {
|
||||
match self {
|
||||
Self::Name(n) => (n.clone(), "kv".to_string()),
|
||||
Self::Full { name, kind } => (name.clone(), kind.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -668,6 +720,31 @@ mod tests {
|
||||
assert_eq!(m.extension_points(), ["theme"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_string_or_table_normalizes_kind() {
|
||||
// §11.6: a bare string is a `kv` collection (the shipped form); a
|
||||
// `{ name, kind }` table sets an explicit kind. Both coexist.
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
collections = [\"catalog\", { name = \"articles\", kind = \"docs\" }]\n",
|
||||
)
|
||||
.expect("string-or-table collections must parse");
|
||||
assert_eq!(
|
||||
m.collections(),
|
||||
vec![
|
||||
("catalog".to_string(), "kv".to_string()),
|
||||
("articles".to_string(), "docs".to_string()),
|
||||
]
|
||||
);
|
||||
// An app manifest carrying `collections` is rejected (no such field +
|
||||
// deny_unknown_fields).
|
||||
let app = "[app]\nslug = \"x\"\nname = \"X\"\ncollections = [\"catalog\"]\n";
|
||||
assert!(
|
||||
Manifest::parse(app).is_err(),
|
||||
"collections on [app] must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||
// A [group] node: scripts + vars, no [app].
|
||||
|
||||
Reference in New Issue
Block a user