Skip to main content

cufflink_types/manifest/
service.rs

1use super::{
2    AuthorizationConfig, CustomRouteDefinition, HandlerType, MigrationHistory, ScheduleDefinition,
3    SqlMigration, StreamDefinition, SubscriptionDefinition, TableDefinition,
4};
5use crate::features;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10/// A service manifest describes the complete definition of a cufflink service.
11/// This is generated by the SDK macros from the user's Rust struct definitions.
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13pub struct ServiceManifest {
14    pub name: String,
15    pub version: Option<String>,
16    pub tables: Vec<TableDefinition>,
17    #[serde(default)]
18    pub cells: Vec<CellDefinition>,
19    #[serde(default)]
20    pub events: Vec<EventDefinition>,
21    #[serde(default)]
22    pub subscriptions: Vec<SubscriptionDefinition>,
23    #[serde(default)]
24    pub custom_routes: Vec<CustomRouteDefinition>,
25    #[serde(default)]
26    pub mode: ServiceMode,
27    /// Authorization configuration (permission areas and default roles).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub authorization: Option<AuthorizationConfig>,
30    /// Optional WASM handler invoked between additive and destructive schema
31    /// phases of a deploy. Used to backfill or restructure data when columns
32    /// are split, renamed, or have their semantics changed. The handler must
33    /// be idempotent — it may be re-invoked on retried deploys.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub on_migrate: Option<String>,
36    /// NATS subject patterns (with `*`/`>` wildcards) this service is allowed to
37    /// publish or request outside its own `dw.{tenant}.*` namespace. `None` marks
38    /// a manifest built before publish authorization existed: the platform then
39    /// runs in legacy mode (allow + warn). `Some` — always emitted by the current
40    /// SDK — enables enforcement.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub publishes: Option<Vec<String>>,
43    /// Extensions the service's handlers may call through `ext_call`.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub extensions: Vec<String>,
46    /// Extensions a native service serves, and who may call each.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub provides: Vec<ProvidedExtension>,
49    /// Platform capabilities this service requires to run safely. The platform
50    /// rejects a deploy whose required features it does not advertise at
51    /// `/api/version`. The SDK auto-derives this (e.g. `route_posture` when any
52    /// route declares a posture) so a security-relevant manifest cannot be
53    /// silently deployed to a platform too old to enforce it.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub required_platform_features: Vec<String>,
56    /// Serve the auto-generated GraphQL endpoint. Off unless the service
57    /// opts in.
58    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
59    pub graphql: bool,
60    /// How custom routes are reached: `legacy` serves each only by handler
61    /// name under `_fn/`; `strict` also matches their declared paths.
62    #[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
63    pub routing: RoutingMode,
64    /// The JSON key the platform's own error responses for this service
65    /// carry their message under, instead of `error`.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub errors: Option<ErrorProfile>,
68    /// The service's OpenAPI document settings; its presence (or any
69    /// documented route) makes the spec server-relative.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub openapi: Option<OpenApiOptions>,
72    /// Shared JSON Schemas the route docs refer to as
73    /// `#/components/schemas/{name}`.
74    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75    pub components: BTreeMap<String, Value>,
76    /// How the durable consumers of the service's subscriptions are named.
77    #[serde(default, skip_serializing_if = "ConsumerNaming::is_legacy")]
78    pub consumers: ConsumerNaming,
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub streams: Vec<StreamDefinition>,
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub schedules: Vec<ScheduleDefinition>,
83    /// Who owns the DDL of the service's schema.
84    #[serde(default, skip_serializing_if = "SchemaOwnership::is_managed")]
85    pub schema: SchemaOwnership,
86    /// The service's Postgres schema; derived from its name when unset.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub schema_name: Option<String>,
89    /// The service's SQL migrations, in version order.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub migrations: Vec<SqlMigration>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub migration_history: Option<MigrationHistory>,
94}
95
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum ConsumerNaming {
99    /// `{service}_{handler}`.
100    #[default]
101    Legacy,
102    /// Named for the tenant, the service, the handler and what it consumes.
103    Scoped,
104}
105
106impl ConsumerNaming {
107    pub fn is_legacy(&self) -> bool {
108        *self == ConsumerNaming::Legacy
109    }
110}
111
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum SchemaOwnership {
115    /// The platform creates and alters the schema from the manifest.
116    #[default]
117    Managed,
118    /// The schema exists already; the platform never emits DDL for it.
119    Adopted,
120}
121
122impl SchemaOwnership {
123    pub fn is_managed(&self) -> bool {
124        *self == SchemaOwnership::Managed
125    }
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
129pub struct OpenApiOptions {
130    /// Defaults to the service name.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub title: Option<String>,
133    /// Defaults to the manifest version.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub version: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub description: Option<String>,
138    /// Defaults to the service's own `/svc/{tenant}/{service}` base.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub servers: Vec<ServerDoc>,
141    /// The document's tag list.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub tags: Vec<TagDoc>,
144    /// The HTTP bearer scheme authenticated routes name; `bearer_auth`
145    /// when unset.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub security_scheme: Option<String>,
148}
149
150impl OpenApiOptions {
151    pub const DEFAULT_SECURITY_SCHEME: &'static str = "bearer_auth";
152
153    pub fn security_scheme(&self) -> &str {
154        self.security_scheme
155            .as_deref()
156            .unwrap_or(Self::DEFAULT_SECURITY_SCHEME)
157    }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ServerDoc {
162    pub url: String,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub description: Option<String>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct TagDoc {
169    pub name: String,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub description: Option<String>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct ErrorProfile {
176    pub key: String,
177}
178
179impl ErrorProfile {
180    /// Whether `key` is a valid error key: `[a-z_][a-z0-9_]{0,63}`.
181    pub fn is_valid_key(key: &str) -> bool {
182        let mut bytes = key.bytes();
183        key.len() <= 64
184            && bytes
185                .next()
186                .is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
187            && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
188    }
189}
190
191#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum RoutingMode {
194    #[default]
195    Legacy,
196    Strict,
197}
198
199impl RoutingMode {
200    pub fn is_legacy(&self) -> bool {
201        *self == RoutingMode::Legacy
202    }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
206#[serde(rename_all = "lowercase")]
207pub enum ServiceMode {
208    #[default]
209    Crud,
210    Wasm,
211    Container,
212    Web,
213    /// A Rust binary supervised by the extension runner that serves the
214    /// extensions it `provides`.
215    Native,
216}
217
218impl ServiceMode {
219    /// The mode as a manifest names it.
220    pub fn as_str(&self) -> &'static str {
221        match self {
222            ServiceMode::Crud => "crud",
223            ServiceMode::Wasm => "wasm",
224            ServiceMode::Container => "container",
225            ServiceMode::Web => "web",
226            ServiceMode::Native => "native",
227        }
228    }
229
230    /// Whether the platform runs the service itself: its schema, module,
231    /// database role, subscriptions and schedules. A web app and a native
232    /// service run in a runner of their own and have none of them.
233    pub fn runs_on_platform(&self) -> bool {
234        match self {
235            ServiceMode::Crud | ServiceMode::Wasm | ServiceMode::Container => true,
236            ServiceMode::Web | ServiceMode::Native => false,
237        }
238    }
239}
240
241/// An extension a native service serves.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct ProvidedExtension {
244    pub name: String,
245    /// Service names, or [`ANY_CALLER`](crate::ext::ANY_CALLER).
246    pub callers: Vec<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct CellDefinition {
251    pub name: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct EventDefinition {
256    pub name: String,
257    pub table: String,
258    pub action: EventAction,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(rename_all = "lowercase")]
263pub enum EventAction {
264    Create,
265    Update,
266    Delete,
267}
268
269impl ServiceManifest {
270    /// Get table definition by name
271    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
272        self.tables.iter().find(|t| t.name == name)
273    }
274
275    /// Whether this manifest needs a WASM artifact bundled at deploy time.
276    ///
277    /// A WASM module is required when the service runs user code: explicit
278    /// `mode: wasm`, an `on_migrate` schema hook, table CRUD lifecycle hooks,
279    /// a schedule, or any `Wasm` subscription/custom-route handler. The CLI uses this to
280    /// decide whether to compile a WASM artifact; the platform uses it to
281    /// reject deploys that arrive without one.
282    pub fn requires_wasm(&self) -> bool {
283        match self.mode {
284            ServiceMode::Wasm => true,
285            ServiceMode::Native => false,
286            ServiceMode::Crud | ServiceMode::Container | ServiceMode::Web => {
287                self.on_migrate.is_some()
288                    || self.tables.iter().any(|t| t.hooks.is_some())
289                    || !self.custom_routes.is_empty()
290                    || !self.schedules.is_empty()
291                    || self
292                        .subscriptions
293                        .iter()
294                        .any(|s| matches!(s.handler_type, HandlerType::Wasm))
295            }
296        }
297    }
298
299    /// All handler names this manifest expects the WASM module to export.
300    /// Used at deploy time to reject manifests whose declared handlers are
301    /// missing from the bundled binary.
302    pub fn declared_wasm_handlers(&self) -> Vec<&str> {
303        let mut handlers: Vec<&str> = Vec::new();
304        if let Some(ref h) = self.on_migrate {
305            handlers.push(h.as_str());
306        }
307        for route in &self.custom_routes {
308            handlers.push(route.handler.as_str());
309            if let Some(reducer) = route.sse.as_ref().and_then(|sse| sse.reducer.as_deref()) {
310                handlers.push(reducer);
311            }
312        }
313        for sub in &self.subscriptions {
314            if matches!(sub.handler_type, HandlerType::Wasm) {
315                handlers.push(sub.handler.as_str());
316            }
317        }
318        handlers.extend(self.schedules.iter().map(|s| s.handler.as_str()));
319        for table in &self.tables {
320            if let Some(ref hooks) = table.hooks {
321                for h in [
322                    hooks.before_create.as_deref(),
323                    hooks.after_create.as_deref(),
324                    hooks.before_update.as_deref(),
325                    hooks.after_update.as_deref(),
326                    hooks.before_delete.as_deref(),
327                    hooks.after_delete.as_deref(),
328                ]
329                .iter()
330                .flatten()
331                {
332                    handlers.push(h);
333                }
334            }
335        }
336        handlers.sort();
337        handlers.dedup();
338        handlers
339    }
340
341    /// The `custom_routes` entry exposing `handler` over the HTTP `_fn/`
342    /// surface, if any. Only custom routes are HTTP-invocable; subscription,
343    /// `on_migrate`, and table-hook handlers run solely via their internal
344    /// triggers, so a handler absent here must not be dispatched from an
345    /// inbound request. Matched by name alone — the `_fn/` route accepts every
346    /// method, so a verb-aware match would demote a job handler to inline
347    /// execution on the wrong verb.
348    pub fn http_route(&self, handler: &str) -> Option<&CustomRouteDefinition> {
349        self.http_route_index(handler)
350            .and_then(|index| self.custom_routes.get(index))
351    }
352
353    /// The position of [`http_route`](Self::http_route) in `custom_routes`.
354    pub fn http_route_index(&self, handler: &str) -> Option<usize> {
355        self.custom_routes.iter().position(|r| r.handler == handler)
356    }
357
358    /// Whether this manifest was built by a posture-aware SDK. True when any
359    /// custom route carries a posture — the current SDK emits a posture on
360    /// every route, so a posture-aware manifest is all-or-nothing and a lone
361    /// undeclared route among declared ones signals tampering or a bug.
362    pub fn is_posture_aware(&self) -> bool {
363        self.custom_routes.iter().any(|r| r.posture.is_some())
364    }
365
366    /// The service's Postgres schema: `schema_name`, or the service name
367    /// with `-` as `_`. Every other derivation of it is a bug.
368    pub fn schema(&self) -> String {
369        service_schema(&self.name, self.schema_name.as_deref())
370    }
371
372    /// Where the service's applied SQL migrations are recorded.
373    pub fn migration_history(&self) -> MigrationHistory {
374        self.migration_history.clone().unwrap_or_default()
375    }
376
377    /// The manifest without the text of its SQL migrations, which only a
378    /// deploy reads.
379    pub fn without_migration_bodies(mut self) -> Self {
380        self.migrations
381            .iter_mut()
382            .for_each(|migration| migration.sql = String::new());
383        self
384    }
385
386    /// Platform features required by service-level fields.
387    pub(crate) fn service_features(&self) -> impl Iterator<Item = &'static str> {
388        let adopted = !self.schema.is_managed() || self.schema_name.is_some();
389        [
390            (self.routing == RoutingMode::Strict).then_some(features::ROUTE_MATCHING),
391            self.errors.is_some().then_some(features::ERROR_PROFILE),
392            (!self.consumers.is_legacy()).then_some(features::NATS_CONSUMER_CONFIG),
393            (!self.streams.is_empty()).then_some(features::STREAMS),
394            (!self.schedules.is_empty()).then_some(features::SCHEDULES),
395            adopted.then_some(features::SCHEMA_ADOPTED),
396            (!self.migrations.is_empty()).then_some(features::SQL_MIGRATIONS),
397            (!self.extensions.is_empty()).then_some(features::EXT_CALL),
398            (self.mode == ServiceMode::Native).then_some(features::NATIVE_SERVICES),
399        ]
400        .into_iter()
401        .flatten()
402    }
403
404    /// SHA-256 of the whole manifest: the deploy skip check compares this, so
405    /// a change to any field (`publishes`, `events`, `mode`, …) deploys.
406    pub fn deploy_hash(&self) -> serde_json::Result<String> {
407        use sha2::{Digest, Sha256};
408        serde_json::to_vec(self).map(|bytes| hex::encode(Sha256::digest(bytes)))
409    }
410
411    /// SHA-256 of the schema-bearing fields, stored on schema snapshots. A
412    /// field added since contributes only when it is set, so the hash of a
413    /// manifest without it never changes.
414    pub fn schema_hash(&self) -> serde_json::Result<String> {
415        use sha2::{Digest, Sha256};
416        let ownership = [
417            (!self.schema.is_managed())
418                .then(|| serde_json::to_string(&self.schema))
419                .transpose()?,
420            self.schema_name
421                .as_ref()
422                .map(serde_json::to_string)
423                .transpose()?,
424            (!self.migrations.is_empty())
425                .then(|| serde_json::to_string(&self.migrations))
426                .transpose()?,
427            self.migration_history
428                .as_ref()
429                .map(serde_json::to_string)
430                .transpose()?,
431        ];
432        let digest = [
433            serde_json::to_string(&self.tables)?,
434            serde_json::to_string(&self.custom_routes)?,
435            serde_json::to_string(&self.subscriptions)?,
436            serde_json::to_string(&self.authorization)?,
437            serde_json::to_string(&self.on_migrate)?,
438        ]
439        .into_iter()
440        .chain(ownership.into_iter().flatten())
441        .fold(Sha256::new(), |hasher, part| hasher.chain_update(part))
442        .finalize();
443        Ok(hex::encode(digest))
444    }
445}
446
447/// The Postgres schema of the service `name`: the `schema_name` it
448/// declares, or its name with `-` as `_`.
449pub fn service_schema(name: &str, schema_name: Option<&str>) -> String {
450    schema_name.map_or_else(|| name.replace('-', "_"), str::to_string)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::manifest::fixtures::sample_manifest;
457    use crate::manifest::{
458        ColumnDefinition, ColumnType, ForeignKeyAction, JobConfig, RoutePosture, TableHooks,
459    };
460
461    #[test]
462    fn test_manifest_serialization_roundtrip() {
463        let manifest = sample_manifest();
464        let json = serde_json::to_string(&manifest).unwrap();
465        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
466        assert_eq!(parsed.name, "test-service");
467        assert_eq!(parsed.tables.len(), 1);
468        assert_eq!(parsed.tables[0].columns.len(), 3);
469    }
470
471    #[test]
472    fn routing_is_serialized_only_when_strict() {
473        let mut manifest = sample_manifest();
474        let json = serde_json::to_value(&manifest).unwrap();
475        assert!(json.get("routing").is_none(), "{json}");
476        manifest.routing = RoutingMode::Strict;
477        let json = serde_json::to_value(&manifest).unwrap();
478        assert_eq!(json["routing"], "strict");
479        let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
480        assert_eq!(parsed.routing, RoutingMode::Strict);
481    }
482
483    #[test]
484    fn error_profile_keys_are_snake_case_identifiers() {
485        for key in ["error_message", "_e", "e1", &"e".repeat(64)] {
486            assert!(ErrorProfile::is_valid_key(key), "{key}");
487        }
488        for key in [
489            "",
490            "1e",
491            "Error",
492            "error-message",
493            "error message",
494            &"e".repeat(65),
495        ] {
496            assert!(!ErrorProfile::is_valid_key(key), "{key}");
497        }
498        let mut manifest = sample_manifest();
499        assert!(serde_json::to_value(&manifest)
500            .unwrap()
501            .get("errors")
502            .is_none());
503        manifest.errors = Some(ErrorProfile {
504            key: "error_message".into(),
505        });
506        assert_eq!(
507            serde_json::to_value(&manifest).unwrap()["errors"],
508            serde_json::json!({"key": "error_message"})
509        );
510    }
511
512    #[test]
513    fn test_get_table() {
514        let manifest = sample_manifest();
515        assert!(manifest.get_table("todos").is_some());
516        assert!(manifest.get_table("nonexistent").is_none());
517    }
518
519    #[test]
520    fn test_schema_hash_deterministic() {
521        let m1 = sample_manifest();
522        let m2 = sample_manifest();
523        assert_eq!(
524            m1.schema_hash().expect("schema hash"),
525            m2.schema_hash().expect("schema hash")
526        );
527    }
528
529    #[test]
530    fn test_schema_hash_changes() {
531        let mut m1 = sample_manifest();
532        let m2 = sample_manifest();
533        m1.tables[0].columns.push(ColumnDefinition {
534            name: "extra".into(),
535            column_type: ColumnType::Text,
536            primary_key: false,
537            nullable: true,
538            auto_generate: false,
539            default_value: None,
540            references: None,
541            on_delete: None,
542            unique: false,
543            validations: vec![],
544        });
545        assert_ne!(
546            m1.schema_hash().expect("schema hash"),
547            m2.schema_hash().expect("schema hash")
548        );
549    }
550
551    #[test]
552    fn test_fk_serialization() {
553        let manifest = sample_manifest();
554        let json = serde_json::to_string(&manifest).unwrap();
555        assert!(json.contains("\"references\":\"users.id\""));
556        assert!(json.contains("\"on_delete\":\"cascade\""));
557
558        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
559        let user_id_col = &parsed.tables[0].columns[2];
560        assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
561        assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
562    }
563
564    #[test]
565    fn test_service_mode_default() {
566        let json = r#"{"name":"svc","tables":[]}"#;
567        let m: ServiceManifest = serde_json::from_str(json).unwrap();
568        assert_eq!(m.mode, ServiceMode::Crud);
569    }
570
571    #[test]
572    fn test_on_migrate_defaults_to_none() {
573        let json = r#"{"name":"svc","tables":[]}"#;
574        let m: ServiceManifest = serde_json::from_str(json).unwrap();
575        assert!(m.on_migrate.is_none());
576    }
577
578    #[test]
579    fn test_on_migrate_serialization_roundtrip() {
580        let mut m = sample_manifest();
581        m.on_migrate = Some("handle_on_migrate".into());
582        let json = serde_json::to_string(&m).unwrap();
583        assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
584        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
585        assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
586    }
587
588    #[test]
589    fn test_on_migrate_skipped_when_none() {
590        let m = sample_manifest();
591        let json = serde_json::to_string(&m).unwrap();
592        assert!(!json.contains("on_migrate"));
593    }
594
595    #[test]
596    fn deploy_hash_covers_fields_schema_hash_ignores() {
597        let m1 = sample_manifest();
598        let mut m2 = sample_manifest();
599        let hash = |m: &ServiceManifest| m.deploy_hash().expect("hash");
600        assert_eq!(hash(&m1), hash(&m2));
601        m2.publishes = Some(vec!["acme.orders.>".into()]);
602        assert_ne!(hash(&m1), hash(&m2));
603        assert_eq!(
604            m1.schema_hash().expect("schema hash"),
605            m2.schema_hash().expect("schema hash")
606        );
607    }
608
609    #[test]
610    fn test_on_migrate_changes_schema_hash() {
611        let m1 = sample_manifest();
612        let mut m2 = sample_manifest();
613        m2.on_migrate = Some("handle_on_migrate".into());
614        assert_ne!(
615            m1.schema_hash().expect("schema hash"),
616            m2.schema_hash().expect("schema hash")
617        );
618    }
619
620    #[test]
621    fn test_requires_wasm_false_for_plain_crud() {
622        let m = sample_manifest();
623        assert!(!m.requires_wasm());
624    }
625
626    #[test]
627    fn test_requires_wasm_true_for_wasm_mode() {
628        let mut m = sample_manifest();
629        m.mode = ServiceMode::Wasm;
630        assert!(m.requires_wasm());
631    }
632
633    #[test]
634    fn a_mode_is_named_as_a_manifest_names_it() {
635        for (mode, on_platform) in [
636            (ServiceMode::Crud, true),
637            (ServiceMode::Wasm, true),
638            (ServiceMode::Container, true),
639            (ServiceMode::Web, false),
640            (ServiceMode::Native, false),
641        ] {
642            assert_eq!(serde_json::to_value(&mode).unwrap(), mode.as_str());
643            assert_eq!(mode.runs_on_platform(), on_platform, "{}", mode.as_str());
644        }
645    }
646
647    #[test]
648    fn a_native_service_runs_no_wasm_and_demands_native_services() {
649        let mut m = sample_manifest();
650        m.on_migrate = Some("handle_on_migrate".into());
651        m.mode = ServiceMode::Native;
652        assert!(!m.requires_wasm());
653        assert!(m.derived_features().contains(features::NATIVE_SERVICES));
654        let json = serde_json::to_value(&m).unwrap();
655        assert_eq!(json["mode"], "native");
656        let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
657        assert_eq!(parsed.mode, ServiceMode::Native);
658    }
659
660    #[test]
661    fn test_requires_wasm_true_for_on_migrate() {
662        let mut m = sample_manifest();
663        m.on_migrate = Some("handle_on_migrate".into());
664        assert!(m.requires_wasm());
665    }
666
667    #[test]
668    fn test_requires_wasm_true_for_table_hooks() {
669        let mut m = sample_manifest();
670        m.tables[0].hooks = Some(TableHooks {
671            before_create: Some("validate".into()),
672            ..Default::default()
673        });
674        assert!(m.requires_wasm());
675    }
676
677    #[test]
678    fn test_requires_wasm_true_for_custom_routes() {
679        let mut m = sample_manifest();
680        m.custom_routes.push(CustomRouteDefinition {
681            method: "GET".into(),
682            path: "/hello".into(),
683            handler: "hello".into(),
684            job: None,
685            posture: None,
686            forward_auth: false,
687            ..Default::default()
688        });
689        assert!(m.requires_wasm());
690    }
691
692    #[test]
693    fn test_requires_wasm_true_for_wasm_subscription() {
694        let mut m = sample_manifest();
695        m.subscriptions.push(SubscriptionDefinition::new(
696            "events.foo",
697            "on_foo",
698            HandlerType::Wasm,
699        ));
700        assert!(m.requires_wasm());
701    }
702
703    #[test]
704    fn test_requires_wasm_false_for_non_wasm_subscription() {
705        let mut m = sample_manifest();
706        m.subscriptions.push(SubscriptionDefinition::new(
707            "events.foo",
708            "on_foo",
709            HandlerType::Webhook,
710        ));
711        assert!(!m.requires_wasm());
712    }
713
714    #[test]
715    fn test_declared_wasm_handlers_collects_all_sources() {
716        let mut m = sample_manifest();
717        m.on_migrate = Some("on_mig".into());
718        m.custom_routes.push(CustomRouteDefinition {
719            method: "GET".into(),
720            path: "/h".into(),
721            handler: "route_h".into(),
722            job: None,
723            posture: None,
724            forward_auth: false,
725            ..Default::default()
726        });
727        m.subscriptions
728            .push(SubscriptionDefinition::new("x", "sub_h", HandlerType::Wasm));
729        m.subscriptions.push(SubscriptionDefinition::new(
730            "y",
731            "wh_ignored",
732            HandlerType::Webhook,
733        ));
734        m.tables[0].hooks = Some(TableHooks {
735            before_create: Some("bc".into()),
736            after_create: Some("ac".into()),
737            before_delete: Some("bc".into()), // duplicate handler name
738            ..Default::default()
739        });
740        let handlers = m.declared_wasm_handlers();
741        assert!(handlers.contains(&"on_mig"));
742        assert!(handlers.contains(&"route_h"));
743        assert!(handlers.contains(&"sub_h"));
744        assert!(handlers.contains(&"bc"));
745        assert!(handlers.contains(&"ac"));
746        assert!(!handlers.contains(&"wh_ignored"));
747        // Deduplicated
748        assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
749    }
750
751    #[test]
752    fn http_route_exposes_only_custom_routes() {
753        let mut m = sample_manifest();
754        m.on_migrate = Some("on_mig".into());
755        m.custom_routes.push(CustomRouteDefinition {
756            method: "GET".into(),
757            path: "/h".into(),
758            handler: "route_h".into(),
759            job: None,
760            posture: None,
761            forward_auth: false,
762            ..Default::default()
763        });
764        m.subscriptions.push(SubscriptionDefinition::new(
765            "evt.sub",
766            "sub_h",
767            HandlerType::Wasm,
768        ));
769        m.tables[0].hooks = Some(TableHooks {
770            after_create: Some("hook_h".into()),
771            ..Default::default()
772        });
773
774        assert_eq!(
775            m.http_route("route_h").map(|r| r.handler.as_str()),
776            Some("route_h")
777        );
778        assert!(m.http_route("sub_h").is_none());
779        assert!(m.http_route("on_mig").is_none());
780        assert!(m.http_route("hook_h").is_none());
781        assert!(m.http_route("unknown").is_none());
782    }
783
784    #[test]
785    fn http_route_allows_handler_declared_as_both_route_and_subscription() {
786        let mut m = sample_manifest();
787        m.custom_routes.push(CustomRouteDefinition {
788            method: "POST".into(),
789            path: "/dual".into(),
790            handler: "dual".into(),
791            job: Some(JobConfig::default()),
792            posture: None,
793            forward_auth: false,
794            ..Default::default()
795        });
796        m.subscriptions.push(SubscriptionDefinition::new(
797            "evt.dual",
798            "dual",
799            HandlerType::Wasm,
800        ));
801
802        let route = m
803            .http_route("dual")
804            .expect("dual-purpose handler stays HTTP-invocable");
805        assert!(route.job.is_some());
806    }
807
808    #[test]
809    fn posture_aware_detects_any_declared_route() {
810        let mut m = sample_manifest();
811        assert!(!m.is_posture_aware());
812        m.custom_routes.push(CustomRouteDefinition {
813            method: "GET".into(),
814            path: "/a".into(),
815            handler: "a".into(),
816            job: None,
817            posture: None,
818            forward_auth: false,
819            ..Default::default()
820        });
821        assert!(!m.is_posture_aware());
822        m.custom_routes.push(CustomRouteDefinition {
823            method: "GET".into(),
824            path: "/b".into(),
825            handler: "b".into(),
826            job: None,
827            posture: Some(RoutePosture::Authenticated),
828            forward_auth: false,
829            ..Default::default()
830        });
831        assert!(m.is_posture_aware());
832    }
833
834    #[test]
835    fn manifest_publishes_and_features_default_absent() {
836        let json = serde_json::to_value(sample_manifest()).unwrap();
837        assert!(json.get("publishes").is_none());
838        assert!(json.get("required_platform_features").is_none());
839        let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
840        let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
841        assert!(parsed.publishes.is_none());
842        assert!(parsed.required_platform_features.is_empty());
843    }
844
845    #[test]
846    fn the_schema_is_the_override_or_the_name_with_underscores() {
847        let mut m = sample_manifest();
848        for (name, schema) in [
849            ("asset-service", "asset_service"),
850            ("note_service", "note_service"),
851            ("Blog-Service", "Blog_Service"),
852            ("a-b-c", "a_b_c"),
853        ] {
854            m.name = name.into();
855            assert_eq!(m.schema(), schema);
856            assert_eq!(service_schema(name, None), schema);
857            assert_eq!(service_schema(name, Some("notes")), "notes");
858        }
859        m.schema_name = Some("notes".into());
860        assert_eq!(m.schema(), "notes");
861    }
862
863    #[test]
864    fn migration_bodies_are_stripped_and_nothing_else() {
865        let mut m = sample_manifest();
866        m.migrations = vec![
867            SqlMigration {
868                version: 1,
869                name: "initial_tables".into(),
870                sql: "CREATE TABLE notes ();".into(),
871                no_transaction: false,
872            },
873            SqlMigration {
874                version: 6,
875                name: "add_note_kind".into(),
876                sql: "-- cufflink:no-transaction\nSELECT 1;".into(),
877                no_transaction: true,
878            },
879        ];
880        let stripped = m.clone().without_migration_bodies();
881        assert!(stripped.migrations.iter().all(|m| m.sql.is_empty()));
882        let shape = |m: &ServiceManifest| -> Vec<(u32, String, bool)> {
883            m.migrations
884                .iter()
885                .map(|m| (m.version, m.name.clone(), m.no_transaction))
886                .collect()
887        };
888        assert_eq!(shape(&stripped), shape(&m));
889        assert_eq!(stripped.name, m.name);
890        assert_eq!(stripped.tables.len(), m.tables.len());
891    }
892
893    #[test]
894    fn the_history_defaults_to_refinerys_table() {
895        let mut m = sample_manifest();
896        assert_eq!(m.migration_history(), MigrationHistory::default());
897        let declared = MigrationHistory {
898            table: "schema_history".into(),
899            baseline: Some(6),
900            ..Default::default()
901        };
902        m.migration_history = Some(declared.clone());
903        assert_eq!(m.migration_history(), declared);
904    }
905
906    #[test]
907    fn schema_ownership_changes_the_schema_hash_only_when_set() {
908        let base = sample_manifest();
909        let hash = |m: &ServiceManifest| m.schema_hash().expect("schema hash");
910        let changed: Vec<ServiceManifest> = vec![
911            ServiceManifest {
912                schema: SchemaOwnership::Adopted,
913                ..base.clone()
914            },
915            ServiceManifest {
916                schema_name: Some("notes".into()),
917                ..base.clone()
918            },
919            ServiceManifest {
920                migrations: vec![SqlMigration {
921                    version: 1,
922                    name: "a".into(),
923                    sql: "SELECT 1;".into(),
924                    no_transaction: false,
925                }],
926                ..base.clone()
927            },
928            ServiceManifest {
929                migration_history: Some(MigrationHistory::default()),
930                ..base.clone()
931            },
932        ];
933        let hashes: std::collections::BTreeSet<String> = changed.iter().map(hash).collect();
934        assert_eq!(hashes.len(), changed.len());
935        assert!(!hashes.contains(&hash(&base)));
936    }
937
938    #[test]
939    fn a_schedule_runs_user_code() {
940        let mut m = sample_manifest();
941        m.schedules = serde_json::from_value(serde_json::json!([
942            {"name": "order_retry", "handler": "retry_orders", "spec": {"kind": "every", "secs": 30}}
943        ]))
944        .unwrap();
945        assert!(m.requires_wasm());
946        assert_eq!(m.declared_wasm_handlers(), ["retry_orders"]);
947        assert!(m.http_route("retry_orders").is_none());
948    }
949}