feat(v1.1.5): files SDK + files:* triggers

Filesystem-backed blob storage as the fifth concrete trigger kind.

- `files::collection(c).{create,head,get,update,delete,list}` Rhai SDK
  (blob in/out; metadata maps; missing-field throws naming the field).
- `FilesService` trait in picloud-shared; `FsFilesRepo` (atomic
  write: temp→fsync→rename→fsync-dir→DB; single-pass SHA-256;
  checksum-verified reads → Corrupted) + `FilesServiceImpl` in
  manager-core. Metadata in Postgres (0018), bytes on disk under
  PICLOUD_FILES_ROOT with 0o700 shard dirs.
- `files:*` trigger kind via the Layout-E pattern (0019: widen both
  CHECKs + files_trigger_details), TriggerEvent::Files (metadata only,
  no bytes), emit_files fan-out, dispatcher arm, admin endpoint
  POST /triggers/files (reuses validate_trigger_target).
- AppFilesRead/AppFilesWrite capabilities → script:read/script:write
  (seven-scope commitment held). AppPubsubPublish reserved for v1.1.6.
- Admin files API (list + delete) + dashboard Files view per app.

Cross-app isolation keyed on cx.app_id at every layer. ~45 new tests
(service in-memory, fs tempdir, bridge integration). No DB required
for the suite. publish_ephemeral and the orphan sweep stay deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-03 21:18:17 +02:00
parent 03d03ea6e7
commit 6e132b6ee0
29 changed files with 3599 additions and 31 deletions

View File

@@ -99,6 +99,7 @@ async fn original_backend_error_is_logged_at_error_level() {
Arc::new(NoopEventEmitter),
Arc::new(FailingSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
);
let engine = Engine::new(Limits::default(), services);

View File

@@ -97,6 +97,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
Arc::new(NoopEventEmitter),
modules,
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
)
}

View File

