Skip to main content

cratefield_core/
module.rs

1//! The module contract (architecture section 4). A module is a crate that
2//! contributes one router under `/v1/<name>`, its migrations, its events and
3//! its scheduled work — and sees nothing but ports.
4
5use crate::config::{Config, ConfigError};
6use crate::events::{AnyError, EventBus, EventHandler, EventName};
7use crate::ports::{Port, Ports};
8use crate::surface::Surface;
9use crate::template::TemplateRegistry;
10use crate::venture::Venture;
11use std::sync::Arc;
12
13pub use futures_core::future::BoxFuture;
14
15/// Contract version shared by core and every module. `Harness::build`
16/// rejects modules whose `harness_api` differs. Bumped only on breaking
17/// contract changes; `cratefield-core`'s major follows it.
18pub const HARNESS_API: u32 = 1;
19
20/// The message `Harness::build` and `fz doctor` report for a module whose
21/// [`Module::harness_api`] differs from core's: it names the module, the
22/// module crate's version, the API it targets, and the `cratefield-core`
23/// crate with its version and API (issue #17).
24#[must_use]
25pub fn harness_api_mismatch(module: &dyn Module) -> String {
26    format!(
27        "module `{name}` v{version} targets harness API {api}, but {core} v{core_version} \
28         provides harness API {harness_api}: rebuild `{name}` against this core — the supported \
29         ranges are in docs/COMPATIBILITY.md",
30        name = module.name(),
31        version = module.version(),
32        api = module.harness_api(),
33        core = env!("CARGO_PKG_NAME"),
34        core_version = env!("CARGO_PKG_VERSION"),
35        harness_api = HARNESS_API,
36    )
37}
38
39/// One migration step, embedded with `include_str!` from
40/// `crates/<module>/migrations/<dialect>/NNNN_name.sql` (issue #8).
41#[derive(Debug, Clone)]
42pub struct SqlMigration {
43    /// Sortable id: `0001`, `0002`, ... — zero-padded so lexical order is
44    /// apply order.
45    pub id: &'static str,
46    /// Short slug from the file name (`0001_init.sql` -> `init`), used in
47    /// the wrangler-facing collected file names.
48    pub name: &'static str,
49    pub sql: &'static str,
50}
51
52/// The sha256 of a migration's SQL, lowercase hex. Recorded in
53/// `harness_migrations` when the migration is applied, so a later run
54/// can tell "already applied" from "applied, then edited" — the rule
55/// forward-only migrations rest on, enforced by the database rather
56/// than by a lockfile in one repository (issues #28, #34).
57///
58/// The lockfile's hash covers the collected *file*; this covers the
59/// embedded SQL. They answer different questions and are not compared.
60#[must_use]
61pub fn migration_checksum(sql: &str) -> String {
62    use sha2::{Digest, Sha256};
63    let digest = Sha256::digest(sql.as_bytes());
64    let mut hex = String::with_capacity(64);
65    for byte in digest {
66        use std::fmt::Write as _;
67        let _ = write!(hex, "{byte:02x}");
68    }
69    hex
70}
71
72/// The message a migration whose recorded checksum no longer matches
73/// gets. Shared so both engines say the same thing.
74#[must_use]
75pub fn migration_edited(key: &str, recorded: &str, found: &str) -> String {
76    format!(
77        "migration `{key}` was already applied to this database with different SQL \
78         (recorded sha256 {}…, embedded {}…). Applied migrations are never edited: \
79         revert the change and write a new migration instead \
80         (docs/MODULE-AUTHORING.md, step 3)",
81        &recorded[..recorded.len().min(12)],
82        &found[..found.len().min(12)]
83    )
84}
85
86/// The module's migrations, per dialect. `postgres` differs from `sqlite`
87/// only where the SQL truly differs (ADR 0004).
88#[derive(Debug, Clone)]
89pub struct Migrations {
90    pub sqlite: &'static [SqlMigration],
91    pub postgres: &'static [SqlMigration],
92}
93
94impl Migrations {
95    pub const EMPTY: Migrations = Migrations {
96        sqlite: &[],
97        postgres: &[],
98    };
99
100    pub const fn sqlite(migrations: &'static [SqlMigration]) -> Self {
101        Migrations {
102            sqlite: migrations,
103            postgres: &[],
104        }
105    }
106}
107
108impl Default for Migrations {
109    fn default() -> Self {
110        Self::EMPTY
111    }
112}
113
114/// Everything a module's router needs: its declared ports, the typed
115/// config, the bus, the templates and the venture identity.
116pub struct ModuleContext {
117    /// Only the ports the module declared in `requires()`/`optional()`.
118    pub ports: Ports,
119    /// Full config; module keys are prefixed (`EMAIL_SIGNUP_CONFIRM_TTL_DAYS`).
120    pub config: Arc<dyn Config>,
121    pub events: EventBus,
122    pub templates: Arc<TemplateRegistry>,
123    pub venture: Arc<Venture>,
124    /// `true` when the venture mounted a UI renderer (ADR 0010). A module
125    /// then defaults its landing redirects (confirmed, expired,
126    /// unsubscribed, status) to `<api base>/ui/<module>/<action>/<page>`
127    /// instead of pages the venture site has to provide.
128    pub ui_mounted: bool,
129}
130
131/// A Factory Zero module. Object-safe; composed as `Arc<dyn Module>`.
132///
133/// Handlers get the request scope as an axum extractor:
134/// `async fn join(scope: Scope, State(ctx): State<Arc<ModuleContext>>, ...)`.
135/// There is no ambient "current request" (ADR 0007).
136pub trait Module: Send + Sync + 'static {
137    /// Kebab-case name; mounted at `/v1/<name>`.
138    fn name(&self) -> &'static str;
139    /// `env!("CARGO_PKG_VERSION")`, surfaced by `/__health`.
140    fn version(&self) -> &'static str;
141    /// Contract version, checked by `Harness::build`.
142    fn harness_api(&self) -> u32 {
143        HARNESS_API
144    }
145    /// Ports the module cannot run without; missing = build error.
146    fn requires(&self) -> &'static [Port];
147    /// Ports the module uses when present.
148    fn optional(&self) -> &'static [Port] {
149        &[]
150    }
151    /// Table names this module owns; duplicates across modules are a build
152    /// error.
153    fn tables(&self) -> &'static [&'static str] {
154        &[]
155    }
156    /// Event names this module emits (`"<module>.<event>"`), listed by
157    /// `/__health`.
158    fn emits(&self) -> &'static [&'static str] {
159        &[]
160    }
161    /// Whether the module has public write endpoints; drives the
162    /// production-captcha rule (section 11).
163    fn public_writes(&self) -> bool {
164        false
165    }
166    /// The module's migrations, embedded per dialect.
167    fn migrations(&self) -> Migrations;
168    /// Rejects invalid configuration: missing required keys or malformed
169    /// values, reported together with the module name.
170    ///
171    /// **When it runs.** The conformance kit calls it, and a module's own
172    /// tests should. It cannot run at `Harness::build` or in `fz doctor`,
173    /// because neither has the deploy config — the values live on the runtime
174    /// `Env` and only exist per request. The Cloudflare runtime therefore runs
175    /// it **once at cold start** and logs any failure loudly (`console_error!`,
176    /// so it reaches Workers Logs); it does not fail the boot, so a
177    /// misconfigured module still degrades per request (issue #101) rather
178    /// than taking the whole Worker down. Turning that into a hard boot
179    /// failure is a deployment decision for an ADR.
180    ///
181    /// # Errors
182    ///
183    /// `Err` listing every invalid or missing key for this module.
184    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError>;
185    /// The module's router, nested under `/v1/<name>`.
186    fn router(&self, ctx: ModuleContext) -> axum::Router;
187    /// Routes this module serves at the root under `/.well-known`, for
188    /// spec-mandated discovery documents (OIDC `openid-configuration`,
189    /// `jwks.json`) that must live outside `/v1` (issue #46). Paths are
190    /// relative to the prefix: register `/jwks.json`, not
191    /// `/.well-known/jwks.json`.
192    ///
193    /// At most one module may provide one: discovery URLs are a singleton
194    /// namespace, so `Harness::build` fails (naming every provider) when
195    /// two modules return a router here. `None` by default.
196    fn well_known(&self) -> Option<axum::Router> {
197        None
198    }
199    /// The module's UI surface (ADR 0010): the actions a renderer may
200    /// offer and the views that compose them. Input schemas come from
201    /// the handler's own body types (`Action::input::<Body>()`), so the
202    /// declaration cannot drift from the route. `Harness::build`
203    /// validates it; `GET /__surface` serves the composition. Default:
204    /// nothing, and a module that declares nothing renders nothing.
205    fn surface(&self) -> Surface {
206        Surface::none()
207    }
208    /// Handlers for events other modules emit; registered at
209    /// `Harness::build`.
210    fn events(&self) -> Vec<(EventName, EventHandler)> {
211        Vec::new()
212    }
213    /// Scheduled work (`cron` is the trigger expression). Default: none.
214    fn scheduled<'a>(
215        &'a self,
216        ctx: &'a ModuleContext,
217        cron: &'a str,
218    ) -> BoxFuture<'a, Result<(), AnyError>> {
219        let _ = (ctx, cron);
220        Box::pin(async { Ok(()) })
221    }
222}