From 84833d3e4eb8fa87781955c2e9c5a5cda6b7656b Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 2 Jun 2026 22:04:21 +0200 Subject: [PATCH] feat(v1.1.3-modules): shared types, migrations, engine + resolver scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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, 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`), - 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)` 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) --- Cargo.lock | 10 + Cargo.toml | 4 + crates/executor-core/Cargo.toml | 11 +- crates/executor-core/src/engine.rs | 127 +++++- crates/executor-core/src/lib.rs | 5 + crates/executor-core/src/module_resolver.rs | 430 ++++++++++++++++++ crates/executor-core/src/sandbox.rs | 10 + crates/executor-core/tests/sdk_docs.rs | 4 +- crates/executor-core/tests/sdk_kv.rs | 3 +- .../migrations/0015_scripts_kind.sql | 31 ++ .../migrations/0016_script_imports.sql | 35 ++ crates/manager-core/src/api.rs | 98 +++- crates/manager-core/src/app_bootstrap.rs | 2 + crates/manager-core/src/lib.rs | 2 + crates/manager-core/src/module_source.rs | 74 +++ crates/manager-core/src/repo.rs | 244 ++++++++-- crates/picloud/src/lib.rs | 33 +- crates/shared/src/lib.rs | 6 +- crates/shared/src/modules.rs | 75 +++ crates/shared/src/script.rs | 52 +++ crates/shared/src/services.rs | 19 +- crates/shared/src/validator.rs | 33 +- crates/shared/src/version.rs | 8 +- 23 files changed, 1231 insertions(+), 85 deletions(-) create mode 100644 crates/executor-core/src/module_resolver.rs create mode 100644 crates/manager-core/migrations/0015_scripts_kind.sql create mode 100644 crates/manager-core/migrations/0016_script_imports.sql create mode 100644 crates/manager-core/src/module_source.rs create mode 100644 crates/shared/src/modules.rs diff --git a/Cargo.lock b/Cargo.lock index 07f8248..8adbc03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,6 +1274,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "lru-slab" version = "0.1.2" @@ -1570,6 +1579,7 @@ dependencies = [ "base64", "chrono", "hex", + "lru", "percent-encoding", "picloud-shared", "rand 0.8.6", diff --git a/Cargo.toml b/Cargo.toml index 8063bfe..9d8913c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,10 @@ regex = "1" hex = "0.4" 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] unsafe_code = "forbid" diff --git a/crates/executor-core/Cargo.toml b/crates/executor-core/Cargo.toml index 4af4552..054f527 100644 --- a/crates/executor-core/Cargo.toml +++ b/crates/executor-core/Cargo.toml @@ -18,7 +18,16 @@ tokio.workspace = true tracing.workspace = true uuid.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/. regex.workspace = true diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index da27ffa..3104aa2 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -4,11 +4,15 @@ use std::time::Instant; use chrono::Utc; 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 crate::module_resolver::{ + extract_imports, new_module_cache, validate_module_source, ModuleCache, PicloudModuleResolver, +}; use crate::sandbox::Limits; use crate::sdk; use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic}; @@ -16,6 +20,11 @@ use crate::types::{ 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 /// `Services` bundle attached. /// @@ -31,12 +40,34 @@ use crate::types::{ pub struct Engine { limits: Limits, 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, } impl Engine { #[must_use] 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::().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] @@ -44,16 +75,42 @@ impl Engine { &self.limits } - /// Parse-only validation. Surfaced at script-upload time so syntax - /// errors are caught before the first invocation. Same logic as the - /// `ScriptValidator` impl below but with the richer `ExecError` - /// variant; callers in the executor path use this, the manager - /// path goes through the trait. - pub fn validate(&self, source: &str) -> Result<(), ExecError> { + /// Shared compiled-module cache. Exposed so tests can introspect + /// the cache state (length, contents) under a Mutex lock. + #[must_use] + pub fn module_cache(&self) -> &Arc { + &self.module_cache + } + + /// 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 ""` + /// declarations so the repo can populate the dep-graph table. + pub fn validate(&self, source: &str) -> Result { + // 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 { + 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, ExecError> { let engine = build_engine(self.limits, None); engine .compile(source) - .map(|_| ()) + .map(Arc::new) .map_err(|e| ExecError::Parse(e.to_string())) } @@ -63,6 +120,25 @@ impl Engine { /// request replace the engine's defaults field-by-field; the /// manager already clamped them against the admin ceiling. pub fn execute(&self, source: &str, req: ExecRequest) -> Result { + 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, + req: ExecRequest, + ) -> Result { let effective_limits = self.limits.with_overrides(&req.sandbox_overrides); let logs: Arc>> = Arc::new(Mutex::new(Vec::new())); 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, 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); - let ast = engine - .compile(source) - .map_err(|e| ExecError::Parse(e.to_string()))?; - let mut scope = Scope::new(); scope.push_constant("ctx", build_ctx_map(&req)); let started = Instant::now(); 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)?; let duration = started.elapsed(); @@ -116,8 +199,18 @@ impl Engine { } impl ScriptValidator for Engine { - fn validate(&self, source: &str) -> Result<(), ValidationError> { - Engine::validate(self, source).map_err(|e| ValidationError::Syntax(e.to_string())) + fn validate(&self, source: &str) -> Result { + 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 { + Engine::validate_module(self, source).map_err(|e| match e { + ExecError::Parse(msg) => ValidationError::ModuleShape(msg), + other => ValidationError::ModuleShape(other.to_string()), + }) } } diff --git a/crates/executor-core/src/lib.rs b/crates/executor-core/src/lib.rs index 384a161..aa175b0 100644 --- a/crates/executor-core/src/lib.rs +++ b/crates/executor-core/src/lib.rs @@ -7,11 +7,16 @@ pub mod context; pub mod engine; pub mod logging; +pub mod module_resolver; pub mod sandbox; pub mod sdk; pub mod types; 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 types::{ ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel, diff --git a/crates/executor-core/src/module_resolver.rs b/crates/executor-core/src/module_resolver.rs new file mode 100644 index 0000000..d926701 --- /dev/null +++ b/crates/executor-core/src/module_resolver.rs @@ -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` so every +//! `import ""` 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's `SharedRhaiModule` +/// type alias is `pub(crate)`). Resolves to `Arc` under the +/// `sync` feature that the workspace pins. +type SharedRhaiModule = Shared; + +/// 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` is an Arc bump. +#[derive(Clone)] +pub struct CachedModule { + pub updated_at: DateTime, + pub module: Shared, +} + +/// 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>; + +#[must_use] +pub fn new_module_cache(capacity: usize) -> Arc { + // 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, + + /// 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, + + /// Compiled-module cache. Shared across executions; invalidated + /// per-entry on `updated_at` mismatch (no explicit pub/sub). + cache: Arc, + + /// 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>, + + /// 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, + + /// 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, + cx: Arc, + cache: Arc, + 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 ""` + /// 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 { + 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 { + let ast = engine.compile(source).map_err(|e| e.to_string())?; + PicloudModuleResolver::check_module_shape(&ast, "")?; + 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 { + 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 { + 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> { + // 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>, + depth: &'r Mutex, + 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, 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) + } +} diff --git a/crates/executor-core/src/sandbox.rs b/crates/executor-core/src/sandbox.rs index 29f0e5c..5ce8061 100644 --- a/crates/executor-core/src/sandbox.rs +++ b/crates/executor-core/src/sandbox.rs @@ -24,6 +24,12 @@ pub struct Limits { /// Max call/expression nesting depth. pub max_call_levels: 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 { @@ -35,6 +41,7 @@ impl Default for Limits { max_map_size: 10_000, max_call_levels: 64, max_expr_depth: 64, + module_import_depth_max: 8, } } } @@ -65,6 +72,9 @@ impl Limits { max_expr_depth: overrides .max_expr_depth .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, } } } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index 42582c3..6f793fc 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -11,7 +11,8 @@ use chrono::Utc; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ 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 tokio::sync::Mutex; @@ -225,6 +226,7 @@ fn make_engine() -> Arc { Arc::new(InMemoryDocs::default()), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), + Arc::new(NoopModuleSource), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index df24c0a..03d5625 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ 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 tokio::sync::Mutex; @@ -104,6 +104,7 @@ fn make_engine() -> Arc { Arc::new(NoopDocsService), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), + Arc::new(NoopModuleSource), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/manager-core/migrations/0015_scripts_kind.sql b/crates/manager-core/migrations/0015_scripts_kind.sql new file mode 100644 index 0000000..ee8e579 --- /dev/null +++ b/crates/manager-core/migrations/0015_scripts_kind.sql @@ -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 ""` 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}$' + ); diff --git a/crates/manager-core/migrations/0016_script_imports.sql b/crates/manager-core/migrations/0016_script_imports.sql new file mode 100644 index 0000000..f6bf4ec --- /dev/null +++ b/crates/manager-core/migrations/0016_script_imports.sql @@ -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 ""` 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); diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index e79a798..b94d285 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -12,8 +12,8 @@ use axum::{ Extension, Json, Router, }; use picloud_shared::{ - AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator, - ValidationError, + AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox, + ScriptValidator, ValidatedScript, ValidationError, }; use serde::Deserialize; @@ -88,6 +88,11 @@ pub struct CreateScriptRequest { pub name: String, pub description: Option, 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, pub memory_limit_mb: Option, /// Sandbox overrides; absent or empty `{}` means "use platform @@ -120,6 +125,10 @@ pub struct UpdateScriptRequest { /// `Some(ScriptSandbox::empty())` to clear them). Absent leaves /// the stored value unchanged. pub sandbox: Option, + /// 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, } #[allow(clippy::option_option)] @@ -202,7 +211,20 @@ async fn create_script( Capability::AppWriteScript(input.app_id), ) .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)?; // Refuse early if the app_id doesn't exist — a clean 422 beats a // raw FK violation surfacing as 500. @@ -216,6 +238,7 @@ async fn create_script( name: input.name, description: input.description, source: input.source, + kind: input.kind, timeout_seconds: input.timeout_seconds, memory_limit_mb: input.memory_limit_mb, sandbox: if input.sandbox.is_empty() { @@ -223,11 +246,39 @@ async fn create_script( } else { Some(input.sandbox) }, + imports: validated.imports, }) .await?; 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( State(state): State>, Extension(principal): Extension, @@ -241,9 +292,44 @@ async fn update_script( Capability::AppWriteScript(script.app_id), ) .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> = 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() { state.sandbox_ceiling.check(sb)?; } @@ -258,6 +344,8 @@ async fn update_script( timeout_seconds: input.timeout_seconds, memory_limit_mb: input.memory_limit_mb, sandbox: input.sandbox, + kind: input.kind, + imports: imports_for_patch, }, ) .await?; diff --git a/crates/manager-core/src/app_bootstrap.rs b/crates/manager-core/src/app_bootstrap.rs index 8b11826..187c912 100644 --- a/crates/manager-core/src/app_bootstrap.rs +++ b/crates/manager-core/src/app_bootstrap.rs @@ -64,9 +64,11 @@ async fn seed_into( name: "hello".to_string(), description: Some("Reference example: returns a greeting at GET /hello.".to_string()), source: HELLO_RHAI_SOURCE.to_string(), + kind: picloud_shared::ScriptKind::Endpoint, timeout_seconds: Some(5), memory_limit_mb: None, sandbox: None, + imports: Vec::new(), }) .await?; diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 4a7c3bc..4da2cd8 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -34,6 +34,7 @@ pub mod kv_repo; pub mod kv_service; pub mod log_sink; pub mod migrations; +pub mod module_source; pub mod outbox_event_emitter; pub mod outbox_repo; 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_service::KvServiceImpl; pub use log_sink::PostgresExecutionLogSink; +pub use module_source::PostgresModuleSource; pub use outbox_event_emitter::OutboxEventEmitter; pub use outbox_repo::{ NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo, diff --git a/crates/manager-core/src/module_source.rs b/crates/manager-core/src/module_source.rs new file mode 100644 index 0000000..34e1caf --- /dev/null +++ b/crates/manager-core/src/module_source.rs @@ -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, +} + +impl From 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, 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 = 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)) + } +} diff --git a/crates/manager-core/src/repo.rs b/crates/manager-core/src/repo.rs index a699ddc..821c1b3 100644 --- a/crates/manager-core/src/repo.rs +++ b/crates/manager-core/src/repo.rs @@ -3,7 +3,8 @@ use std::collections::BTreeMap; use async_trait::async_trait; use picloud_orchestrator_core::{ResolverError, ScriptResolver}; use picloud_shared::{ - AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox, + AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptKind, + ScriptSandbox, }; use sqlx::PgPool; @@ -42,6 +43,29 @@ pub trait ScriptRepository: Send + Sync { patch: ScriptPatch, ) -> Result; 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; + + /// 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; + + /// 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, ScriptRepositoryError>; } /// Inbound shape for create. Defaults match the migration's CHECK @@ -52,11 +76,19 @@ pub struct NewScript { pub name: String, pub description: Option, 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, pub memory_limit_mb: Option, /// Sandbox overrides; `None` means store an empty object (use /// platform defaults at exec time). pub sandbox: Option, + /// v1.1.3: literal-path `import ""` 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, } /// 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(empty)` to clear them); `None` leaves them untouched. pub sandbox: Option, + /// `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, + /// 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>, } 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] impl ScriptRepository for PostgresScriptRepository { async fn get(&self, id: ScriptId) -> Result, ScriptRepositoryError> { - let row = sqlx::query_as::<_, ScriptRow>( - "SELECT id, app_id, name, description, version, source, \ - timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \ - FROM scripts WHERE id = $1", - ) + let row = sqlx::query_as::<_, ScriptRow>(&format!( + "SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE id = $1" + )) .bind(id.into_inner()) .fetch_optional(&self.pool) .await?; @@ -103,22 +148,18 @@ impl ScriptRepository for PostgresScriptRepository { } async fn list(&self) -> Result, ScriptRepositoryError> { - let rows = sqlx::query_as::<_, ScriptRow>( - "SELECT id, app_id, name, description, version, source, \ - timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \ - FROM scripts ORDER BY name", - ) + let rows = sqlx::query_as::<_, ScriptRow>(&format!( + "SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name" + )) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError> { - let rows = sqlx::query_as::<_, ScriptRow>( - "SELECT id, app_id, name, description, version, source, \ - timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \ - FROM scripts WHERE app_id = $1 ORDER BY name", - ) + let rows = sqlx::query_as::<_, ScriptRow>(&format!( + "SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE app_id = $1 ORDER BY name" + )) .bind(app_id.into_inner()) .fetch_all(&self.pool) .await?; @@ -129,14 +170,17 @@ impl ScriptRepository for PostgresScriptRepository { &self, user_id: AdminUserId, ) -> Result, ScriptRepositoryError> { - let rows = sqlx::query_as::<_, ScriptRow>( - "SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \ - s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \ - FROM scripts s \ + let cols = SCRIPT_SELECT_COLS + .split(", ") + .map(|c| format!("s.{c}")) + .collect::>() + .join(", "); + let rows = sqlx::query_as::<_, ScriptRow>(&format!( + "SELECT {cols} FROM scripts s \ JOIN app_members m ON m.app_id = s.app_id \ WHERE m.user_id = $1 \ - ORDER BY s.name", - ) + ORDER BY s.name" + )) .bind(user_id.into_inner()) .fetch_all(&self.pool) .await?; @@ -146,34 +190,42 @@ impl ScriptRepository for PostgresScriptRepository { async fn create(&self, input: NewScript) -> Result { let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default()) .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 ( \ - app_id, name, description, source, \ + app_id, name, description, source, kind, \ timeout_seconds, memory_limit_mb, sandbox \ - ) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \ - RETURNING id, app_id, name, description, version, source, \ - timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at", - ) + ) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \ + RETURNING {SCRIPT_SELECT_COLS}" + )) .bind(input.app_id.into_inner()) .bind(&input.name) .bind(input.description.as_deref()) .bind(&input.source) + .bind(input.kind.as_str()) .bind(input.timeout_seconds) .bind(input.memory_limit_mb) .bind(sandbox_json) - .fetch_one(&self.pool) + .fetch_one(&mut *tx) .await; - match res { - Ok(row) => Ok(row.into()), + let script: Script = match res { + Ok(row) => row.into(), 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", 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( @@ -192,7 +244,8 @@ impl ScriptRepository for PostgresScriptRepository { .sandbox .as_ref() .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 \ name = COALESCE($2, name), \ description = CASE WHEN $3::bool THEN $4 ELSE description END, \ @@ -200,12 +253,12 @@ impl ScriptRepository for PostgresScriptRepository { timeout_seconds = COALESCE($6, timeout_seconds), \ memory_limit_mb = COALESCE($7, memory_limit_mb), \ sandbox = COALESCE($8, sandbox), \ + kind = COALESCE($9, kind), \ version = version + 1, \ updated_at = NOW() \ WHERE id = $1 \ - RETURNING id, app_id, name, description, version, source, \ - timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at", - ) + RETURNING {SCRIPT_SELECT_COLS}" + )) .bind(id.into_inner()) .bind(patch.name.as_deref()) .bind(patch.description.is_some()) @@ -214,19 +267,30 @@ impl ScriptRepository for PostgresScriptRepository { .bind(patch.timeout_seconds) .bind(patch.memory_limit_mb) .bind(sandbox_json) - .fetch_optional(&self.pool) + .bind(patch.kind.map(|k| k.as_str())) + .fetch_optional(&mut *tx) .await; - match res { - Ok(Some(row)) => Ok(row.into()), - Ok(None) => Err(ScriptRepositoryError::NotFound(id)), + let script: Script = match res { + Ok(Some(row)) => row.into(), + Ok(None) => return Err(ScriptRepositoryError::NotFound(id)), 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(), - )) + )); } - 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> { @@ -239,6 +303,85 @@ impl ScriptRepository for PostgresScriptRepository { } Ok(()) } + + async fn count_routes_for_script( + &self, + script_id: ScriptId, + ) -> Result { + 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 { + 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, ScriptRepositoryError> { + let cols = SCRIPT_SELECT_COLS + .split(", ") + .map(|c| format!("s.{c}")) + .collect::>() + .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. @@ -250,6 +393,10 @@ struct ScriptRow { description: Option, version: i32, 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, memory_limit_mb: i32, sandbox: serde_json::Value, @@ -264,6 +411,10 @@ impl From for Script { // fall back to an empty ScriptSandbox rather than poisoning a // list response. 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 { id: r.id.into(), app_id: r.app_id.into(), @@ -271,6 +422,7 @@ impl From for Script { description: r.description, version: r.version, source: r.source, + kind, timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30), memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256), sandbox, diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 8bce40f..99dac87 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -120,12 +120,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { let abandoned_repo: Arc = Arc::new(PostgresAbandonedRepo::new(pool.clone())); let trigger_config = TriggerConfig::from_env(); - // SDK services bundle. v1.1.1 added KV + dead-letter; v1.1.2 adds - // the docs store. All four bound services share the - // outbox-backed event emitter so KV and docs mutations both fan - // out through the same dispatcher. + // SDK services bundle. v1.1.1 added KV + dead-letter; v1.1.2 added + // the docs store; v1.1.3 adds the module source backing the Rhai + // resolver. All bound services share the outbox-backed event + // emitter so KV and docs mutations both fan out through the same + // dispatcher. 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 = Arc::new(OutboxEventEmitter::new( trigger_repo.clone(), outbox_repo.clone(), @@ -142,7 +143,9 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { outbox_repo.clone(), authz.clone(), )); - let services = Services::new(kv, docs, dl_service.clone(), events); + let modules: Arc = + 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)); // 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> { self.0.delete(id).await } + async fn count_routes_for_script( + &self, + script_id: picloud_shared::ScriptId, + ) -> Result { + self.0.count_routes_for_script(script_id).await + } + async fn count_triggers_for_script( + &self, + script_id: picloud_shared::ScriptId, + ) -> Result { + self.0.count_triggers_for_script(script_id).await + } + async fn list_imports( + &self, + script_id: picloud_shared::ScriptId, + ) -> Result, picloud_manager_core::ScriptRepositoryError> { + self.0.list_imports(script_id).await + } } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index ef8b937..5c3f5c2 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -16,6 +16,7 @@ pub mod ids; pub mod inbox; pub mod kv; pub mod log_sink; +pub mod modules; pub mod outbox_writer; pub mod route; pub mod sandbox; @@ -40,12 +41,13 @@ pub use inbox::{ }; pub use kv::{KvError, KvListPage, KvService, NoopKvService}; pub use log_sink::{ExecutionLogSink, LogSinkError}; +pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource}; pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError}; pub use route::{DispatchMode, HostKind, PathKind, Route}; pub use sandbox::ScriptSandbox; -pub use script::Script; +pub use script::{Script, ScriptKind}; pub use sdk_cx::SdkCallCx; pub use services::Services; 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}; diff --git a/crates/shared/src/modules.rs b/crates/shared/src/modules.rs new file mode 100644 index 0000000..5e15fff --- /dev/null +++ b/crates/shared/src/modules.rs @@ -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 "" as ;` +//! 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, +} + +/// 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, 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, ModuleSourceError> { + Ok(None) + } +} diff --git a/crates/shared/src/script.rs b/crates/shared/src/script.rs index 60fbc05..1ea53b0 100644 --- a/crates/shared/src/script.rs +++ b/crates/shared/src/script.rs @@ -3,6 +3,52 @@ use serde::{Deserialize, Serialize}; 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 "" as ;` 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 { + match s { + "endpoint" => Some(Self::Endpoint), + "module" => Some(Self::Module), + _ => None, + } + } +} + /// A user-uploaded Rhai script and its execution configuration. /// /// This is the canonical representation that flows between manager (storage), @@ -20,6 +66,12 @@ pub struct Script { pub version: i32, 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, /// Per-script overrides for Rhai sandbox limits. Empty = platform diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 98636b9..0834767 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -7,9 +7,11 @@ //! handed to `executor-core::sdk::register_all` alongside an //! `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 -//! 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 //! consumers that only *pattern-match* a `&Services`; only crates that @@ -18,8 +20,8 @@ use std::sync::Arc; use crate::{ - DeadLetterService, DocsService, KvService, NoopDeadLetterService, NoopDocsService, - NoopEventEmitter, NoopKvService, ServiceEventEmitter, + DeadLetterService, DocsService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, + NoopEventEmitter, NoopKvService, NoopModuleSource, ServiceEventEmitter, }; /// 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 /// `NoopEventEmitter`. pub events: Arc, + + /// 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, } impl Services { @@ -57,12 +65,14 @@ impl Services { docs: Arc, dead_letters: Arc, events: Arc, + modules: Arc, ) -> Self { Self { kv, docs, dead_letters, events, + modules, } } @@ -78,6 +88,7 @@ impl Services { Arc::new(NoopDocsService), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), + Arc::new(NoopModuleSource), ) } } diff --git a/crates/shared/src/validator.rs b/crates/shared/src/validator.rs index 318469f..7548ded 100644 --- a/crates/shared/src/validator.rs +++ b/crates/shared/src/validator.rs @@ -10,8 +10,39 @@ use thiserror::Error; pub enum ValidationError { #[error("invalid script source: {0}")] 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 ""` 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, } 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; + + /// 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 { + Err(ValidationError::ModuleShape( + "module validation not implemented by this validator".into(), + )) + } } diff --git a/crates/shared/src/version.rs b/crates/shared/src/version.rs index 50d31cc..295eb98 100644 --- a/crates/shared/src/version.rs +++ b/crates/shared/src/version.rs @@ -27,7 +27,13 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// `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 /// handlers (carries `prev_data` change-data-capture for update/delete). -pub const SDK_VERSION: &str = "1.3"; +/// +/// 1.4 additions (v1.1.3): `import "" as ;` 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}/...`. /// Bump (new integer + new URL prefix) when the request/response