@@ -228,6 +228,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -0,0 +1,333 @@
//! `files::` SDK bridge integration tests — runs a real Rhai engine
//! against an in-memory `FilesService` impl. Mirrors `tests/sdk_kv.rs`:
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
//! reachable runtime. Exercises the actual Rhai surface — blob in/out,
//! the metadata map shape, and the missing-required-field throw.
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
AppId, ExecutionId, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService, NewFile,
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
};
use serde_json::{json, Value};
use tokio::sync::Mutex;
use uuid::Uuid;
#[derive(Default)]
struct InMemoryFiles {
#[allow(clippy::type_complexity)]
data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
}
/// The in-memory fake doesn't exercise the real checksum path (the
/// `FsFilesRepo` tempdir tests in manager-core cover SHA-256); a stable
/// placeholder keeps the metadata map non-empty.
fn fake_checksum(bytes: &[u8]) -> String {
format!("len-{}", bytes.len())
}
#[async_trait]
impl FilesService for InMemoryFiles {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
new: NewFile,
) -> Result<Uuid, FilesError> {
if collection.is_empty() {
return Err(FilesError::InvalidCollection("empty".into()));
}
new.validate(100 * 1024 * 1024)?;
let id = Uuid::new_v4();
let now = chrono::Utc::now();
let meta = FileMeta {
id,
collection: collection.to_string(),
name: new.name.clone(),
content_type: new.content_type.clone(),
size: new.data.len() as u64,
checksum: fake_checksum(&new.data),
created_at: now,
updated_at: now,
};
self.data
.lock()
.await
.insert((cx.app_id, collection.to_string(), id), (meta, new.data));
Ok(id)
}
async fn head(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<FileMeta>, FilesError> {
let Ok(uuid) = Uuid::parse_str(id) else {
return Ok(None);
};
Ok(self
.data
.lock()
.await
.get(&(cx.app_id, collection.to_string(), uuid))
.map(|(m, _)| m.clone()))
}
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<Vec<u8>>, FilesError> {
let Ok(uuid) = Uuid::parse_str(id) else {
return Ok(None);
};
Ok(self
.data
.lock()
.await
.get(&(cx.app_id, collection.to_string(), uuid))
.map(|(_, b)| b.clone()))
}
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
upd: FileUpdate,
) -> Result<(), FilesError> {
upd.validate(100 * 1024 * 1024)?;
let Ok(uuid) = Uuid::parse_str(id) else {
return Err(FilesError::NotFound);
};
let mut data = self.data.lock().await;
let key = (cx.app_id, collection.to_string(), uuid);
let Some((meta, _)) = data.get(&key).cloned() else {
return Err(FilesError::NotFound);
};
let mut meta = meta;
if let Some(n) = upd.name {
meta.name = n;
}
if let Some(ct) = upd.content_type {
meta.content_type = ct;
}
meta.size = upd.data.len() as u64;
meta.checksum = fake_checksum(&upd.data);
data.insert(key, (meta, upd.data));
Ok(())
}
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
let Ok(uuid) = Uuid::parse_str(id) else {
return Ok(false);
};
Ok(self
.data
.lock()
.await
.remove(&(cx.app_id, collection.to_string(), uuid))
.is_some())
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<FilesListPage, FilesError> {
let data = self.data.lock().await;
let files: Vec<FileMeta> = data
.iter()
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
.map(|(_, (m, _))| m.clone())
.collect();
Ok(FilesListPage {
files,
next_cursor: None,
})
}
}
fn make_engine() -> Arc<Engine> {
let services = Services::new(
Arc::new(NoopKvService),
Arc::new(NoopDocsService),
Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(InMemoryFiles::default()),
);
Arc::new(Engine::new(Limits::default(), services))
}
fn baseline_request(app_id: AppId) -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest {
execution_id,
request_id: RequestId::new(),
script_id: ScriptId::new(),
script_name: "files-test".into(),
invocation_type: InvocationType::Http,
path: "/files-test".into(),
headers: BTreeMap::new(),
body: Value::Null,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
is_dead_letter_handler: false,
event: None,
}
}
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
let src = src.to_string();
tokio::task::spawn_blocking(move || engine.execute(&src, req))
.await
.expect("spawn_blocking should not panic")
.expect("script execution should succeed")
.body
}
async fn run_script_err(engine: Arc<Engine>, src: &str, req: ExecRequest) -> String {
let src = src.to_string();
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
.await
.expect("spawn_blocking should not panic");
format!("{:?}", res.expect_err("script should error"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_create_get_round_trip_via_blob() {
let engine = make_engine();
let app = AppId::new();
// base64("hello") = "aGVsbG8="; decode → blob; create; get back; encode.
let src = r#"
let c = files::collection("avatars");
let data = base64::decode("aGVsbG8=");
let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data });
let back = c.get(id);
base64::encode(back)
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, json!("aGVsbG8="));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_head_returns_metadata_map() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = files::collection("avatars");
let data = base64::decode("aGVsbG8=");
let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data });
let meta = c.head(id);
#{ name: meta.name, content_type: meta.content_type, size: meta.size, has_checksum: meta.checksum != () }
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(
body,
json!({ "name": "a.txt", "content_type": "text/plain", "size": 5, "has_checksum": true })
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_get_and_head_missing_return_unit() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = files::collection("avatars");
let g = c.get("00000000-0000-0000-0000-000000000000");
let h = c.head("00000000-0000-0000-0000-000000000000");
#{ g: g == (), h: h == () }
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, json!({ "g": true, "h": true }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_update_then_delete() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = files::collection("avatars");
let id = c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") });
c.update(id, #{ data: base64::decode("YmM=") }); // "bc"
let after = base64::encode(c.get(id));
let removed = c.delete(id);
let gone = c.delete(id);
#{ after: after, removed: removed, gone: gone }
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(
body,
json!({ "after": "YmM=", "removed": true, "gone": false })
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_create_missing_data_throws_naming_field() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = files::collection("avatars");
c.create(#{ name: "a", content_type: "text/plain" })
"#;
let err = run_script_err(engine, src, baseline_request(app)).await;
assert!(
err.contains("data"),
"error should name the missing field: {err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_create_missing_name_throws_naming_field() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = files::collection("avatars");
c.create(#{ content_type: "text/plain", data: base64::decode("YQ==") })
"#;
let err = run_script_err(engine, src, baseline_request(app)).await;
assert!(
err.contains("name"),
"error should name the missing field: {err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_empty_collection_name_throws() {
let engine = make_engine();
let app = AppId::new();
let err = run_script_err(engine, r#"files::collection("")"#, baseline_request(app)).await;
assert!(err.to_lowercase().contains("empty"), "got {err}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_list_returns_files_array() {
let engine = make_engine();
let app = AppId::new();
let src = r#"
let c = files::collection("avatars");
c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") });
c.create(#{ name: "b", content_type: "text/plain", data: base64::decode("Yg==") });
let page = c.list();
page.files.len()
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(body, json!(2));
}

View File

@@ -88,6 +88,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
http,
Arc::new(picloud_shared::NoopFilesService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -107,6 +107,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
);
Arc::new(Engine::new(Limits::default(), services))
}