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