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:
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user