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

@@ -348,6 +348,7 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
/// `docs/v1.1.x-design-notes.md` §4 (the dead-letter sub-shape) and
/// §2/blueprint §9 (KV). Each variant becomes a Rhai map with a
/// `source` discriminant plus per-source fields.
#[allow(clippy::too_many_lines)]
fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
let mut m = Map::new();
m.insert("source".into(), event.source().into());
@@ -406,6 +407,33 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
cron_map.insert("fired_at".into(), fired_at.to_rfc3339().into());
m.insert("cron".into(), cron_map.into());
}
TriggerEvent::Files {
op,
collection,
id,
name,
content_type,
size,
checksum,
prev,
} => {
m.insert("op".into(), op.as_str().into());
let mut files_map = Map::new();
files_map.insert("collection".into(), collection.clone().into());
files_map.insert("id".into(), id.clone().into());
files_map.insert("name".into(), name.clone().into());
files_map.insert("content_type".into(), content_type.clone().into());
files_map.insert(
"size".into(),
i64::try_from(*size).unwrap_or(i64::MAX).into(),
);
files_map.insert("checksum".into(), checksum.clone().into());
files_map.insert(
"prev".into(),
prev.clone().map_or(Dynamic::UNIT, json_to_dynamic),
);
m.insert("files".into(), files_map.into());
}
TriggerEvent::DeadLetter {
dead_letter_id,
original,

View File

@@ -0,0 +1,281 @@
//! `files::` Rhai bridge — collection-scoped handle pattern (v1.1.5).
//!
//! ```rhai
//! let avatars = files::collection("avatars");
//! let id = avatars.create(#{ name: "a.jpg", content_type: "image/jpeg", data: blob });
//! let meta = avatars.head(id); // metadata map or ()
//! let bytes = avatars.get(id); // Blob or ()
//! avatars.update(id, #{ data: new_bytes });
//! let gone = avatars.delete(id); // bool (was-present)
//! let page = avatars.list(); // #{ files: [...], next_cursor: () }
//! ```
//!
//! The `FilesHandle` custom Rhai type captures the collection name once
//! and routes each call through the injected `Arc<dyn FilesService>`
//! with the per-call `Arc<SdkCallCx>`. **The service derives `app_id`
//! from `cx.app_id` — it never appears in any signature script-side,
//! preserving cross-app isolation.**
//!
//! Error convention (per `docs/sdk-shape.md`): `create`/`update`/
//! `delete` throw on failure; `get`/`head` return `()` for a missing
//! file; `delete` returns `bool` (was-present). The blob bytes are a
//! Rhai `Blob` (byte array) in both directions.
use std::sync::Arc;
use picloud_shared::{
FileMeta, FileUpdate, FilesError, FilesService, NewFile, SdkCallCx, Services,
};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use tokio::runtime::Handle as TokioHandle;
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
/// plus an owned string).
#[derive(Clone)]
pub struct FilesHandle {
collection: String,
service: Arc<dyn FilesService>,
cx: Arc<SdkCallCx>,
}
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let files_service = services.files.clone();
let mut module = Module::new();
{
let files_service = files_service.clone();
let cx = cx.clone();
module.set_native_fn(
"collection",
move |name: &str| -> Result<FilesHandle, Box<EvalAltResult>> {
if name.is_empty() {
return Err("files::collection name must not be empty".into());
}
Ok(FilesHandle {
collection: name.to_string(),
service: files_service.clone(),
cx: cx.clone(),
})
},
);
}
engine.register_static_module("files", module.into());
engine.register_type_with_name::<FilesHandle>("FilesHandle");
register_create(engine);
register_head(engine);
register_get(engine);
register_update(engine);
register_delete(engine);
register_list(engine);
}
fn register_create(engine: &mut RhaiEngine) {
engine.register_fn(
"create",
|handle: &mut FilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
let name = require_string(&meta, "name")?;
let content_type = require_string(&meta, "content_type")?;
let data = require_blob(&meta, "data")?;
let h = handle.clone();
let new = NewFile {
name,
content_type,
data,
};
let id = block_on(async move { h.service.create(&h.cx, &h.collection, new).await })?;
Ok(id.to_string())
},
);
}
fn register_head(engine: &mut RhaiEngine) {
engine.register_fn(
"head",
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let h = handle.clone();
let id = id.to_string();
let meta = block_on(async move { h.service.head(&h.cx, &h.collection, &id).await })?;
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
},
);
}
fn register_get(engine: &mut RhaiEngine) {
engine.register_fn(
"get",
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let h = handle.clone();
let id = id.to_string();
let bytes = block_on(async move { h.service.get(&h.cx, &h.collection, &id).await })?;
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
},
);
}
fn register_update(engine: &mut RhaiEngine) {
engine.register_fn(
"update",
|handle: &mut FilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
let data = require_blob(&meta, "data")?;
let name = optional_string(&meta, "name")?;
let content_type = optional_string(&meta, "content_type")?;
let h = handle.clone();
let id = id.to_string();
let upd = FileUpdate {
data,
name,
content_type,
};
block_on(async move { h.service.update(&h.cx, &h.collection, &id, upd).await })
},
);
}
fn register_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|handle: &mut FilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let id = id.to_string();
block_on(async move { h.service.delete(&h.cx, &h.collection, &id).await })
},
);
}
fn register_list(engine: &mut RhaiEngine) {
engine.register_fn(
"list",
|handle: &mut FilesHandle| -> Result<Map, Box<EvalAltResult>> {
list_call(handle, None, 0)
},
);
engine.register_fn(
"list",
|handle: &mut FilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
list_call(handle, Some(cursor.to_string()), 0)
},
);
engine.register_fn(
"list",
|handle: &mut FilesHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
list_call(handle, Some(cursor.to_string()), limit)
},
);
// `list(#{ cursor, limit })` — the map form documented in the brief.
engine.register_fn(
"list",
|handle: &mut FilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
let cursor = match opts.get("cursor") {
Some(v) if !v.is_unit() => {
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
"files: list cursor must be a string".into()
})?)
}
_ => None,
};
let limit = match opts.get("limit") {
Some(v) if !v.is_unit() => {
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
}
_ => 0,
};
list_call(handle, cursor, limit)
},
);
}
fn list_call(
handle: &FilesHandle,
cursor: Option<String>,
limit: u32,
) -> Result<Map, Box<EvalAltResult>> {
let h = handle.clone();
let page = block_on(async move {
h.service
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
.await
})?;
let mut m = Map::new();
let files: Array = page
.files
.iter()
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
.collect();
m.insert("files".into(), files.into());
m.insert(
"next_cursor".into(),
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
);
Ok(m)
}
/// Render a `FileMeta` into the Rhai map shape scripts see from
/// `head` / `list`.
fn file_meta_to_map(meta: &FileMeta) -> Map {
let mut m = Map::new();
m.insert("id".into(), meta.id.to_string().into());
m.insert("collection".into(), meta.collection.clone().into());
m.insert("name".into(), meta.name.clone().into());
m.insert("content_type".into(), meta.content_type.clone().into());
m.insert(
"size".into(),
i64::try_from(meta.size).unwrap_or(i64::MAX).into(),
);
m.insert("checksum".into(), meta.checksum.clone().into());
m.insert("created_at".into(), meta.created_at.to_rfc3339().into());
m.insert("updated_at".into(), meta.updated_at.to_rfc3339().into());
m
}
/// Pull a required string field out of a Rhai map; throw naming the
/// field if it's absent or not a string.
fn require_string(meta: &Map, field: &'static str) -> Result<String, Box<EvalAltResult>> {
match meta.get(field) {
Some(v) if v.is_string() => Ok(v.clone().into_string().unwrap_or_default()),
Some(_) => Err(format!("files::create: field '{field}' must be a string").into()),
None => Err(format!("files::create: missing required field '{field}'").into()),
}
}
/// Pull an optional string field; `None` when the key is absent or unit.
fn optional_string(meta: &Map, field: &'static str) -> Result<Option<String>, Box<EvalAltResult>> {
match meta.get(field) {
None => Ok(None),
Some(v) if v.is_unit() => Ok(None),
Some(v) if v.is_string() => Ok(Some(v.clone().into_string().unwrap_or_default())),
Some(_) => Err(format!("files::update: field '{field}' must be a string").into()),
}
}
/// Pull a required blob (`data`) out of a Rhai map; throw naming the
/// field if it's absent or not a blob.
fn require_blob(meta: &Map, field: &'static str) -> Result<Vec<u8>, Box<EvalAltResult>> {
match meta.get(field) {
Some(v) if v.is_blob() => Ok(v.clone().into_blob().unwrap_or_default()),
Some(_) => Err(format!("files: field '{field}' must be a Blob (byte array)").into()),
None => Err(format!("files: missing required field '{field}'").into()),
}
}
/// Run an async future inside the synchronous Rhai context. Mirrors
/// `kv::block_on`; safe because `LocalExecutorClient` runs the script
/// under `spawn_blocking`, so a runtime handle is reachable.
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
where
F: std::future::Future<Output = Result<T, FilesError>> + Send,
T: Send,
{
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("files: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("files: {err}").into(), rhai::Position::NONE).into()
})
}

View File

@@ -15,6 +15,7 @@ pub mod bridge;
pub mod cx;
pub mod dead_letters;
pub mod docs;
pub mod files;
pub mod http;
pub mod kv;
pub mod stdlib;
@@ -37,5 +38,6 @@ pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCal
kv::register(engine, services, cx.clone());
docs::register(engine, services, cx.clone());
dead_letters::register(engine, services, cx.clone());
http::register(engine, services, cx);
http::register(engine, services, cx.clone());
files::register(engine, services, cx);
}

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))
}