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>
282 lines
10 KiB
Rust
282 lines
10 KiB
Rust
//! `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()
|
|
})
|
|
}
|