test(cli): shared-files journey + .gitignore + docs (§11.6 files C5)
- collections.rs: shared_files_collection_is_read_write_across_the_subtree
— a group declares kv+docs+files in one string-or-table manifest; authed
app A creates a blob via files::shared_collection("assets").create(...),
app B lists + gets the SAME bytes back across the subtree, a
sibling-subtree app gets CollectionNotShared, collections ls shows all
three kinds. Live-verified the bytes land under
files/groups/<group_id>/assets/<id[0:2]>/<id>.
- .gitignore: ignore /crates/picloud-cli/data (the CLI journey harness
spawns the server from that dir; the files journey is the first CLI test
to write blobs).
- docs: design §11.6 (KV+DOCS+FILES shipped; topics/queue still deferred),
sdk-shape (files::shared_collection), CLAUDE.md current-focus.
Full journey suite 117/117; schema blessed (55 migrations); workspace
clippy -D clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,8 +26,11 @@ docker-compose.override.yml
|
|||||||
config.local.toml
|
config.local.toml
|
||||||
/data
|
/data
|
||||||
# Files-root blob storage created when integration tests run build_app
|
# Files-root blob storage created when integration tests run build_app
|
||||||
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
|
# from a crate dir (PICLOUD_FILES_ROOT default ./data). The CLI journey
|
||||||
|
# harness spawns the server from the picloud-cli dir, so it lands there too
|
||||||
|
# (the §11.6 group-files journey is the first CLI test to write blobs).
|
||||||
/crates/picloud/data
|
/crates/picloud/data
|
||||||
|
/crates/picloud-cli/data
|
||||||
/postgres-data
|
/postgres-data
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
|
|||||||
@@ -394,6 +394,141 @@ fn shared_docs_collection_is_read_write_across_the_subtree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §11.6 files slice: a group declares a `files`-kind shared collection (mixed
|
||||||
|
/// with kv + docs in one string-or-table manifest); an authenticated app A
|
||||||
|
/// `create`s a blob and app B `list`s + `get`s the SAME bytes back across the
|
||||||
|
/// subtree (the group-keyed disk path under `files/groups/<group_id>/...`); a
|
||||||
|
/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows all
|
||||||
|
/// three kinds.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn shared_files_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("fcoll-grp");
|
||||||
|
let sibling = common::unique_slug("fcoll-sib");
|
||||||
|
let app_a = common::unique_slug("fcoll-a");
|
||||||
|
let app_b = common::unique_slug("fcoll-b");
|
||||||
|
let foreign = common::unique_slug("fcoll-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 kv + docs + files collections in one manifest.
|
||||||
|
let dir = manifest_dir();
|
||||||
|
let gmanifest = format!(
|
||||||
|
"[group]\nslug = \"{group}\"\nname = \"FColl\"\n\n\
|
||||||
|
collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}, \
|
||||||
|
{{ name = \"assets\", kind = \"files\" }}]\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` shows all three kinds.
|
||||||
|
let ls = String::from_utf8(
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["collections", "ls", "--group", &group])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
ls.contains("assets") && ls.contains("files"),
|
||||||
|
"ls should list the files collection:\n{ls}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-apply is a no-op (all three markers already exist).
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&gpath)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
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);
|
||||||
|
for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] {
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", app, "--group", grp])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
// App A creates a blob in the shared files collection (authenticated).
|
||||||
|
// base64("hi") = "aGk=".
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/fwriter.rhai"),
|
||||||
|
r#"files::shared_collection("assets").create(#{ name: "a.txt", content_type: "text/plain", data: base64::decode("aGk=") })"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/fwriter.rhai"))
|
||||||
|
.args(["--app", &app_a, "--name", "fwriter"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &app_script_id(&env, &app_a, "fwriter")])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// App B lists the shared collection and reads the SAME bytes back —
|
||||||
|
// cross-app blob sharing via the group-keyed disk path.
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/freader.rhai"),
|
||||||
|
r#"let c = files::shared_collection("assets"); let p = c.list(); base64::encode(c.get(p.files[0].id))"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/freader.rhai"))
|
||||||
|
.args(["--app", &app_b, "--name", "freader"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
assert_eq!(
|
||||||
|
invoke_body(&env, &app_script_id(&env, &app_b, "freader")),
|
||||||
|
serde_json::json!("aGk="),
|
||||||
|
"app B reads the blob app A wrote to the shared files collection"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The foreign app (sibling subtree) → CollectionNotShared.
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/fpeek.rhai"),
|
||||||
|
r#"files::shared_collection("assets").list()"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/fpeek.rhai"))
|
||||||
|
.args(["--app", &foreign, "--name", "fpeek"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &app_script_id(&env, &foreign, "fpeek")])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"a foreign app must not resolve another subtree's shared files collection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
/// 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 {
|
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||||
let ls = common::pic_as(env)
|
let ls = common::pic_as(env)
|
||||||
|
|||||||
@@ -1044,31 +1044,38 @@ 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 + DOCS slices (full shared read/write).** A group declares a collection
|
> **Shipped — §11.6 KV + DOCS + FILES slices (full shared read/write).** A group declares a
|
||||||
> group-shared (`[group]` manifest `collections = [...]`, owner-polymorphic marker table
|
> collection group-shared (`[group]` manifest `collections = [...]`, owner-polymorphic marker table
|
||||||
> `0052_group_collections` with a `kind` discriminator); the data lives in a per-kind store keyed by
|
> `0052_group_collections` with a `kind` discriminator); the data lives in a per-kind store keyed by
|
||||||
> the owning `group_id` (NOT app — a shared row belongs to the group): `0053_group_kv_entries`
|
> the owning `group_id` (NOT app — a shared row belongs to the group): `0053_group_kv_entries`
|
||||||
> (`kind='kv'`) and `0054_group_docs` (`kind='docs'`, the queryable-JSON store). Scripts read/write
|
> (`kind='kv'`), `0054_group_docs` (`kind='docs'`, the queryable-JSON store), and `0055_group_files`
|
||||||
> via the **explicit** `kv::shared_collection("catalog")` / `docs::shared_collection("articles")`
|
> (`kind='files'`, the blob store — metadata in Postgres, bytes on disk under
|
||||||
> handles (distinct `GroupKvHandle`/`GroupDocsHandle`; `shared` alone is a Rhai reserved word, hence
|
> `<root>/files/groups/<group_id>/...`, a `groups/` infix disjoint from the per-app `files/<app_id>/`
|
||||||
> `shared_collection`). The service resolves the owning group from `cx.app_id`'s ancestor chain
|
> subtree so the existing recursive orphan sweeper covers both). Scripts read/write via the
|
||||||
> **filtered by kind**, nearest-group-wins — **that walk is the isolation boundary**: a foreign
|
> **explicit** `kv::shared_collection("catalog")` / `docs::shared_collection("articles")` /
|
||||||
> app's chain never contains the owning group, so the name returns `CollectionNotShared`; a `kv` and
|
> `files::shared_collection("assets")` handles (distinct `GroupKvHandle`/`GroupDocsHandle`/
|
||||||
> a `docs` collection of the same name are distinct stores. **Trust model:** reads are open to any
|
> `GroupFilesHandle`; `shared` alone is a Rhai reserved word, hence `shared_collection`). The service
|
||||||
> subtree script (anonymous public HTTP included — the declaration *is* the grant); **writes require
|
> resolves the owning group from `cx.app_id`'s ancestor chain **filtered by kind**, nearest-group-wins
|
||||||
> an authenticated editor+** on the owning group (`GroupKvWrite`/`GroupDocsWrite`, fail closed on an
|
> — **that walk is the isolation boundary**: a foreign app's chain never contains the owning group, so
|
||||||
> anonymous principal). The docs slice reuses the `docs_filter` DSL — `build_find_query` was
|
> the name returns `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name
|
||||||
> generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`), both compile-time
|
> are distinct stores. **Trust model:** reads are open to any subtree script (anonymous public HTTP
|
||||||
> literals, so the find SQL has one source. Declarative authoring uses the **string-or-table** form:
|
> included — the declaration *is* the grant); **writes require an authenticated editor+** on the
|
||||||
> `collections = ["catalog", { name = "articles", kind = "docs" }]` (bare string = `kv`). CASCADE on
|
> owning group (`GroupKvWrite`/`GroupDocsWrite`/`GroupFilesWrite`, fail closed on an anonymous
|
||||||
> group delete; an app delete leaves the shared data. Live- + journey-validated for both kinds (app
|
> principal). The docs slice reuses the `docs_filter` DSL — `build_find_query` was generalized on its
|
||||||
> A writes, app B reads/finds; a sibling-subtree app gets `CollectionNotShared`).
|
> owner column (`docs`/`app_id` vs `group_docs`/`group_id`), both compile-time literals, so the find
|
||||||
|
> SQL has one source; the files slice likewise generalized the atomic-write + checksum-on-read path
|
||||||
|
> helpers on an owner-relative dir, so the security-sensitive disk mechanics have one source.
|
||||||
|
> Declarative authoring uses the **string-or-table** form: `collections = ["catalog", { name =
|
||||||
|
> "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = `kv`). CASCADE on
|
||||||
|
> group delete; an app delete leaves the shared data. Live- + journey-validated for all three kinds
|
||||||
|
> (app A writes, app B reads/finds/gets the same bytes; a sibling-subtree app gets
|
||||||
|
> `CollectionNotShared`).
|
||||||
>
|
>
|
||||||
> **Deferred (documented gaps):** write-triggers/events on shared collections (the "group trigger
|
> **Deferred (documented gaps):** write-triggers/events on shared collections (the "group trigger
|
||||||
> has no app to watch" problem); **files/topics/queue** shared collections (the `kind` column
|
> has no app to watch" problem); **topics/queue** shared collections (trigger-centric, same gap);
|
||||||
> generalizes — files needs group-keyed disk paths); per-group total-size quotas + write-rate
|
> per-group total-size quotas + write-rate limits; CAS/`set_if`; an operator admin API for shared
|
||||||
> limits; CAS/`set_if`; app-declared collections. Multi-node tree-apply leans on the runtime
|
> blobs (scripts use the SDK; `pic collections ls` shows the marker — matches KV/docs); app-declared
|
||||||
> backstop for no-op edges, as elsewhere.
|
> 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)
|
||||||
|
|
||||||
|
|||||||
@@ -99,14 +99,18 @@ script. **Reads** are open to any subtree script (including anonymous
|
|||||||
public endpoints — declaring the collection *is* the grant); **writes**
|
public endpoints — declaring the collection *is* the grant); **writes**
|
||||||
require an authenticated principal with editor+ on the owning group.
|
require an authenticated principal with editor+ on the owning group.
|
||||||
|
|
||||||
**Docs too.** The same handle exists for the queryable-JSON store:
|
**Docs and files too.** The same handle exists for the queryable-JSON store
|
||||||
`docs::shared_collection("articles")` returns a shared-docs handle with the
|
and the blob store: `docs::shared_collection("articles")` returns a shared-docs
|
||||||
full `create`/`get`/`find`/`find_one`/`update`/`delete`/`list` surface (same
|
handle with the full `create`/`get`/`find`/`find_one`/`update`/`delete`/`list`
|
||||||
trust model + isolation boundary as the KV one). A `[group]` declares a
|
surface, and `files::shared_collection("assets")` returns a shared-files handle
|
||||||
|
with `create`/`head`/`get`/`update`/`delete`/`list` (Blob in/out) — both with
|
||||||
|
the same trust model + isolation boundary as the KV one. A `[group]` declares a
|
||||||
collection's store with a kind: `collections = ["catalog", { name =
|
collection's store with a kind: `collections = ["catalog", { name =
|
||||||
"articles", kind = "docs" }]` — a bare string is `kv`, a `{ name, kind }`
|
"articles", kind = "docs" }, { name = "assets", kind = "files" }]` — a bare
|
||||||
table sets it. A `kv` and a `docs` collection of the same name are distinct
|
string is `kv`, a `{ name, kind }` table sets it (`kv`/`docs`/`files`). A `kv`,
|
||||||
stores. (Files/topics/queue shared collections are not yet implemented.)
|
a `docs`, and a `files` collection of the same name are distinct stores; group
|
||||||
|
blobs live on disk under `<root>/files/groups/<group_id>/...`. (Topics/queue
|
||||||
|
shared collections are not yet implemented.)
|
||||||
|
|
||||||
## Error convention
|
## Error convention
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user