feat(v1.1.3-modules): shared types, migrations, engine + resolver scaffold
Lays down the v1.1.3 plumbing:
- `ScriptKind` enum in `picloud-shared` ('endpoint' | 'module').
- `ModuleSource` trait + `ModuleScript` DTO + `NoopModuleSource` in
`picloud-shared`. Resolver lives in `executor-core`; Postgres impl
in `manager-core` (`PostgresModuleSource`).
- `Services::new` grows a fifth `modules: Arc<dyn ModuleSource>` arg.
- `ScriptValidator` returns `ValidatedScript { imports }` so the
manager can populate the dep-graph table on save. New
`validate_module` method on the trait gates module-shape rules.
- `Engine::execute_ast(&Arc<rhai::AST>, req)` lets the orchestrator's
script cache reuse compiled ASTs. `Engine::execute(&str, req)` is
preserved as a convenience that compiles inline. `Engine::compile`
exposes the AST for callers that want to cache.
- `PicloudModuleResolver` replaces `DummyModuleResolver` per-call.
Bridges Rhai's sync `ModuleResolver::resolve` to async
`ModuleSource::lookup` via `Handle::block_on`. Enforces:
- cross-app isolation (resolver captures `Arc<SdkCallCx>`),
- circular import detection (in-progress stack on the resolver),
- import depth limit (default 8 via
`Limits::module_import_depth_max`).
- Module-shape validation walks `ast.statements()` via `rhai/internals`
and accepts only `Var { CONSTANT }`, `Import`, and `Noop`. The
manager admin endpoint runs `validate_module` at save (primary
gate); resolver re-runs it at load (defense in depth).
- LRU cache `(AppId, name) -> (updated_at, Arc<Module>)` owned by
`Engine`. Size from `PICLOUD_MODULE_CACHE_SIZE` (default 512).
- Migration `0015_scripts_kind.sql` adds `scripts.kind` + composite
index + module-name shape CHECK.
- Migration `0016_script_imports.sql` adds the dep-graph table with
FK CASCADE on both columns.
- Repo: `kind` threaded through SELECT/INSERT/UPDATE. New
`count_routes_for_script` / `count_triggers_for_script` /
`list_imports` methods. `create`/`update` open a transaction and
call `replace_imports_tx` to populate the dep-graph.
- Admin endpoint: accepts `kind`; rejects reserved module names;
rejects `endpoint → module` transitions when routes / triggers
exist.
- SDK_VERSION 1.3 → 1.4.
Workspace builds; full test suite (~440 tests) green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -1274,6 +1274,15 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1570,6 +1579,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
|
"lru",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ regex = "1"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
|
|
||||||
|
# LRU caches (v1.1.3 — top-level script AST cache in orchestrator-core +
|
||||||
|
# per-module compiled-module cache in executor-core).
|
||||||
|
lru = "0.12"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,16 @@ tokio.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
rhai.workspace = true
|
async-trait.workspace = true
|
||||||
|
# `internals` feature surfaces `rhai::Stmt`, `rhai::Expr`, `ASTFlags`
|
||||||
|
# (used by the v1.1.3 module-shape validator to walk top-level
|
||||||
|
# statements and accept only `fn` / `const` / `import`). Pinned at
|
||||||
|
# the workspace level; bumping rhai is a deliberate, reviewed change.
|
||||||
|
rhai = { workspace = true, features = ["internals"] }
|
||||||
|
|
||||||
|
# v1.1.3 — per-module compiled-Module cache lives in this crate so the
|
||||||
|
# resolver can reuse compiled modules across invocations.
|
||||||
|
lru.workspace = true
|
||||||
|
|
||||||
# Stdlib utility modules — see crates/executor-core/src/sdk/stdlib/.
|
# Stdlib utility modules — see crates/executor-core/src/sdk/stdlib/.
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidationError, SDK_VERSION,
|
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
|
||||||
|
SDK_VERSION,
|
||||||
};
|
};
|
||||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope, AST};
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
|
|
||||||
|
use crate::module_resolver::{
|
||||||
|
extract_imports, new_module_cache, validate_module_source, ModuleCache, PicloudModuleResolver,
|
||||||
|
};
|
||||||
use crate::sandbox::Limits;
|
use crate::sandbox::Limits;
|
||||||
use crate::sdk;
|
use crate::sdk;
|
||||||
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
@@ -16,6 +20,11 @@ use crate::types::{
|
|||||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Default capacity for the module cache. Sized assuming a small fleet
|
||||||
|
/// of distinct modules per process; can be overridden via
|
||||||
|
/// `PICLOUD_MODULE_CACHE_SIZE`.
|
||||||
|
const DEFAULT_MODULE_CACHE_SIZE: usize = 512;
|
||||||
|
|
||||||
/// Preconfigured Rhai engine with sandbox limits applied and the SDK
|
/// Preconfigured Rhai engine with sandbox limits applied and the SDK
|
||||||
/// `Services` bundle attached.
|
/// `Services` bundle attached.
|
||||||
///
|
///
|
||||||
@@ -31,12 +40,34 @@ use crate::types::{
|
|||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
limits: Limits,
|
limits: Limits,
|
||||||
services: Services,
|
services: Services,
|
||||||
|
/// v1.1.3: shared compiled-module cache. Per-key
|
||||||
|
/// `(app_id, name)`; invalidated lazily by `updated_at` mismatch
|
||||||
|
/// at resolver time.
|
||||||
|
module_cache: Arc<ModuleCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(limits: Limits, services: Services) -> Self {
|
pub fn new(limits: Limits, services: Services) -> Self {
|
||||||
Self { limits, services }
|
let cap = std::env::var("PICLOUD_MODULE_CACHE_SIZE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
|
.unwrap_or(DEFAULT_MODULE_CACHE_SIZE);
|
||||||
|
Self::with_module_cache_capacity(limits, services, cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit capacity for tests that exercise LRU eviction.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_module_cache_capacity(
|
||||||
|
limits: Limits,
|
||||||
|
services: Services,
|
||||||
|
module_cache_capacity: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
limits,
|
||||||
|
services,
|
||||||
|
module_cache: new_module_cache(module_cache_capacity),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -44,16 +75,42 @@ impl Engine {
|
|||||||
&self.limits
|
&self.limits
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse-only validation. Surfaced at script-upload time so syntax
|
/// Shared compiled-module cache. Exposed so tests can introspect
|
||||||
/// errors are caught before the first invocation. Same logic as the
|
/// the cache state (length, contents) under a Mutex lock.
|
||||||
/// `ScriptValidator` impl below but with the richer `ExecError`
|
#[must_use]
|
||||||
/// variant; callers in the executor path use this, the manager
|
pub fn module_cache(&self) -> &Arc<ModuleCache> {
|
||||||
/// path goes through the trait.
|
&self.module_cache
|
||||||
pub fn validate(&self, source: &str) -> Result<(), ExecError> {
|
}
|
||||||
|
|
||||||
|
/// Parse-only validation for endpoint scripts. Surfaced at script-
|
||||||
|
/// upload time so syntax errors are caught before the first
|
||||||
|
/// invocation. Returns the script's literal-path `import "<name>"`
|
||||||
|
/// declarations so the repo can populate the dep-graph table.
|
||||||
|
pub fn validate(&self, source: &str) -> Result<ValidatedScript, ExecError> {
|
||||||
|
// Validation uses a fresh `RhaiEngine` without service hooks
|
||||||
|
// attached — modules are only resolved at execute() time, so
|
||||||
|
// the resolver during validate is intentionally Dummy (no DB
|
||||||
|
// access here; we just need the parser).
|
||||||
|
let engine = build_engine(self.limits, None);
|
||||||
|
extract_imports(&engine, source).map_err(ExecError::Parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Module-shape validation (v1.1.3). Compiles, rejects any top-
|
||||||
|
/// level statement that isn't `fn`/`const`/`import`, and returns
|
||||||
|
/// the declared imports.
|
||||||
|
pub fn validate_module(&self, source: &str) -> Result<ValidatedScript, ExecError> {
|
||||||
|
let engine = build_engine(self.limits, None);
|
||||||
|
validate_module_source(&engine, source).map_err(ExecError::Parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile `source` to a reusable AST. Lets callers (the
|
||||||
|
/// orchestrator's script cache) compile once and execute many
|
||||||
|
/// times against the same AST.
|
||||||
|
pub fn compile(&self, source: &str) -> Result<Arc<AST>, ExecError> {
|
||||||
let engine = build_engine(self.limits, None);
|
let engine = build_engine(self.limits, None);
|
||||||
engine
|
engine
|
||||||
.compile(source)
|
.compile(source)
|
||||||
.map(|_| ())
|
.map(Arc::new)
|
||||||
.map_err(|e| ExecError::Parse(e.to_string()))
|
.map_err(|e| ExecError::Parse(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +120,25 @@ impl Engine {
|
|||||||
/// request replace the engine's defaults field-by-field; the
|
/// request replace the engine's defaults field-by-field; the
|
||||||
/// manager already clamped them against the admin ceiling.
|
/// manager already clamped them against the admin ceiling.
|
||||||
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||||
|
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||||
|
// Compile inline so the source-only path stays available for
|
||||||
|
// tests and one-off callers that don't pre-cache an AST.
|
||||||
|
let engine_for_compile = build_engine(effective_limits, None);
|
||||||
|
let ast = engine_for_compile
|
||||||
|
.compile(source)
|
||||||
|
.map(Arc::new)
|
||||||
|
.map_err(|e| ExecError::Parse(e.to_string()))?;
|
||||||
|
self.execute_ast(&ast, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v1.1.3: execute a pre-compiled AST. The orchestrator's script
|
||||||
|
/// cache hands compiled ASTs in directly; this path skips the
|
||||||
|
/// per-call compile.
|
||||||
|
pub fn execute_ast(
|
||||||
|
&self,
|
||||||
|
ast: &Arc<AST>,
|
||||||
|
req: ExecRequest,
|
||||||
|
) -> Result<ExecResponse, ExecError> {
|
||||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
||||||
@@ -80,18 +156,25 @@ impl Engine {
|
|||||||
is_dead_letter_handler: req.is_dead_letter_handler,
|
is_dead_letter_handler: req.is_dead_letter_handler,
|
||||||
event: req.event.clone(),
|
event: req.event.clone(),
|
||||||
});
|
});
|
||||||
|
// v1.1.3: replace the no-op `DummyModuleResolver` build_engine
|
||||||
|
// installed with the real per-call resolver. The resolver owns
|
||||||
|
// `cx.clone()` so cross-app isolation derives from this exact
|
||||||
|
// call's context, not from any script-passed argument.
|
||||||
|
let resolver = PicloudModuleResolver::new(
|
||||||
|
self.services.modules.clone(),
|
||||||
|
cx.clone(),
|
||||||
|
self.module_cache.clone(),
|
||||||
|
effective_limits.module_import_depth_max,
|
||||||
|
);
|
||||||
|
engine.set_module_resolver(resolver);
|
||||||
sdk::register_all(&mut engine, &self.services, cx);
|
sdk::register_all(&mut engine, &self.services, cx);
|
||||||
|
|
||||||
let ast = engine
|
|
||||||
.compile(source)
|
|
||||||
.map_err(|e| ExecError::Parse(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut scope = Scope::new();
|
let mut scope = Scope::new();
|
||||||
scope.push_constant("ctx", build_ctx_map(&req));
|
scope.push_constant("ctx", build_ctx_map(&req));
|
||||||
|
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let value: Dynamic = engine
|
let value: Dynamic = engine
|
||||||
.eval_ast_with_scope(&mut scope, &ast)
|
.eval_ast_with_scope(&mut scope, ast.as_ref())
|
||||||
.map_err(map_eval_error)?;
|
.map_err(map_eval_error)?;
|
||||||
let duration = started.elapsed();
|
let duration = started.elapsed();
|
||||||
|
|
||||||
@@ -116,8 +199,18 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ScriptValidator for Engine {
|
impl ScriptValidator for Engine {
|
||||||
fn validate(&self, source: &str) -> Result<(), ValidationError> {
|
fn validate(&self, source: &str) -> Result<ValidatedScript, ValidationError> {
|
||||||
Engine::validate(self, source).map_err(|e| ValidationError::Syntax(e.to_string()))
|
Engine::validate(self, source).map_err(|e| match e {
|
||||||
|
ExecError::Parse(msg) => ValidationError::Syntax(msg),
|
||||||
|
other => ValidationError::Syntax(other.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_module(&self, source: &str) -> Result<ValidatedScript, ValidationError> {
|
||||||
|
Engine::validate_module(self, source).map_err(|e| match e {
|
||||||
|
ExecError::Parse(msg) => ValidationError::ModuleShape(msg),
|
||||||
|
other => ValidationError::ModuleShape(other.to_string()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,16 @@
|
|||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod module_resolver;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod sdk;
|
pub mod sdk;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use engine::Engine;
|
pub use engine::Engine;
|
||||||
|
pub use module_resolver::{
|
||||||
|
extract_imports, new_module_cache, validate_module_source, CachedModule, ModuleCache,
|
||||||
|
ModuleCacheKey, PicloudModuleResolver,
|
||||||
|
};
|
||||||
pub use sandbox::Limits;
|
pub use sandbox::Limits;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||||
|
|||||||
430
crates/executor-core/src/module_resolver.rs
Normal file
430
crates/executor-core/src/module_resolver.rs
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
|
||||||
|
//!
|
||||||
|
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
||||||
|
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
|
||||||
|
//! `import "<name>"` request resolves against the calling app
|
||||||
|
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
|
||||||
|
//! — that's the load-bearing cross-app isolation property.
|
||||||
|
//!
|
||||||
|
//! Three runtime invariants are enforced:
|
||||||
|
//!
|
||||||
|
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
|
||||||
|
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
|
||||||
|
//! script-passed argument).
|
||||||
|
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
||||||
|
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
||||||
|
//! 3. **Depth limit** — guards against deep but acyclic chains
|
||||||
|
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
||||||
|
//!
|
||||||
|
//! Compiled modules are cached per `(app_id, name)` and invalidated by
|
||||||
|
//! `updated_at` change — no explicit pub/sub. The cache is owned by
|
||||||
|
//! `Engine` and shared across calls; only the resolver state (stack,
|
||||||
|
//! depth) is per-call.
|
||||||
|
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use lru::LruCache;
|
||||||
|
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
|
||||||
|
use rhai::module_resolvers::ModuleResolver;
|
||||||
|
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||||
|
|
||||||
|
/// Local alias for `rhai::Shared<rhai::Module>` (rhai's `SharedRhaiModule`
|
||||||
|
/// type alias is `pub(crate)`). Resolves to `Arc<Module>` under the
|
||||||
|
/// `sync` feature that the workspace pins.
|
||||||
|
type SharedRhaiModule = Shared<Module>;
|
||||||
|
|
||||||
|
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
|
||||||
|
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
|
||||||
|
/// CHECK) so the `String` here is bounded by ~64 bytes.
|
||||||
|
pub type ModuleCacheKey = (AppId, String);
|
||||||
|
|
||||||
|
/// Cache value: the freshness comparator + the compiled module Rhai
|
||||||
|
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CachedModule {
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub module: Shared<Module>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded LRU cache shared across all `Engine::execute` calls. Construct
|
||||||
|
/// once at process startup; the resolver holds an Arc into it.
|
||||||
|
pub type ModuleCache = Mutex<LruCache<ModuleCacheKey, CachedModule>>;
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
|
||||||
|
// capacity 0 is nonsensical for an LRU; clamp up to 1 so the cache
|
||||||
|
// is at least usable (callers control this via env var, and 0 means
|
||||||
|
// "I disabled caching" — but disabling caching by accident would
|
||||||
|
// recompile every module every call, which is a worse UX than
|
||||||
|
// capping at 1).
|
||||||
|
let cap = NonZeroUsize::new(capacity.max(1)).expect("max(1) is non-zero");
|
||||||
|
Arc::new(Mutex::new(LruCache::new(cap)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
||||||
|
pub struct PicloudModuleResolver {
|
||||||
|
/// Backend the resolver consults for `(app_id, name)`. The bridge
|
||||||
|
/// runs Rhai's sync `resolve()` and the async `lookup()` together
|
||||||
|
/// via `tokio::runtime::Handle::block_on(...)` — safe because
|
||||||
|
/// `LocalExecutorClient` runs `Engine::execute` inside
|
||||||
|
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
||||||
|
/// that still carries a `Handle`.
|
||||||
|
source: Arc<dyn ModuleSource>,
|
||||||
|
|
||||||
|
/// Calling context. `cx.app_id` is the cross-app isolation
|
||||||
|
/// boundary; the resolver passes `&cx` to every `ModuleSource`
|
||||||
|
/// call so the backend can scope its queries.
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
|
||||||
|
/// Compiled-module cache. Shared across executions; invalidated
|
||||||
|
/// per-entry on `updated_at` mismatch (no explicit pub/sub).
|
||||||
|
cache: Arc<ModuleCache>,
|
||||||
|
|
||||||
|
/// In-progress imports stack — pushed before a `lookup`+compile,
|
||||||
|
/// popped after. A hit on this stack while resolving means the
|
||||||
|
/// graph contains a cycle.
|
||||||
|
in_progress: Mutex<Vec<String>>,
|
||||||
|
|
||||||
|
/// Current import depth. Independent of the cycle check (cycles
|
||||||
|
/// might be short; deep acyclic graphs might fit under the cap
|
||||||
|
/// but still warrant a guard).
|
||||||
|
depth: Mutex<u32>,
|
||||||
|
|
||||||
|
/// Hard ceiling on import depth. Defaults to 8; env-overridable
|
||||||
|
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
||||||
|
/// resolver construction.
|
||||||
|
depth_limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PicloudModuleResolver {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
source: Arc<dyn ModuleSource>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
cache: Arc<ModuleCache>,
|
||||||
|
depth_limit: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
source,
|
||||||
|
cx,
|
||||||
|
cache,
|
||||||
|
in_progress: Mutex::new(Vec::new()),
|
||||||
|
depth: Mutex::new(0),
|
||||||
|
depth_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate `ast` as a module body: only top-level `fn` decls,
|
||||||
|
/// `const` decls, and `import` statements are allowed. Top-level
|
||||||
|
/// expressions (which would execute on import — a footgun for
|
||||||
|
/// cache semantics) are rejected.
|
||||||
|
///
|
||||||
|
/// `fn` declarations live in a separate slot on the AST and are
|
||||||
|
/// not in `statements()`, so the only allowed `Stmt` variants we
|
||||||
|
/// expect to see at top level are `Var` (when `CONSTANT` flag is
|
||||||
|
/// set) and `Import`. Anything else triggers a `ModuleShape` error.
|
||||||
|
fn check_module_shape(ast: &AST, name: &str) -> Result<(), String> {
|
||||||
|
use rhai::ASTFlags;
|
||||||
|
for stmt in ast.statements() {
|
||||||
|
match stmt {
|
||||||
|
rhai::Stmt::Var(_, opts, _) if opts.intersects(ASTFlags::CONSTANT) => {}
|
||||||
|
rhai::Stmt::Import(..) => {}
|
||||||
|
rhai::Stmt::Noop(..) => {}
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"module {name:?}: top-level {} is not allowed; \
|
||||||
|
modules may only contain fn declarations, \
|
||||||
|
const declarations, and import statements",
|
||||||
|
stmt_kind_label(other),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a compiled AST and collect the literal-path `import "<name>"`
|
||||||
|
/// declarations. Dynamic imports (e.g. `import some_var as y;`) are
|
||||||
|
/// skipped because the dep-graph can only track names known at
|
||||||
|
/// compile time. Exposed via [`extract_imports`] so the manager's
|
||||||
|
/// admin endpoints can populate the `script_imports` table from
|
||||||
|
/// the same logic the resolver uses.
|
||||||
|
fn extract_imports_inner(ast: &AST) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for stmt in ast.statements() {
|
||||||
|
if let rhai::Stmt::Import(boxed, _) = stmt {
|
||||||
|
let (path_expr, _alias) = boxed.as_ref();
|
||||||
|
if let rhai::Expr::StringConstant(s, _) = path_expr {
|
||||||
|
out.push(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile-and-validate a candidate module body. Public so the
|
||||||
|
/// `Engine::validate_module` impl in `engine.rs` can call into it
|
||||||
|
/// without duplicating the shape check.
|
||||||
|
pub fn compile_module_ast(engine: &RhaiEngine, source: &str) -> Result<AST, String> {
|
||||||
|
let ast = engine.compile(source).map_err(|e| e.to_string())?;
|
||||||
|
PicloudModuleResolver::check_module_shape(&ast, "<source>")?;
|
||||||
|
Ok(ast)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `source` as an endpoint script (no module-shape check) and
|
||||||
|
/// return its declared literal-path imports. Used by
|
||||||
|
/// `Engine::validate` to populate `ValidatedScript::imports` so the
|
||||||
|
/// repo can write dep-graph edges.
|
||||||
|
pub fn extract_imports(engine: &RhaiEngine, source: &str) -> Result<ValidatedScript, String> {
|
||||||
|
let ast = engine.compile(source).map_err(|e| e.to_string())?;
|
||||||
|
Ok(ValidatedScript {
|
||||||
|
imports: PicloudModuleResolver::extract_imports_inner(&ast),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `source` as a module script: enforce shape, then extract
|
||||||
|
/// imports. Used by `Engine::validate_module`.
|
||||||
|
pub fn validate_module_source(
|
||||||
|
engine: &RhaiEngine,
|
||||||
|
source: &str,
|
||||||
|
) -> Result<ValidatedScript, String> {
|
||||||
|
let ast = compile_module_ast(engine, source)?;
|
||||||
|
Ok(ValidatedScript {
|
||||||
|
imports: PicloudModuleResolver::extract_imports_inner(&ast),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stmt_kind_label(stmt: &rhai::Stmt) -> &'static str {
|
||||||
|
use rhai::ASTFlags;
|
||||||
|
match stmt {
|
||||||
|
rhai::Stmt::Var(_, opts, _) if opts.intersects(ASTFlags::CONSTANT) => "const declaration",
|
||||||
|
rhai::Stmt::Var(..) => "let declaration",
|
||||||
|
rhai::Stmt::Expr(..) => "expression",
|
||||||
|
rhai::Stmt::FnCall(..) => "function call",
|
||||||
|
rhai::Stmt::If(..) => "if statement",
|
||||||
|
rhai::Stmt::Switch(..) => "switch statement",
|
||||||
|
rhai::Stmt::While(..) => "while/loop statement",
|
||||||
|
rhai::Stmt::Do(..) => "do statement",
|
||||||
|
rhai::Stmt::For(..) => "for statement",
|
||||||
|
rhai::Stmt::Assignment(..) => "assignment",
|
||||||
|
rhai::Stmt::Block(..) => "block",
|
||||||
|
rhai::Stmt::TryCatch(..) => "try/catch",
|
||||||
|
rhai::Stmt::Return(..) => "return/throw statement",
|
||||||
|
rhai::Stmt::BreakLoop(..) => "break/continue",
|
||||||
|
rhai::Stmt::Import(..) => "import statement",
|
||||||
|
rhai::Stmt::Export(..) => "export statement",
|
||||||
|
_ => "statement",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleResolver for PicloudModuleResolver {
|
||||||
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
engine: &RhaiEngine,
|
||||||
|
_source: Option<&str>,
|
||||||
|
path: &str,
|
||||||
|
pos: Position,
|
||||||
|
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
||||||
|
// RAII guard wraps both the depth counter and the import-stack
|
||||||
|
// push so that any early return (cycle / depth-exceeded / DB
|
||||||
|
// error / compile error / panic) leaves both consistent for
|
||||||
|
// any subsequent resolve() call on this resolver instance.
|
||||||
|
struct StackGuard<'r> {
|
||||||
|
stack: &'r Mutex<Vec<String>>,
|
||||||
|
depth: &'r Mutex<u32>,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
impl<'r> Drop for StackGuard<'r> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.armed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(mut s) = self.stack.lock() {
|
||||||
|
s.pop();
|
||||||
|
}
|
||||||
|
if let Ok(mut d) = self.depth.lock() {
|
||||||
|
*d = d.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only check + atomic push under one lock pair, so a
|
||||||
|
// sibling resolve() call on a shared resolver instance can't
|
||||||
|
// race in between. (We don't expect parallel calls on the same
|
||||||
|
// resolver — Rhai evaluates a single AST on one thread — but
|
||||||
|
// grouping the operations is cheaper than reasoning about the
|
||||||
|
// future.)
|
||||||
|
{
|
||||||
|
let mut depth = self.depth.lock().expect("module depth lock poisoned");
|
||||||
|
if *depth >= self.depth_limit {
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
format!(
|
||||||
|
"import depth limit ({}) exceeded while resolving {path:?}",
|
||||||
|
self.depth_limit
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
pos,
|
||||||
|
)),
|
||||||
|
pos,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut stack = self
|
||||||
|
.in_progress
|
||||||
|
.lock()
|
||||||
|
.expect("module in_progress lock poisoned");
|
||||||
|
if stack.iter().any(|p| p == path) {
|
||||||
|
let mut chain = stack.clone();
|
||||||
|
chain.push(path.to_string());
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
format!("circular import detected: {}", chain.join(" -> ")).into(),
|
||||||
|
pos,
|
||||||
|
)),
|
||||||
|
pos,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
stack.push(path.to_string());
|
||||||
|
*depth += 1;
|
||||||
|
}
|
||||||
|
let _guard = StackGuard {
|
||||||
|
stack: &self.in_progress,
|
||||||
|
depth: &self.depth,
|
||||||
|
armed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bridge to async. The resolver always runs on a `spawn_blocking`
|
||||||
|
// thread (see LocalExecutorClient in orchestrator-core), which
|
||||||
|
// still carries a Tokio handle. `try_current` makes the failure
|
||||||
|
// mode explicit when callers wire up an `Engine` from a non-
|
||||||
|
// Tokio context (typically a test harness).
|
||||||
|
let handle = tokio::runtime::Handle::try_current().map_err(|_| {
|
||||||
|
Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
"module resolver invoked outside a Tokio runtime; \
|
||||||
|
wrap Engine::execute in tokio::task::spawn_blocking"
|
||||||
|
.into(),
|
||||||
|
pos,
|
||||||
|
)),
|
||||||
|
pos,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||||
|
handle.block_on(self.source.lookup(&self.cx, path));
|
||||||
|
|
||||||
|
let module_row = match lookup_result {
|
||||||
|
Ok(Some(m)) => m,
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
||||||
|
path.to_string(),
|
||||||
|
pos,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
format!("module backend error: {e}").into(),
|
||||||
|
pos,
|
||||||
|
)),
|
||||||
|
pos,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache lookup: hit only if both key matches AND updated_at
|
||||||
|
// matches (cache is invalidated lazily on version change).
|
||||||
|
let cache_key = (self.cx.app_id, path.to_string());
|
||||||
|
{
|
||||||
|
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||||
|
if let Some(cached) = cache.get(&cache_key) {
|
||||||
|
if cached.updated_at == module_row.updated_at {
|
||||||
|
tracing::debug!(
|
||||||
|
target = "picloud::modules::cache",
|
||||||
|
app_id = %self.cx.app_id,
|
||||||
|
module = path,
|
||||||
|
"cache hit"
|
||||||
|
);
|
||||||
|
return Ok(cached.module.clone());
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
target = "picloud::modules::cache",
|
||||||
|
app_id = %self.cx.app_id,
|
||||||
|
module = path,
|
||||||
|
"cache stale; recompiling"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!(
|
||||||
|
target = "picloud::modules::cache",
|
||||||
|
app_id = %self.cx.app_id,
|
||||||
|
module = path,
|
||||||
|
"cache miss"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile + module-shape validation. Module sources MAY have
|
||||||
|
// already been gated at create-time (admin endpoint runs
|
||||||
|
// `validate_module`), but we revalidate here to catch DB-direct
|
||||||
|
// inserts that bypass the API surface.
|
||||||
|
let ast = engine.compile(&module_row.source).map_err(|e| {
|
||||||
|
// Wrap as an ErrorRuntime to preserve the parse message
|
||||||
|
// text without trying to reconstruct rhai's internal
|
||||||
|
// ParseErrorType variant (which would require matching on
|
||||||
|
// its full variant set).
|
||||||
|
Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
format!("module {path:?} parse error: {e}").into(),
|
||||||
|
e.position(),
|
||||||
|
)),
|
||||||
|
pos,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(msg.into(), pos)),
|
||||||
|
pos,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rhai's eval_ast_as_new compiles the AST's body + functions
|
||||||
|
// into a Module that the importing script consumes via
|
||||||
|
// `path::fn(...)` calls. Recursive imports inside this module
|
||||||
|
// are resolved through the same `engine.set_module_resolver`
|
||||||
|
// (which is THIS resolver), so cycle/depth tracking carries
|
||||||
|
// through naturally.
|
||||||
|
let module = Module::eval_ast_as_new(rhai::Scope::new(), &ast, engine).map_err(|e| {
|
||||||
|
Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
e,
|
||||||
|
pos,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let shared: SharedRhaiModule = module.into();
|
||||||
|
|
||||||
|
// Insert (possibly evicting via LRU). Subsequent imports of
|
||||||
|
// the same module under the same updated_at hit the cache.
|
||||||
|
{
|
||||||
|
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||||
|
cache.put(
|
||||||
|
cache_key,
|
||||||
|
CachedModule {
|
||||||
|
updated_at: module_row.updated_at,
|
||||||
|
module: shared.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,12 @@ pub struct Limits {
|
|||||||
/// Max call/expression nesting depth.
|
/// Max call/expression nesting depth.
|
||||||
pub max_call_levels: usize,
|
pub max_call_levels: usize,
|
||||||
pub max_expr_depth: usize,
|
pub max_expr_depth: usize,
|
||||||
|
|
||||||
|
/// v1.1.3: hard ceiling on `import` chain depth (A→B→C→…). Independent
|
||||||
|
/// of cycle detection — guards against deep but acyclic graphs.
|
||||||
|
/// Not script-overridable (this is a platform-level guard, not a
|
||||||
|
/// per-script knob).
|
||||||
|
pub module_import_depth_max: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Limits {
|
impl Default for Limits {
|
||||||
@@ -35,6 +41,7 @@ impl Default for Limits {
|
|||||||
max_map_size: 10_000,
|
max_map_size: 10_000,
|
||||||
max_call_levels: 64,
|
max_call_levels: 64,
|
||||||
max_expr_depth: 64,
|
max_expr_depth: 64,
|
||||||
|
module_import_depth_max: 8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +72,9 @@ impl Limits {
|
|||||||
max_expr_depth: overrides
|
max_expr_depth: overrides
|
||||||
.max_expr_depth
|
.max_expr_depth
|
||||||
.map_or(self.max_expr_depth, narrow_usize),
|
.map_or(self.max_expr_depth, narrow_usize),
|
||||||
|
// module_import_depth_max is platform-level — overrides
|
||||||
|
// never touch it. Carry through unchanged.
|
||||||
|
module_import_depth_max: self.module_import_depth_max,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use chrono::Utc;
|
|||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
|
AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
|
||||||
NoopEventEmitter, NoopKvService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
NoopEventEmitter, NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox,
|
||||||
|
SdkCallCx, Services,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -225,6 +226,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(InMemoryDocs::default()),
|
Arc::new(InMemoryDocs::default()),
|
||||||
Arc::new(NoopDeadLetterService),
|
Arc::new(NoopDeadLetterService),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use async_trait::async_trait;
|
|||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
|
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
|
||||||
NoopEventEmitter, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
NoopEventEmitter, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -104,6 +104,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopDocsService),
|
Arc::new(NoopDocsService),
|
||||||
Arc::new(NoopDeadLetterService),
|
Arc::new(NoopDeadLetterService),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
31
crates/manager-core/migrations/0015_scripts_kind.sql
Normal file
31
crates/manager-core/migrations/0015_scripts_kind.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- v1.1.3: distinguish endpoint scripts (HTTP / trigger entry points) from
|
||||||
|
-- module scripts (libraries `import`ed by other scripts). The Rhai module
|
||||||
|
-- resolver added in v1.1.3 looks up `kind = 'module'` rows by
|
||||||
|
-- `(app_id, name)`; route bind and trigger create reject `kind = 'module'`
|
||||||
|
-- targets.
|
||||||
|
--
|
||||||
|
-- Backfill: existing rows take the DEFAULT clause on column add. Every
|
||||||
|
-- script that existed in v1.0 / v1.1.0 / v1.1.1 / v1.1.2 was an endpoint
|
||||||
|
-- (the only kind those versions supported), which matches the default.
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD COLUMN kind TEXT NOT NULL DEFAULT 'endpoint'
|
||||||
|
CHECK (kind IN ('endpoint', 'module'));
|
||||||
|
|
||||||
|
-- Composite index on (app_id, kind) so the resolver's per-app module
|
||||||
|
-- lookup ("modules in app X named Y") is one index scan. The existing
|
||||||
|
-- per-app UNIQUE on `name` already serves name-based lookups, but it
|
||||||
|
-- doesn't help when filtering specifically for `kind = 'module'`.
|
||||||
|
CREATE INDEX idx_scripts_app_kind ON scripts (app_id, kind);
|
||||||
|
|
||||||
|
-- Modules are imported by exact string name; arbitrary spaces / control
|
||||||
|
-- characters would make `import "<name>"` fragile. We constrain module
|
||||||
|
-- names to a conservative identifier shape (letters, digits, underscore;
|
||||||
|
-- starts with a non-digit; up to 64 chars). Endpoint scripts keep the
|
||||||
|
-- looser pre-v1.1.3 name rules — the dashboard generates endpoint names
|
||||||
|
-- (and some users may already have spaces in them; we don't break those).
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_module_name_shape
|
||||||
|
CHECK (
|
||||||
|
kind <> 'module'
|
||||||
|
OR name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'
|
||||||
|
);
|
||||||
35
crates/manager-core/migrations/0016_script_imports.sql
Normal file
35
crates/manager-core/migrations/0016_script_imports.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- v1.1.3: dep graph between scripts and the modules they `import`.
|
||||||
|
--
|
||||||
|
-- Populated at script save-time. The validator extracts literal-path
|
||||||
|
-- `import "<name>"` declarations from the AST; the script repo writes
|
||||||
|
-- one row per resolved (importer, imported) pair inside the same
|
||||||
|
-- transaction as the INSERT/UPDATE on `scripts`. Unresolved names
|
||||||
|
-- (imported module doesn't exist yet) are silently skipped — the
|
||||||
|
-- resolver returns ErrorModuleNotFound at runtime, and a later save
|
||||||
|
-- of either script re-resolves and writes the edge.
|
||||||
|
--
|
||||||
|
-- Dynamic imports (`import some_var as alias;`) are not tracked
|
||||||
|
-- here — the resolver still honors them at runtime, but the graph
|
||||||
|
-- only captures names known at compile time. Document as a known
|
||||||
|
-- v1.1.3 limitation.
|
||||||
|
--
|
||||||
|
-- Purpose: drives a future "Used by" panel on a module's detail page
|
||||||
|
-- (v1.2+) and is the foundation for cluster-mode eager cache
|
||||||
|
-- invalidation (v1.3+). v1.1.3 only persists the rows; no admin
|
||||||
|
-- endpoint surfaces them yet.
|
||||||
|
CREATE TABLE script_imports (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
importer_script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||||
|
imported_script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (importer_script_id, imported_script_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reverse-edge index: "list scripts that import module X". The PK
|
||||||
|
-- covers (importer, imported) so forward lookups by importer are
|
||||||
|
-- already free; the reverse direction needs its own index.
|
||||||
|
CREATE INDEX idx_script_imports_imported ON script_imports (imported_script_id);
|
||||||
|
|
||||||
|
-- App-scoped scan ("all imports in this app") — used by the schema
|
||||||
|
-- snapshot tests and (eventually) the admin "audit" view.
|
||||||
|
CREATE INDEX idx_script_imports_app ON script_imports (app_id);
|
||||||
@@ -12,8 +12,8 @@ use axum::{
|
|||||||
Extension, Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox,
|
||||||
ValidationError,
|
ScriptValidator, ValidatedScript, ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -88,6 +88,11 @@ pub struct CreateScriptRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
|
/// v1.1.3: `endpoint` (default — handles HTTP routes / trigger
|
||||||
|
/// targets) or `module` (library of fn/const imported by other
|
||||||
|
/// scripts). Modules reject route binding and trigger creation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: ScriptKind,
|
||||||
pub timeout_seconds: Option<i32>,
|
pub timeout_seconds: Option<i32>,
|
||||||
pub memory_limit_mb: Option<i32>,
|
pub memory_limit_mb: Option<i32>,
|
||||||
/// Sandbox overrides; absent or empty `{}` means "use platform
|
/// Sandbox overrides; absent or empty `{}` means "use platform
|
||||||
@@ -120,6 +125,10 @@ pub struct UpdateScriptRequest {
|
|||||||
/// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
|
/// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
|
||||||
/// the stored value unchanged.
|
/// the stored value unchanged.
|
||||||
pub sandbox: Option<ScriptSandbox>,
|
pub sandbox: Option<ScriptSandbox>,
|
||||||
|
/// v1.1.3: `Some(kind)` changes the script's role. Transitions to
|
||||||
|
/// `Module` are rejected if any routes or triggers still reference
|
||||||
|
/// the script. `module → endpoint` is always allowed.
|
||||||
|
pub kind: Option<ScriptKind>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::option_option)]
|
#[allow(clippy::option_option)]
|
||||||
@@ -202,7 +211,20 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
Capability::AppWriteScript(input.app_id),
|
Capability::AppWriteScript(input.app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
state.validator.validate(&input.source)?;
|
// v1.1.3: dispatch to the right validator based on declared kind.
|
||||||
|
// Module bodies have stricter rules (no top-level statements) so
|
||||||
|
// they need a separate gate; endpoints retain the parse-only path.
|
||||||
|
let validated: ValidatedScript = if input.kind == ScriptKind::Module {
|
||||||
|
if RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
|
||||||
|
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
||||||
|
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
||||||
|
input.name
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
state.validator.validate_module(&input.source)?
|
||||||
|
} else {
|
||||||
|
state.validator.validate(&input.source)?
|
||||||
|
};
|
||||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||||
// raw FK violation surfacing as 500.
|
// raw FK violation surfacing as 500.
|
||||||
@@ -216,6 +238,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
|
kind: input.kind,
|
||||||
timeout_seconds: input.timeout_seconds,
|
timeout_seconds: input.timeout_seconds,
|
||||||
memory_limit_mb: input.memory_limit_mb,
|
memory_limit_mb: input.memory_limit_mb,
|
||||||
sandbox: if input.sandbox.is_empty() {
|
sandbox: if input.sandbox.is_empty() {
|
||||||
@@ -223,11 +246,39 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
} else {
|
} else {
|
||||||
Some(input.sandbox)
|
Some(input.sandbox)
|
||||||
},
|
},
|
||||||
|
imports: validated.imports,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Module names that would shadow a built-in stdlib / service namespace.
|
||||||
|
/// Rejected at create time so `import "kv" as foo` can never resolve to
|
||||||
|
/// a user-supplied module instead of (in a hypothetical future) the
|
||||||
|
/// real KV bridge — defense against author confusion, not a security
|
||||||
|
/// boundary (stdlib namespaces and module imports already live in
|
||||||
|
/// disjoint Rhai scopes).
|
||||||
|
const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
"random",
|
||||||
|
"time",
|
||||||
|
"json",
|
||||||
|
"base64",
|
||||||
|
"hex",
|
||||||
|
"url",
|
||||||
|
"kv",
|
||||||
|
"docs",
|
||||||
|
"dead_letters",
|
||||||
|
"http",
|
||||||
|
"files",
|
||||||
|
"pubsub",
|
||||||
|
"secrets",
|
||||||
|
"email",
|
||||||
|
"users",
|
||||||
|
"queue",
|
||||||
|
];
|
||||||
|
|
||||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
@@ -241,9 +292,44 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
Capability::AppWriteScript(script.app_id),
|
Capability::AppWriteScript(script.app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(src) = input.source.as_deref() {
|
|
||||||
state.validator.validate(src)?;
|
// Effective post-update kind: explicit override > existing kind.
|
||||||
|
let effective_kind = input.kind.unwrap_or(script.kind);
|
||||||
|
|
||||||
|
// v1.1.3: reject `endpoint → module` if the script still has
|
||||||
|
// routes or triggers bound to it. The reverse direction is always
|
||||||
|
// allowed (a module can't have routes/triggers anyway, so the
|
||||||
|
// transition can never strand users).
|
||||||
|
if effective_kind == ScriptKind::Module && script.kind != ScriptKind::Module {
|
||||||
|
let routes = state.repo.count_routes_for_script(id).await?;
|
||||||
|
let triggers = state.repo.count_triggers_for_script(id).await?;
|
||||||
|
if routes + triggers > 0 {
|
||||||
|
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
||||||
|
"cannot change kind to module: script is referenced by {routes} route(s) and {triggers} trigger(s); detach them first"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
if RESERVED_MODULE_NAMES.contains(&script.name.as_str()) {
|
||||||
|
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
|
||||||
|
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
||||||
|
script.name
|
||||||
|
))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.1.3: re-validate using the effective kind so endpoint → module
|
||||||
|
// transitions with a fresh source enforce the module shape rules.
|
||||||
|
// Source-less edits (name/description only) don't re-validate.
|
||||||
|
let imports_for_patch: Option<Vec<String>> = if let Some(src) = input.source.as_deref() {
|
||||||
|
let validated = if effective_kind == ScriptKind::Module {
|
||||||
|
state.validator.validate_module(src)?
|
||||||
|
} else {
|
||||||
|
state.validator.validate(src)?
|
||||||
|
};
|
||||||
|
Some(validated.imports)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(sb) = input.sandbox.as_ref() {
|
if let Some(sb) = input.sandbox.as_ref() {
|
||||||
state.sandbox_ceiling.check(sb)?;
|
state.sandbox_ceiling.check(sb)?;
|
||||||
}
|
}
|
||||||
@@ -258,6 +344,8 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
timeout_seconds: input.timeout_seconds,
|
timeout_seconds: input.timeout_seconds,
|
||||||
memory_limit_mb: input.memory_limit_mb,
|
memory_limit_mb: input.memory_limit_mb,
|
||||||
sandbox: input.sandbox,
|
sandbox: input.sandbox,
|
||||||
|
kind: input.kind,
|
||||||
|
imports: imports_for_patch,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ async fn seed_into(
|
|||||||
name: "hello".to_string(),
|
name: "hello".to_string(),
|
||||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||||
source: HELLO_RHAI_SOURCE.to_string(),
|
source: HELLO_RHAI_SOURCE.to_string(),
|
||||||
|
kind: picloud_shared::ScriptKind::Endpoint,
|
||||||
timeout_seconds: Some(5),
|
timeout_seconds: Some(5),
|
||||||
memory_limit_mb: None,
|
memory_limit_mb: None,
|
||||||
sandbox: None,
|
sandbox: None,
|
||||||
|
imports: Vec::new(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub mod kv_repo;
|
|||||||
pub mod kv_service;
|
pub mod kv_service;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
|
pub mod module_source;
|
||||||
pub mod outbox_event_emitter;
|
pub mod outbox_event_emitter;
|
||||||
pub mod outbox_repo;
|
pub mod outbox_repo;
|
||||||
pub mod principal_resolver;
|
pub mod principal_resolver;
|
||||||
@@ -95,6 +96,7 @@ pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
|
|||||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||||
pub use kv_service::KvServiceImpl;
|
pub use kv_service::KvServiceImpl;
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
|
pub use module_source::PostgresModuleSource;
|
||||||
pub use outbox_event_emitter::OutboxEventEmitter;
|
pub use outbox_event_emitter::OutboxEventEmitter;
|
||||||
pub use outbox_repo::{
|
pub use outbox_repo::{
|
||||||
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
|
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
|
||||||
|
|||||||
74
crates/manager-core/src/module_source.rs
Normal file
74
crates/manager-core/src/module_source.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
|
||||||
|
//!
|
||||||
|
//! Mirrors the structure of [`crate::kv_repo::PostgresKvRepo`] /
|
||||||
|
//! [`crate::docs_repo::PostgresDocsRepo`]: thin wrapper around a
|
||||||
|
//! `PgPool` that owns a single statement returning the module by
|
||||||
|
//! `(cx.app_id, name, kind = 'module')`. The resolver lives in
|
||||||
|
//! `executor-core` and consumes this trait through the `Services`
|
||||||
|
//! bundle, so manager-core stays the only crate that touches
|
||||||
|
//! Postgres.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, SdkCallCx};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct PostgresModuleSource {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresModuleSource {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ModuleRow {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
|
name: String,
|
||||||
|
source: String,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ModuleRow> for ModuleScript {
|
||||||
|
fn from(r: ModuleRow) -> Self {
|
||||||
|
Self {
|
||||||
|
script_id: r.id.into(),
|
||||||
|
app_id: r.app_id.into(),
|
||||||
|
name: r.name,
|
||||||
|
source: r.source,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModuleSource for PostgresModuleSource {
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
// The query is the cross-app isolation boundary: app_id comes
|
||||||
|
// from cx (never from the script-passed argument), and the
|
||||||
|
// CHECK constraint `kind IN ('endpoint','module')` plus the
|
||||||
|
// `kind = 'module'` filter together guarantee endpoint scripts
|
||||||
|
// are never importable. The `(app_id, kind)` index from
|
||||||
|
// migration 0015 makes this an index scan returning at most
|
||||||
|
// one row (per-app uniqueness on `name`).
|
||||||
|
let row: Option<ModuleRow> = sqlx::query_as(
|
||||||
|
"SELECT id, app_id, name, source, updated_at \
|
||||||
|
FROM scripts \
|
||||||
|
WHERE app_id = $1 AND kind = 'module' AND name = $2",
|
||||||
|
)
|
||||||
|
.bind(cx.app_id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ use std::collections::BTreeMap;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptKind,
|
||||||
|
ScriptSandbox,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
@@ -42,6 +43,29 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
patch: ScriptPatch,
|
patch: ScriptPatch,
|
||||||
) -> Result<Script, ScriptRepositoryError>;
|
) -> Result<Script, ScriptRepositoryError>;
|
||||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError>;
|
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError>;
|
||||||
|
|
||||||
|
/// v1.1.3: how many routes reference this script. Used by the
|
||||||
|
/// API layer to refuse `endpoint → module` kind changes when the
|
||||||
|
/// script is still bound to user-facing entry points.
|
||||||
|
async fn count_routes_for_script(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<i64, ScriptRepositoryError>;
|
||||||
|
|
||||||
|
/// v1.1.3: how many triggers (kv / docs / dead-letter) target
|
||||||
|
/// this script. Same purpose as `count_routes_for_script`.
|
||||||
|
async fn count_triggers_for_script(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<i64, ScriptRepositoryError>;
|
||||||
|
|
||||||
|
/// v1.1.3: list module dependencies of this script — the rows in
|
||||||
|
/// `script_imports` where `importer_script_id = script_id`. Used
|
||||||
|
/// by tests and (eventually) a dashboard "Imports" panel.
|
||||||
|
async fn list_imports(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inbound shape for create. Defaults match the migration's CHECK
|
/// Inbound shape for create. Defaults match the migration's CHECK
|
||||||
@@ -52,11 +76,19 @@ pub struct NewScript {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
|
/// Defaults to `Endpoint` if absent. `Module` scripts cannot be
|
||||||
|
/// bound to routes or used as trigger targets.
|
||||||
|
pub kind: ScriptKind,
|
||||||
pub timeout_seconds: Option<i32>,
|
pub timeout_seconds: Option<i32>,
|
||||||
pub memory_limit_mb: Option<i32>,
|
pub memory_limit_mb: Option<i32>,
|
||||||
/// Sandbox overrides; `None` means store an empty object (use
|
/// Sandbox overrides; `None` means store an empty object (use
|
||||||
/// platform defaults at exec time).
|
/// platform defaults at exec time).
|
||||||
pub sandbox: Option<ScriptSandbox>,
|
pub sandbox: Option<ScriptSandbox>,
|
||||||
|
/// v1.1.3: literal-path `import "<name>"` declarations extracted
|
||||||
|
/// from the source. The repo writes these into `script_imports`
|
||||||
|
/// transactionally with the script row. Empty when validation
|
||||||
|
/// found no imports (the common case for endpoints today).
|
||||||
|
pub imports: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inbound shape for update. `None` fields are left untouched.
|
/// Inbound shape for update. `None` fields are left untouched.
|
||||||
@@ -70,6 +102,15 @@ pub struct ScriptPatch {
|
|||||||
/// `Some(sandbox)` replaces the stored overrides wholesale (including
|
/// `Some(sandbox)` replaces the stored overrides wholesale (including
|
||||||
/// `Some(empty)` to clear them); `None` leaves them untouched.
|
/// `Some(empty)` to clear them); `None` leaves them untouched.
|
||||||
pub sandbox: Option<ScriptSandbox>,
|
pub sandbox: Option<ScriptSandbox>,
|
||||||
|
/// `Some(new_kind)` changes the script's role; the API layer
|
||||||
|
/// rejects unsafe transitions (e.g. endpoint→module when routes
|
||||||
|
/// or triggers reference the script).
|
||||||
|
pub kind: Option<ScriptKind>,
|
||||||
|
/// v1.1.3: when `source` is also `Some`, the repo replaces the
|
||||||
|
/// `script_imports` edges for this script with these names.
|
||||||
|
/// `None` keeps the existing edges untouched (a name/description
|
||||||
|
/// edit alone shouldn't touch the dep graph).
|
||||||
|
pub imports: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresScriptRepository {
|
pub struct PostgresScriptRepository {
|
||||||
@@ -88,14 +129,18 @@ impl PostgresScriptRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Columns selected from `scripts` everywhere — kept in one constant so
|
||||||
|
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||||
|
/// one query.
|
||||||
|
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
||||||
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ScriptRepository for PostgresScriptRepository {
|
impl ScriptRepository for PostgresScriptRepository {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let row = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
"SELECT id, app_id, name, description, version, source, \
|
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE id = $1"
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
))
|
||||||
FROM scripts WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -103,22 +148,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
"SELECT id, app_id, name, description, version, source, \
|
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
))
|
||||||
FROM scripts ORDER BY name",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
"SELECT id, app_id, name, description, version, source, \
|
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE app_id = $1 ORDER BY name"
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
))
|
||||||
FROM scripts WHERE app_id = $1 ORDER BY name",
|
|
||||||
)
|
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -129,14 +170,17 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
&self,
|
&self,
|
||||||
user_id: AdminUserId,
|
user_id: AdminUserId,
|
||||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let cols = SCRIPT_SELECT_COLS
|
||||||
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
.split(", ")
|
||||||
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
.map(|c| format!("s.{c}"))
|
||||||
FROM scripts s \
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
|
"SELECT {cols} FROM scripts s \
|
||||||
JOIN app_members m ON m.app_id = s.app_id \
|
JOIN app_members m ON m.app_id = s.app_id \
|
||||||
WHERE m.user_id = $1 \
|
WHERE m.user_id = $1 \
|
||||||
ORDER BY s.name",
|
ORDER BY s.name"
|
||||||
)
|
))
|
||||||
.bind(user_id.into_inner())
|
.bind(user_id.into_inner())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -146,34 +190,42 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
"INSERT INTO scripts ( \
|
"INSERT INTO scripts ( \
|
||||||
app_id, name, description, source, \
|
app_id, name, description, source, kind, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox \
|
timeout_seconds, memory_limit_mb, sandbox \
|
||||||
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||||
RETURNING id, app_id, name, description, version, source, \
|
RETURNING {SCRIPT_SELECT_COLS}"
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
))
|
||||||
)
|
|
||||||
.bind(input.app_id.into_inner())
|
.bind(input.app_id.into_inner())
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(input.description.as_deref())
|
.bind(input.description.as_deref())
|
||||||
.bind(&input.source)
|
.bind(&input.source)
|
||||||
|
.bind(input.kind.as_str())
|
||||||
.bind(input.timeout_seconds)
|
.bind(input.timeout_seconds)
|
||||||
.bind(input.memory_limit_mb)
|
.bind(input.memory_limit_mb)
|
||||||
.bind(sandbox_json)
|
.bind(sandbox_json)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&mut *tx)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match res {
|
let script: Script = match res {
|
||||||
Ok(row) => Ok(row.into()),
|
Ok(row) => row.into(),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
Err(ScriptRepositoryError::Conflict(format!(
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
"a script named {:?} already exists in this app",
|
"a script named {:?} already exists in this app",
|
||||||
input.name
|
input.name
|
||||||
)))
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Dep-graph: write any literal-path imports declared in the
|
||||||
|
// source. Unresolved names (the referenced module doesn't
|
||||||
|
// exist yet) are silently skipped — best-effort.
|
||||||
|
replace_imports_tx(&mut tx, script.id, script.app_id, &input.imports).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update(
|
async fn update(
|
||||||
@@ -192,7 +244,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
.sandbox
|
.sandbox
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
"UPDATE scripts SET \
|
"UPDATE scripts SET \
|
||||||
name = COALESCE($2, name), \
|
name = COALESCE($2, name), \
|
||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
@@ -200,12 +253,12 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||||
sandbox = COALESCE($8, sandbox), \
|
sandbox = COALESCE($8, sandbox), \
|
||||||
|
kind = COALESCE($9, kind), \
|
||||||
version = version + 1, \
|
version = version + 1, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, app_id, name, description, version, source, \
|
RETURNING {SCRIPT_SELECT_COLS}"
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
))
|
||||||
)
|
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(patch.name.as_deref())
|
.bind(patch.name.as_deref())
|
||||||
.bind(patch.description.is_some())
|
.bind(patch.description.is_some())
|
||||||
@@ -214,19 +267,30 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
.bind(patch.timeout_seconds)
|
.bind(patch.timeout_seconds)
|
||||||
.bind(patch.memory_limit_mb)
|
.bind(patch.memory_limit_mb)
|
||||||
.bind(sandbox_json)
|
.bind(sandbox_json)
|
||||||
.fetch_optional(&self.pool)
|
.bind(patch.kind.map(|k| k.as_str()))
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match res {
|
let script: Script = match res {
|
||||||
Ok(Some(row)) => Ok(row.into()),
|
Ok(Some(row)) => row.into(),
|
||||||
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
Err(ScriptRepositoryError::Conflict(
|
return Err(ScriptRepositoryError::Conflict(
|
||||||
"a script with that name already exists in this app".into(),
|
"a script with that name already exists in this app".into(),
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace imports only when the caller has a fresh list (i.e.
|
||||||
|
// the source actually changed and the validator re-extracted
|
||||||
|
// imports). A name-only or description-only edit leaves the
|
||||||
|
// dep graph alone.
|
||||||
|
if let Some(imports) = patch.imports.as_deref() {
|
||||||
|
replace_imports_tx(&mut tx, script.id, script.app_id, imports).await?;
|
||||||
}
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||||
@@ -239,6 +303,85 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_routes_for_script(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let n: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM routes WHERE script_id = $1")
|
||||||
|
.bind(script_id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(n.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_triggers_for_script(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let n: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM triggers WHERE script_id = $1")
|
||||||
|
.bind(script_id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(n.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_imports(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let cols = SCRIPT_SELECT_COLS
|
||||||
|
.split(", ")
|
||||||
|
.map(|c| format!("s.{c}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
|
"SELECT {cols} FROM scripts s \
|
||||||
|
JOIN script_imports i ON i.imported_script_id = s.id \
|
||||||
|
WHERE i.importer_script_id = $1 \
|
||||||
|
ORDER BY s.name"
|
||||||
|
))
|
||||||
|
.bind(script_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the `script_imports` edges for `importer` with rows derived
|
||||||
|
/// from `import_names`. Names that don't resolve to a `kind = 'module'`
|
||||||
|
/// script in the same app are silently skipped (best-effort dep graph).
|
||||||
|
async fn replace_imports_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
importer: ScriptId,
|
||||||
|
app_id: AppId,
|
||||||
|
import_names: &[String],
|
||||||
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
|
sqlx::query("DELETE FROM script_imports WHERE importer_script_id = $1")
|
||||||
|
.bind(importer.into_inner())
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
if import_names.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Insert with ON CONFLICT DO NOTHING in case the source declares
|
||||||
|
// `import "x"` twice — the dep graph stores each pair at most once.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO script_imports (app_id, importer_script_id, imported_script_id) \
|
||||||
|
SELECT $1, $2, s.id \
|
||||||
|
FROM scripts s \
|
||||||
|
WHERE s.app_id = $1 \
|
||||||
|
AND s.kind = 'module' \
|
||||||
|
AND s.id <> $2 \
|
||||||
|
AND s.name = ANY($3) \
|
||||||
|
ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(importer.into_inner())
|
||||||
|
.bind(import_names)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
||||||
@@ -250,6 +393,10 @@ struct ScriptRow {
|
|||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
version: i32,
|
version: i32,
|
||||||
source: String,
|
source: String,
|
||||||
|
/// v1.1.3: 'endpoint' | 'module'. Stored as TEXT with a CHECK
|
||||||
|
/// constraint so we don't need a Postgres enum (avoiding the
|
||||||
|
/// migration churn of adding values later).
|
||||||
|
kind: String,
|
||||||
timeout_seconds: i32,
|
timeout_seconds: i32,
|
||||||
memory_limit_mb: i32,
|
memory_limit_mb: i32,
|
||||||
sandbox: serde_json::Value,
|
sandbox: serde_json::Value,
|
||||||
@@ -264,6 +411,10 @@ impl From<ScriptRow> for Script {
|
|||||||
// fall back to an empty ScriptSandbox rather than poisoning a
|
// fall back to an empty ScriptSandbox rather than poisoning a
|
||||||
// list response.
|
// list response.
|
||||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||||
|
// Defensive: if a row's `kind` somehow falls outside the CHECK
|
||||||
|
// constraint, treat it as Endpoint (the safe default — won't
|
||||||
|
// grant a row import-target status it doesn't have).
|
||||||
|
let kind = ScriptKind::from_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
|
||||||
Self {
|
Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
app_id: r.app_id.into(),
|
app_id: r.app_id.into(),
|
||||||
@@ -271,6 +422,7 @@ impl From<ScriptRow> for Script {
|
|||||||
description: r.description,
|
description: r.description,
|
||||||
version: r.version,
|
version: r.version,
|
||||||
source: r.source,
|
source: r.source,
|
||||||
|
kind,
|
||||||
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
||||||
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
||||||
sandbox,
|
sandbox,
|
||||||
|
|||||||
@@ -120,12 +120,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let abandoned_repo: Arc<dyn AbandonedRepo> = Arc::new(PostgresAbandonedRepo::new(pool.clone()));
|
let abandoned_repo: Arc<dyn AbandonedRepo> = Arc::new(PostgresAbandonedRepo::new(pool.clone()));
|
||||||
let trigger_config = TriggerConfig::from_env();
|
let trigger_config = TriggerConfig::from_env();
|
||||||
|
|
||||||
// SDK services bundle. v1.1.1 added KV + dead-letter; v1.1.2 adds
|
// SDK services bundle. v1.1.1 added KV + dead-letter; v1.1.2 added
|
||||||
// the docs store. All four bound services share the
|
// the docs store; v1.1.3 adds the module source backing the Rhai
|
||||||
// outbox-backed event emitter so KV and docs mutations both fan
|
// resolver. All bound services share the outbox-backed event
|
||||||
// out through the same dispatcher.
|
// emitter so KV and docs mutations both fan out through the same
|
||||||
|
// dispatcher.
|
||||||
let kv_repo = Arc::new(PostgresKvRepo::new(pool.clone()));
|
let kv_repo = Arc::new(PostgresKvRepo::new(pool.clone()));
|
||||||
let docs_repo = Arc::new(PostgresDocsRepo::new(pool));
|
let docs_repo = Arc::new(PostgresDocsRepo::new(pool.clone()));
|
||||||
let events: Arc<dyn ServiceEventEmitter> = Arc::new(OutboxEventEmitter::new(
|
let events: Arc<dyn ServiceEventEmitter> = Arc::new(OutboxEventEmitter::new(
|
||||||
trigger_repo.clone(),
|
trigger_repo.clone(),
|
||||||
outbox_repo.clone(),
|
outbox_repo.clone(),
|
||||||
@@ -142,7 +143,9 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
outbox_repo.clone(),
|
outbox_repo.clone(),
|
||||||
authz.clone(),
|
authz.clone(),
|
||||||
));
|
));
|
||||||
let services = Services::new(kv, docs, dl_service.clone(), events);
|
let modules: Arc<dyn picloud_shared::ModuleSource> =
|
||||||
|
Arc::new(picloud_manager_core::PostgresModuleSource::new(pool));
|
||||||
|
let services = Services::new(kv, docs, dl_service.clone(), events, modules);
|
||||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
@@ -418,4 +421,22 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|||||||
) -> Result<(), picloud_manager_core::ScriptRepositoryError> {
|
) -> Result<(), picloud_manager_core::ScriptRepositoryError> {
|
||||||
self.0.delete(id).await
|
self.0.delete(id).await
|
||||||
}
|
}
|
||||||
|
async fn count_routes_for_script(
|
||||||
|
&self,
|
||||||
|
script_id: picloud_shared::ScriptId,
|
||||||
|
) -> Result<i64, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.count_routes_for_script(script_id).await
|
||||||
|
}
|
||||||
|
async fn count_triggers_for_script(
|
||||||
|
&self,
|
||||||
|
script_id: picloud_shared::ScriptId,
|
||||||
|
) -> Result<i64, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.count_triggers_for_script(script_id).await
|
||||||
|
}
|
||||||
|
async fn list_imports(
|
||||||
|
&self,
|
||||||
|
script_id: picloud_shared::ScriptId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_imports(script_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod ids;
|
|||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
|
pub mod modules;
|
||||||
pub mod outbox_writer;
|
pub mod outbox_writer;
|
||||||
pub mod route;
|
pub mod route;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
@@ -40,12 +41,13 @@ pub use inbox::{
|
|||||||
};
|
};
|
||||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
|
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
|
||||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||||
pub use route::{DispatchMode, HostKind, PathKind, Route};
|
pub use route::{DispatchMode, HostKind, PathKind, Route};
|
||||||
pub use sandbox::ScriptSandbox;
|
pub use sandbox::ScriptSandbox;
|
||||||
pub use script::Script;
|
pub use script::{Script, ScriptKind};
|
||||||
pub use sdk_cx::SdkCallCx;
|
pub use sdk_cx::SdkCallCx;
|
||||||
pub use services::Services;
|
pub use services::Services;
|
||||||
pub use trigger_event::{DeadLetterEventDetail, DocsEventOp, KvEventOp, TriggerEvent};
|
pub use trigger_event::{DeadLetterEventDetail, DocsEventOp, KvEventOp, TriggerEvent};
|
||||||
pub use validator::{ScriptValidator, ValidationError};
|
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
||||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||||
|
|||||||
75
crates/shared/src/modules.rs
Normal file
75
crates/shared/src/modules.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//! `ModuleSource` — the v1.1.3 Rhai module-loading contract.
|
||||||
|
//!
|
||||||
|
//! The executor-core `PicloudModuleResolver` calls into this trait to
|
||||||
|
//! load `kind = 'module'` scripts referenced by `import "<name>" as <alias>;`
|
||||||
|
//! statements. The Postgres impl in `manager-core` reads from the
|
||||||
|
//! `scripts` table; tests pin in-memory fakes.
|
||||||
|
//!
|
||||||
|
//! Implementations MUST derive `app_id` from `cx.app_id` and pass it
|
||||||
|
//! to every backend query. The `name` argument carries only the
|
||||||
|
//! script's name (the literal between the import quotes); the trait
|
||||||
|
//! has no way to express a cross-app lookup. That asymmetry is the
|
||||||
|
//! load-bearing cross-app isolation boundary — see `docs/sdk-shape.md`.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{AppId, ScriptId, SdkCallCx};
|
||||||
|
|
||||||
|
/// A module script as returned by `ModuleSource::lookup`. Carries only
|
||||||
|
/// the fields the resolver needs: the id (for diagnostics), the source
|
||||||
|
/// (to compile), and `updated_at` (the cache-staleness comparator).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModuleScript {
|
||||||
|
pub script_id: ScriptId,
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub name: String,
|
||||||
|
pub source: String,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lookup contract used by `PicloudModuleResolver`. `lookup` MUST
|
||||||
|
/// scope by `cx.app_id`; cross-app reads must be unreachable.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ModuleSource: Send + Sync {
|
||||||
|
/// Resolve a module script by `(cx.app_id, name)`. Returns `None`
|
||||||
|
/// when no row exists, or when a row exists but its `kind` is
|
||||||
|
/// `'endpoint'` (endpoints are never importable). The resolver
|
||||||
|
/// surfaces `None` as `ErrorModuleNotFound` to Rhai.
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure modes surfaced from `ModuleSource::lookup`. "Not found" is
|
||||||
|
/// not exceptional — it's `Ok(None)`.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ModuleSourceError {
|
||||||
|
/// Backend (Postgres, network, etc.) unavailable or returned an
|
||||||
|
/// error. The string is safe to surface to a script (Rhai wraps
|
||||||
|
/// it in `ErrorModuleNotFound` with the module name + reason).
|
||||||
|
#[error("module backend error: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub used by the executor-core test harness so engine integration
|
||||||
|
/// tests don't need a real DB-backed source. Every lookup returns
|
||||||
|
/// `Ok(None)` — `import "x"` always errors as "module not found"
|
||||||
|
/// under this impl.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopModuleSource;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModuleSource for NoopModuleSource {
|
||||||
|
async fn lookup(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,52 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::{AppId, ScriptId, ScriptSandbox};
|
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||||
|
|
||||||
|
/// Semantic role of a script (v1.1.3).
|
||||||
|
///
|
||||||
|
/// `Endpoint` scripts have an executable entry point — they bind to HTTP
|
||||||
|
/// routes and act as trigger handlers. `Module` scripts are libraries of
|
||||||
|
/// `fn`/`const` declarations imported by other scripts via Rhai's
|
||||||
|
/// `import "<name>" as <alias>;` syntax. Modules cannot be invoked
|
||||||
|
/// directly: route binding and trigger creation reject `Module` targets.
|
||||||
|
///
|
||||||
|
/// Serialized as `"endpoint"` / `"module"` so the wire shape is the
|
||||||
|
/// same string the SQL `CHECK (kind IN ('endpoint','module'))`
|
||||||
|
/// constraint enforces.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ScriptKind {
|
||||||
|
Endpoint,
|
||||||
|
Module,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScriptKind {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptKind {
|
||||||
|
/// Wire / SQL representation. Inverse of `from_str`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Endpoint => "endpoint",
|
||||||
|
Self::Module => "module",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the canonical wire / SQL form. Returns `None` for any
|
||||||
|
/// other input; callers map that to a 400 / `ValidationError`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"endpoint" => Some(Self::Endpoint),
|
||||||
|
"module" => Some(Self::Module),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A user-uploaded Rhai script and its execution configuration.
|
/// A user-uploaded Rhai script and its execution configuration.
|
||||||
///
|
///
|
||||||
/// This is the canonical representation that flows between manager (storage),
|
/// This is the canonical representation that flows between manager (storage),
|
||||||
@@ -20,6 +66,12 @@ pub struct Script {
|
|||||||
pub version: i32,
|
pub version: i32,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
|
|
||||||
|
/// `Endpoint` (default; the only kind v1.0 through v1.1.2 supported)
|
||||||
|
/// or `Module` (v1.1.3 — imported by other scripts, never bound
|
||||||
|
/// directly to a route or trigger).
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: ScriptKind,
|
||||||
|
|
||||||
pub timeout_seconds: u32,
|
pub timeout_seconds: u32,
|
||||||
|
|
||||||
/// Per-script overrides for Rhai sandbox limits. Empty = platform
|
/// Per-script overrides for Rhai sandbox limits. Empty = platform
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
//! handed to `executor-core::sdk::register_all` alongside an
|
//! handed to `executor-core::sdk::register_all` alongside an
|
||||||
//! `SdkCallCx` to wire each `::` namespace.
|
//! `SdkCallCx` to wire each `::` namespace.
|
||||||
//!
|
//!
|
||||||
//! v1.1.0 shipped this empty; v1.1.1 adds the first two service fields
|
//! v1.1.0 shipped this empty; v1.1.1 added the first two service fields
|
||||||
//! (`kv`, `dead_letters`) plus the `events` emitter that bound services
|
//! (`kv`, `dead_letters`) plus the `events` emitter that bound services
|
||||||
//! use to publish events into the triggers outbox.
|
//! use to publish events into the triggers outbox. v1.1.3 adds the
|
||||||
|
//! `modules` field — the `ModuleSource` consulted by the per-call
|
||||||
|
//! `PicloudModuleResolver` to load `import`ed module scripts.
|
||||||
//!
|
//!
|
||||||
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
|
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
|
||||||
//! consumers that only *pattern-match* a `&Services`; only crates that
|
//! consumers that only *pattern-match* a `&Services`; only crates that
|
||||||
@@ -18,8 +20,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DeadLetterService, DocsService, KvService, NoopDeadLetterService, NoopDocsService,
|
DeadLetterService, DocsService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
||||||
NoopEventEmitter, NoopKvService, ServiceEventEmitter,
|
NoopEventEmitter, NoopKvService, NoopModuleSource, ServiceEventEmitter,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||||
@@ -45,6 +47,12 @@ pub struct Services {
|
|||||||
/// `manager-core::outbox_event_emitter` replaces v1.1.0's
|
/// `manager-core::outbox_event_emitter` replaces v1.1.0's
|
||||||
/// `NoopEventEmitter`.
|
/// `NoopEventEmitter`.
|
||||||
pub events: Arc<dyn ServiceEventEmitter>,
|
pub events: Arc<dyn ServiceEventEmitter>,
|
||||||
|
|
||||||
|
/// Module source (v1.1.3). The `PicloudModuleResolver` consults
|
||||||
|
/// this to load `kind = 'module'` scripts that other scripts
|
||||||
|
/// `import`. Backed by Postgres in the picloud binary; in-memory
|
||||||
|
/// fakes in resolver tests.
|
||||||
|
pub modules: Arc<dyn ModuleSource>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Services {
|
impl Services {
|
||||||
@@ -57,12 +65,14 @@ impl Services {
|
|||||||
docs: Arc<dyn DocsService>,
|
docs: Arc<dyn DocsService>,
|
||||||
dead_letters: Arc<dyn DeadLetterService>,
|
dead_letters: Arc<dyn DeadLetterService>,
|
||||||
events: Arc<dyn ServiceEventEmitter>,
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
|
modules: Arc<dyn ModuleSource>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kv,
|
kv,
|
||||||
docs,
|
docs,
|
||||||
dead_letters,
|
dead_letters,
|
||||||
events,
|
events,
|
||||||
|
modules,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +88,7 @@ impl Services {
|
|||||||
Arc::new(NoopDocsService),
|
Arc::new(NoopDocsService),
|
||||||
Arc::new(NoopDeadLetterService),
|
Arc::new(NoopDeadLetterService),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,39 @@ use thiserror::Error;
|
|||||||
pub enum ValidationError {
|
pub enum ValidationError {
|
||||||
#[error("invalid script source: {0}")]
|
#[error("invalid script source: {0}")]
|
||||||
Syntax(String),
|
Syntax(String),
|
||||||
|
|
||||||
|
/// v1.1.3: source compiled but failed the module-shape gate
|
||||||
|
/// (top-level statements other than `fn` / `const` / `import`).
|
||||||
|
#[error("module syntax error: {0}")]
|
||||||
|
ModuleShape(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of a successful validate. v1.1.3 carries the list of literal
|
||||||
|
/// `import "<name>"` paths the script declares — the manager writes
|
||||||
|
/// these into the `script_imports` dep-graph table. Endpoints may also
|
||||||
|
/// have imports; the field is populated unconditionally.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ValidatedScript {
|
||||||
|
/// Literal-path imports (in declaration order). Dynamic imports
|
||||||
|
/// `import some_var as y;` are not captured — the resolver still
|
||||||
|
/// honors them at runtime, but the dep graph only tracks names
|
||||||
|
/// known at compile time.
|
||||||
|
pub imports: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ScriptValidator: Send + Sync {
|
pub trait ScriptValidator: Send + Sync {
|
||||||
fn validate(&self, source: &str) -> Result<(), ValidationError>;
|
/// Endpoint-shape validation: parse-only syntax check. Returns the
|
||||||
|
/// declared (literal) imports so the manager can populate the
|
||||||
|
/// dep-graph table on save.
|
||||||
|
fn validate(&self, source: &str) -> Result<ValidatedScript, ValidationError>;
|
||||||
|
|
||||||
|
/// Module-shape validation: parse + reject any top-level
|
||||||
|
/// statement that isn't `fn` / `const` / `import`. Default impl
|
||||||
|
/// rejects every module so non-engine validators stay simple
|
||||||
|
/// (tests / stubs don't need to know module rules).
|
||||||
|
fn validate_module(&self, _source: &str) -> Result<ValidatedScript, ValidationError> {
|
||||||
|
Err(ValidationError::ModuleShape(
|
||||||
|
"module validation not implemented by this validator".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||||||
/// `docs::collection(name).{create,get,find,find_one,update,delete,list}`
|
/// `docs::collection(name).{create,get,find,find_one,update,delete,list}`
|
||||||
/// with the v1.1.2 query DSL subset; `ctx.event.docs` for docs-trigger
|
/// with the v1.1.2 query DSL subset; `ctx.event.docs` for docs-trigger
|
||||||
/// handlers (carries `prev_data` change-data-capture for update/delete).
|
/// handlers (carries `prev_data` change-data-capture for update/delete).
|
||||||
pub const SDK_VERSION: &str = "1.3";
|
///
|
||||||
|
/// 1.4 additions (v1.1.3): `import "<name>" as <alias>;` for scripts
|
||||||
|
/// whose corresponding module (`kind = 'module'`) lives in the same
|
||||||
|
/// app. Cross-app imports are unreachable (the `name` argument carries
|
||||||
|
/// no `app_id`). Modules expose `fn`/`const` declarations only;
|
||||||
|
/// top-level statements are rejected at create-time.
|
||||||
|
pub const SDK_VERSION: &str = "1.4";
|
||||||
|
|
||||||
/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`.
|
/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`.
|
||||||
/// Bump (new integer + new URL prefix) when the request/response
|
/// Bump (new integer + new URL prefix) when the request/response
|
||||||
|
|||||||
Reference in New Issue
Block a user