feat(cli): collections manifest + ls + journeys; rename to kv::shared_collection (§11.6 C5)
Finish the §11.6 KV slice: declarative authoring, read-only inspection, the
runtime journey, and docs — plus a forced SDK-name fix.
- SDK name: `shared` is a Rhai RESERVED keyword, so `kv::shared(...)` is a parse
error. Renamed the handle constructor to `kv::shared_collection(...)`
(keeps the user's "shared" intent; unambiguous). Updated everywhere.
- CLI manifest: `collections = [...]` on `[group]` only (ManifestApp has no such
field + deny_unknown_fields → an app manifest carrying it is a hard error);
Manifest::collections() accessor; build_bundle emits it.
- plan/apply: a `collection` row group in `pic plan`; created/deleted counts in
the apply summary; collections threaded through PlanDto/NodePlanDto/
ApplyReportDto.
- read-only `pic collections ls --group` → GET /admin/groups/{id}/collections
(viewer+), backed by ApplyService::collection_report + CollectionInfo.
- journey: a group declares `catalog`; app A writes, app B (same subtree) reads
it back; a sibling-subtree app gets CollectionNotShared; `collections ls` +
re-apply NoOp. Full suite 114/114.
- docs: groups-and-project-tool §11.6 (KV-only MVP shipped + deferrals),
sdk-shape.md (the shared-collection handle + trust model), CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint
|
|||||||
|
|
||||||
**v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`).
|
**v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`).
|
||||||
|
|
||||||
**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import). Next: §11.6 (group-level collections, v1.3) or multi-node cluster mode.
|
**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV slice** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` (`kind='kv'`) + the group-keyed store `0053_group_kv_entries.sql` (no `app_id` — a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data). Scripts use the **explicit** `kv::shared_collection("name")` handle (`shared` alone is a Rhai reserved word); `GroupKvServiceImpl` resolves the owning group from `cx.app_id`'s ancestor chain (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`. **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead`/`GroupKvWrite`, `script_gate_require_principal` fails closed on anon). Declarative reconcile mirrors `extension_points`; read-only `pic collections ls --group`. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), docs/files/topics/queue shared collections, per-group quotas). Next: extend §11.6 to docs/files/topics, or multi-node cluster mode.
|
||||||
|
|
||||||
**Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary).
|
**Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary).
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ pub struct KvHandle {
|
|||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §11.6 shared-collection handle, returned by `kv::shared(name)`. A distinct
|
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||||
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
|
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
|
||||||
/// collections never grow triggers) and a script's choice of private-vs-shared
|
/// collections never grow triggers) and a script's choice of private-vs-shared
|
||||||
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
||||||
@@ -58,7 +58,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
let kv_service = services.kv.clone();
|
let kv_service = services.kv.clone();
|
||||||
let group_kv_service = services.group_kv.clone();
|
let group_kv_service = services.group_kv.clone();
|
||||||
|
|
||||||
// `kv::collection(name)` / `kv::shared(name)` — both constructors live in
|
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||||
// the `kv` static module so the script-visible calls are `kv::collection`
|
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||||
// and `kv::shared`.
|
// and `kv::shared`.
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
@@ -83,10 +83,10 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
let group_kv_service = group_kv_service.clone();
|
let group_kv_service = group_kv_service.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"shared",
|
"shared_collection",
|
||||||
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err("kv::shared name must not be empty".into());
|
return Err("kv::shared_collection name must not be empty".into());
|
||||||
}
|
}
|
||||||
Ok(GroupKvHandle {
|
Ok(GroupKvHandle {
|
||||||
collection: name.to_string(),
|
collection: name.to_string(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
--
|
--
|
||||||
-- A row marks a collection NAME at a node as group-SHARED: every app in the
|
-- A row marks a collection NAME at a node as group-SHARED: every app in the
|
||||||
-- owning group's subtree may read it (and, with an authenticated editor+,
|
-- owning group's subtree may read it (and, with an authenticated editor+,
|
||||||
-- write it) via the explicit `kv::shared("name")` SDK handle. Resolution is
|
-- write it) via the explicit `kv::shared_collection("name")` SDK handle. Resolution is
|
||||||
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
|
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
|
||||||
-- walk is the isolation boundary (a foreign app's chain never contains the
|
-- walk is the isolation boundary (a foreign app's chain never contains the
|
||||||
-- owning group, so the name simply does not resolve). See docs §11.6.
|
-- owning group, so the name simply does not resolve). See docs §11.6.
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::apply_service::{
|
use crate::apply_service::{
|
||||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtensionPointInfo,
|
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||||
NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||||
};
|
};
|
||||||
use crate::authz::{require, AuthzDenied, Capability};
|
use crate::authz::{require, AuthzDenied, Capability};
|
||||||
use crate::group_repo::GroupRepository;
|
use crate::group_repo::GroupRepository;
|
||||||
@@ -40,9 +40,29 @@ pub fn apply_router(service: ApplyService) -> Router {
|
|||||||
"/groups/{id}/extension-points",
|
"/groups/{id}/extension-points",
|
||||||
get(group_extension_points_handler),
|
get(group_extension_points_handler),
|
||||||
)
|
)
|
||||||
|
.route("/groups/{id}/collections", get(group_collections_handler))
|
||||||
.with_state(service)
|
.with_state(service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only §11.6 shared-collection report for a group: its own declared
|
||||||
|
/// shared KV collection names. Viewer-tier read.
|
||||||
|
async fn group_collections_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.collection_report(ApplyOwner::Group(group_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
/// Read-only §5.5 extension-point report for an app: every EP visible on its
|
/// Read-only §5.5 extension-point report for an app: every EP visible on its
|
||||||
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
|
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
|
||||||
async fn app_extension_points_handler(
|
async fn app_extension_points_handler(
|
||||||
|
|||||||
@@ -437,6 +437,12 @@ pub struct ExtensionPointInfo {
|
|||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row of the read-only §11.6 shared-collection report.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CollectionInfo {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Errors
|
// Errors
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -1772,6 +1778,24 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only §11.6 shared-collection report for a node: the shared KV
|
||||||
|
/// collection names declared directly at it. Group-only in practice (the
|
||||||
|
/// CLI rejects app-declared collections); an app node returns its own
|
||||||
|
/// (degenerate) declarations, if any. Backs `pic collections ls`.
|
||||||
|
pub async fn collection_report(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
) -> Result<Vec<CollectionInfo>, ApplyError> {
|
||||||
|
let names =
|
||||||
|
crate::group_collection_repo::list_for_owner(&self.pool, owner.as_script_owner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| CollectionInfo { name })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// §5.5 no-provider plan check (app nodes only). Every extension point
|
/// §5.5 no-provider plan check (app nodes only). Every extension point
|
||||||
/// visible to the app — declared in this bundle or inherited from an
|
/// visible to the app — declared in this bundle or inherited from an
|
||||||
/// ancestor — must have a provider: a module of that name the app provides
|
/// ancestor — must have a provider: a module of that name the app provides
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
|
//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
|
||||||
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
|
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
|
||||||
//! the `kv::shared("name")` Rhai handle (§11.6).
|
//! the `kv::shared_collection("name")` Rhai handle (§11.6).
|
||||||
//!
|
//!
|
||||||
//! Layers added over the raw repo:
|
//! Layers added over the raw repo:
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -1332,6 +1332,26 @@ impl Client {
|
|||||||
.await?;
|
.await?;
|
||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
|
||||||
|
/// shared collections are declared on groups.
|
||||||
|
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {
|
||||||
|
let ident = seg(group_ident);
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/groups/{ident}/collections"),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the §11.6 shared-collection report.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CollectionInfoDto {
|
||||||
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||||
@@ -1351,6 +1371,8 @@ pub struct PlanDto {
|
|||||||
pub vars: Vec<ChangeDto>,
|
pub vars: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extension_points: Vec<ChangeDto>,
|
pub extension_points: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub collections: Vec<ChangeDto>,
|
||||||
/// Fingerprint of the live state this plan was computed against; carried
|
/// Fingerprint of the live state this plan was computed against; carried
|
||||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1391,6 +1413,8 @@ pub struct NodePlanDto {
|
|||||||
pub vars: Vec<ChangeDto>,
|
pub vars: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extension_points: Vec<ChangeDto>,
|
pub extension_points: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub collections: Vec<ChangeDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response of `POST .../apply`: counts of what changed.
|
/// Response of `POST .../apply`: counts of what changed.
|
||||||
@@ -1423,6 +1447,10 @@ pub struct ApplyReportDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extension_points_deleted: u32,
|
pub extension_points_deleted: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub collections_created: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub collections_deleted: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ pub async fn run(
|
|||||||
"+{} -{}",
|
"+{} -{}",
|
||||||
report.extension_points_created, report.extension_points_deleted
|
report.extension_points_created, report.extension_points_deleted
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"collections",
|
||||||
|
format!(
|
||||||
|
"+{} -{}",
|
||||||
|
report.collections_created, report.collections_deleted
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for w in &report.warnings {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
@@ -165,6 +172,13 @@ pub async fn run_tree(
|
|||||||
"+{} -{}",
|
"+{} -{}",
|
||||||
report.extension_points_created, report.extension_points_deleted
|
report.extension_points_created, report.extension_points_deleted
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"collections",
|
||||||
|
format!(
|
||||||
|
"+{} -{}",
|
||||||
|
report.collections_created, report.collections_deleted
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for w in &report.warnings {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
|
|||||||
23
crates/picloud-cli/src/cmds/collections.rs
Normal file
23
crates/picloud-cli/src/cmds/collections.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//! `pic collections ls --group <g>` — read-only view of a group's §11.6 shared
|
||||||
|
//! KV collections. Group-only (shared collections are owned by groups);
|
||||||
|
//! authoring is declarative via the `[group]` manifest `collections = [...]`.
|
||||||
|
//! Scripts read/write them with `kv::shared_collection("name")`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let items = client.collections_list(group).await?;
|
||||||
|
|
||||||
|
let mut table = Table::new(["name"]);
|
||||||
|
for c in &items {
|
||||||
|
table.row([c.name.clone()]);
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod api_keys;
|
|||||||
pub mod apply;
|
pub mod apply;
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod apps_domains;
|
pub mod apps_domains;
|
||||||
|
pub mod collections;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
pub mod extension_points;
|
pub mod extension_points;
|
||||||
|
|||||||
@@ -64,13 +64,14 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||||
for n in &plan.nodes {
|
for n in &plan.nodes {
|
||||||
let node = format!("{}:{}", n.kind, n.slug);
|
let node = format!("{}:{}", n.kind, n.slug);
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
let groups: [(&str, &Vec<ChangeDto>); 7] = [
|
||||||
("script", &n.scripts),
|
("script", &n.scripts),
|
||||||
("route", &n.routes),
|
("route", &n.routes),
|
||||||
("trigger", &n.triggers),
|
("trigger", &n.triggers),
|
||||||
("secret", &n.secrets),
|
("secret", &n.secrets),
|
||||||
("var", &n.vars),
|
("var", &n.vars),
|
||||||
("extension_point", &n.extension_points),
|
("extension_point", &n.extension_points),
|
||||||
|
("collection", &n.collections),
|
||||||
];
|
];
|
||||||
for (rk, changes) in groups {
|
for (rk, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
@@ -162,6 +163,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
"secrets": manifest.secrets.names,
|
"secrets": manifest.secrets.names,
|
||||||
"vars": vars,
|
"vars": vars,
|
||||||
"extension_points": manifest.extension_points(),
|
"extension_points": manifest.extension_points(),
|
||||||
|
"collections": manifest.collections(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,13 +178,14 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
|||||||
|
|
||||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
let groups: [(&str, &Vec<ChangeDto>); 7] = [
|
||||||
("script", &plan.scripts),
|
("script", &plan.scripts),
|
||||||
("route", &plan.routes),
|
("route", &plan.routes),
|
||||||
("trigger", &plan.triggers),
|
("trigger", &plan.triggers),
|
||||||
("secret", &plan.secrets),
|
("secret", &plan.secrets),
|
||||||
("var", &plan.vars),
|
("var", &plan.vars),
|
||||||
("extension_point", &plan.extension_points),
|
("extension_point", &plan.extension_points),
|
||||||
|
("collection", &plan.collections),
|
||||||
];
|
];
|
||||||
for (kind, changes) in groups {
|
for (kind, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ enum Cmd {
|
|||||||
cmd: ExtensionPointsCmd,
|
cmd: ExtensionPointsCmd,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Shared group collections (§11.6) — read-only view of the KV collection
|
||||||
|
/// names a group offers as cross-app-shared. Authored declaratively via the
|
||||||
|
/// `[group]` manifest `collections = [...]`; scripts read/write them with
|
||||||
|
/// `kv::shared_collection("name")`.
|
||||||
|
Collections {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: CollectionsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
/// Files inspection — list a collection's blobs, download bytes, or
|
/// Files inspection — list a collection's blobs, download bytes, or
|
||||||
/// delete a file. Read + delete only; writes go through scripts.
|
/// delete a file. Read + delete only; writes go through scripts.
|
||||||
Files {
|
Files {
|
||||||
@@ -558,6 +567,16 @@ enum ExtensionPointsCmd {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum CollectionsCmd {
|
||||||
|
/// List the shared collections a group declares (§11.6). Group-only —
|
||||||
|
/// shared collections are owned by groups, not apps.
|
||||||
|
Ls {
|
||||||
|
#[arg(long)]
|
||||||
|
group: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum ScriptsCmd {
|
enum ScriptsCmd {
|
||||||
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
||||||
@@ -1985,6 +2004,9 @@ async fn main() -> ExitCode {
|
|||||||
Cmd::ExtensionPoints {
|
Cmd::ExtensionPoints {
|
||||||
cmd: ExtensionPointsCmd::Ls { app, group },
|
cmd: ExtensionPointsCmd::Ls { app, group },
|
||||||
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||||
|
Cmd::Collections {
|
||||||
|
cmd: CollectionsCmd::Ls { group },
|
||||||
|
} => cmds::collections::ls(&group, mode).await,
|
||||||
Cmd::Files {
|
Cmd::Files {
|
||||||
cmd:
|
cmd:
|
||||||
FilesCmd::Ls {
|
FilesCmd::Ls {
|
||||||
|
|||||||
@@ -111,6 +111,17 @@ 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`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn collections(&self) -> &[String] {
|
||||||
|
match &self.group {
|
||||||
|
Some(g) => &g.collections,
|
||||||
|
None => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load and parse the manifest at `path`.
|
/// Load and parse the manifest at `path`.
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
let body =
|
let body =
|
||||||
@@ -238,6 +249,13 @@ pub struct ManifestGroup {
|
|||||||
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
|
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub extension_points: Vec<String>,
|
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.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub collections: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mod api_keys;
|
|||||||
mod apply;
|
mod apply;
|
||||||
mod apps;
|
mod apps;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod collections;
|
||||||
mod config;
|
mod config;
|
||||||
mod dead_letters;
|
mod dead_letters;
|
||||||
mod email_queue;
|
mod email_queue;
|
||||||
|
|||||||
187
crates/picloud-cli/tests/collections.rs
Normal file
187
crates/picloud-cli/tests/collections.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
//! §11.6 shared group collections, end to end via `pic`:
|
||||||
|
//! * a group declares a shared KV collection `catalog`; two apps under it
|
||||||
|
//! share one store — an authenticated write in app A is read back in app B
|
||||||
|
//! (cross-app data sharing, the deliberate inverse of per-app isolation),
|
||||||
|
//! * an app under a SIBLING group naming `catalog` gets CollectionNotShared
|
||||||
|
//! (the ancestry walk is the isolation boundary),
|
||||||
|
//! * `pic collections ls --group` lists the declared name; re-plan is NoOp.
|
||||||
|
//!
|
||||||
|
//! The anonymous-write-fails-closed half of the trust model is unit-tested in
|
||||||
|
//! `group_kv_service` (a journey invoke always carries the admin principal).
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||||
|
|
||||||
|
fn manifest_dir() -> TempDir {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn shared_collection_is_read_write_across_the_subtree() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let group = common::unique_slug("coll-grp");
|
||||||
|
let sibling = common::unique_slug("coll-sib");
|
||||||
|
let app_a = common::unique_slug("coll-a");
|
||||||
|
let app_b = common::unique_slug("coll-b");
|
||||||
|
let foreign = common::unique_slug("coll-f");
|
||||||
|
|
||||||
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||||
|
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &sibling])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// The group declares `catalog` a shared collection (declarative apply).
|
||||||
|
let dir = manifest_dir();
|
||||||
|
let gmanifest =
|
||||||
|
format!("[group]\nslug = \"{group}\"\nname = \"Coll\"\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();
|
||||||
|
|
||||||
|
// `collections ls --group` shows the declared name.
|
||||||
|
let ls = String::from_utf8(
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["collections", "ls", "--group", &group])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
ls.contains("catalog"),
|
||||||
|
"ls should list the collection:\n{ls}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-apply is a no-op (the marker already exists).
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&gpath)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Two apps under the group; one under a sibling group.
|
||||||
|
let _a = AppGuard::new(&env.url, &env.token, &app_a);
|
||||||
|
let _b = AppGuard::new(&env.url, &env.token, &app_b);
|
||||||
|
let _f = AppGuard::new(&env.url, &env.token, &foreign);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &app_a, "--group", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &app_b, "--group", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &foreign, "--group", &sibling])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// App A writes to the shared collection (authenticated invoke → admin
|
||||||
|
// principal, so the editor+ write gate is satisfied).
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/writer.rhai"),
|
||||||
|
r#"kv::shared_collection("catalog").set("sku-1", #{ price: 9 }); "ok""#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/writer.rhai"))
|
||||||
|
.args(["--app", &app_a, "--name", "writer"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let writer_id = app_script_id(&env, &app_a, "writer");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &writer_id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// App B reads the SAME store back — cross-app sharing.
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/reader.rhai"),
|
||||||
|
r#"kv::shared_collection("catalog").get("sku-1")"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/reader.rhai"))
|
||||||
|
.args(["--app", &app_b, "--name", "reader"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let reader_id = app_script_id(&env, &app_b, "reader");
|
||||||
|
assert_eq!(
|
||||||
|
invoke_body(&env, &reader_id),
|
||||||
|
serde_json::json!({ "price": 9 }),
|
||||||
|
"an app in the subtree reads what another app wrote to the shared collection"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The foreign app (sibling subtree) names `catalog` but it does not resolve
|
||||||
|
// on its chain — CollectionNotShared. The invoke fails.
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/foreign.rhai"),
|
||||||
|
r#"kv::shared_collection("catalog").get("sku-1")"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/foreign.rhai"))
|
||||||
|
.args(["--app", &foreign, "--name", "peek"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let foreign_id = app_script_id(&env, &foreign, "peek");
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &foreign_id])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"a foreign app must not resolve another subtree's shared collection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||||
|
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||||
|
let ls = common::pic_as(env)
|
||||||
|
.args(["scripts", "ls", "--app", app])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
let table = String::from_utf8(ls.stdout).unwrap();
|
||||||
|
table
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.get(2) == Some(&name))
|
||||||
|
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||||
|
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||||
|
let out = common::pic_as(env)
|
||||||
|
.args(["scripts", "invoke", id])
|
||||||
|
.output()
|
||||||
|
.expect("scripts invoke");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"invoke failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! `GroupKvService` — the §11.6 shared group-collection KV contract.
|
//! `GroupKvService` — the §11.6 shared group-collection KV contract.
|
||||||
//!
|
//!
|
||||||
//! The cross-app-sharing counterpart to [`crate::KvService`]. A script reaches
|
//! The cross-app-sharing counterpart to [`crate::KvService`]. A script reaches
|
||||||
//! it via the explicit `kv::shared("name")` handle (distinct from the
|
//! it via the explicit `kv::shared_collection("name")` handle (distinct from the
|
||||||
//! app-private `kv::collection("name")`). Unlike `KvService`, the *owner* of
|
//! app-private `kv::collection("name")`). Unlike `KvService`, the *owner* of
|
||||||
//! the data is not `cx.app_id`: the impl resolves the **owning group** by
|
//! the data is not `cx.app_id`: the impl resolves the **owning group** by
|
||||||
//! walking the calling app's ancestor chain for the nearest group that declares
|
//! walking the calling app's ancestor chain for the nearest group that declares
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ pub struct Services {
|
|||||||
pub vars: Arc<dyn VarsService>,
|
pub vars: Arc<dyn VarsService>,
|
||||||
|
|
||||||
/// Shared cross-app group collections (§11.6). Scripts get
|
/// Shared cross-app group collections (§11.6). Scripts get
|
||||||
/// `kv::shared(name)` — read/write KV scoped to the nearest ancestor
|
/// `kv::shared_collection(name)` — read/write KV scoped to the nearest ancestor
|
||||||
/// group that declares the collection shared. Backed by Postgres in the
|
/// group that declares the collection shared. Backed by Postgres in the
|
||||||
/// picloud binary (wired via [`Services::with_group_kv`]); defaults to
|
/// picloud binary (wired via [`Services::with_group_kv`]); defaults to
|
||||||
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
|
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
|
||||||
|
|||||||
@@ -1044,6 +1044,25 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
|||||||
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
|
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
|
||||||
demands.
|
demands.
|
||||||
|
|
||||||
|
> **Shipped — §11.6 KV slice (full shared read/write).** A group declares a collection
|
||||||
|
> group-shared (`[group]` manifest `collections = ["catalog"]`, owner-polymorphic marker table
|
||||||
|
> `0052_group_collections`, `kind='kv'`); the data lives in `0053_group_kv_entries`, keyed by the
|
||||||
|
> owning `group_id` (NOT app — a shared row belongs to the group). Scripts read/write via the
|
||||||
|
> **explicit** `kv::shared_collection("catalog")` handle (a distinct `GroupKvHandle`; `shared`
|
||||||
|
> alone is a Rhai reserved word, hence `shared_collection`). The service resolves the owning group
|
||||||
|
> from `cx.app_id`'s ancestor chain (nearest-group-wins) — **that walk is the isolation boundary**:
|
||||||
|
> a foreign app's chain never contains the owning group, so the name returns `CollectionNotShared`.
|
||||||
|
> **Trust model:** reads are open to any subtree script (anonymous public HTTP included — the
|
||||||
|
> declaration *is* the grant); **writes require an authenticated editor+** on the owning group
|
||||||
|
> (`GroupKvWrite`, fails closed on an anonymous principal). CASCADE on group delete; an app delete
|
||||||
|
> leaves the shared data. Live- + journey-validated (app A writes, app B reads; a sibling-subtree
|
||||||
|
> app gets `CollectionNotShared`).
|
||||||
|
>
|
||||||
|
> **Deferred (documented gaps):** write-triggers/events on shared collections (the "group trigger
|
||||||
|
> has no app to watch" problem); docs/files/topics/queue shared collections (the `kind` column
|
||||||
|
> generalizes); per-group total-size quotas + write-rate limits; CAS/`set_if`; app-declared
|
||||||
|
> collections. Multi-node tree-apply leans on the runtime backstop for no-op edges, as elsewhere.
|
||||||
|
|
||||||
### 11.1 Re-sequencing review (post-Phase-3)
|
### 11.1 Re-sequencing review (post-Phase-3)
|
||||||
|
|
||||||
A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions
|
A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions
|
||||||
|
|||||||
@@ -74,6 +74,31 @@ collection, id)` for docs. Collections are **mandatory**, not optional
|
|||||||
— even single-collection apps name their collection. The service layer
|
— even single-collection apps name their collection. The service layer
|
||||||
rejects requests with empty collection names.
|
rejects requests with empty collection names.
|
||||||
|
|
||||||
|
### Shared group collections (§11.6)
|
||||||
|
|
||||||
|
`kv::collection(name)` is **app-private** — scoped to `cx.app_id`, the
|
||||||
|
isolation boundary. A separate, explicit handle reaches a **group-shared**
|
||||||
|
KV collection that every app in a group's subtree reads (and an
|
||||||
|
authenticated editor+ writes):
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let cat = kv::shared_collection("catalog"); // resolves the nearest
|
||||||
|
cat.set("sku-1", #{ price: 9 }); // ancestor group that
|
||||||
|
let p = cat.get("sku-1"); // declares "catalog"
|
||||||
|
```
|
||||||
|
|
||||||
|
The name is deliberately distinct from `kv::collection` — a script opts
|
||||||
|
into shared scope explicitly (implicit shadowing would silently redirect a
|
||||||
|
write across apps). (`kv::shared` would be ideal but `shared` is a Rhai
|
||||||
|
reserved word, hence `shared_collection`.) The owning group is resolved
|
||||||
|
from `cx.app_id`'s ancestor chain — that walk is the isolation boundary;
|
||||||
|
an app outside the owning group's subtree gets a "not a shared collection"
|
||||||
|
error, never another subtree's data. A collection is made shared
|
||||||
|
declaratively (`[group]` manifest `collections = [...]`), never from a
|
||||||
|
script. **Reads** are open to any subtree script (including anonymous
|
||||||
|
public endpoints — declaring the collection *is* the grant); **writes**
|
||||||
|
require an authenticated principal with editor+ on the owning group.
|
||||||
|
|
||||||
## Error convention
|
## Error convention
|
||||||
|
|
||||||
- **Throw on failure.** `widgets.set("k", "v")` throws a Rhai runtime
|
- **Throw on failure.** `widgets.set("k", "v")` throws a Rhai runtime
|
||||||
|
|||||||
Reference in New Issue
Block a user