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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub authorization: Option<AuthorizationConfig>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub on_migrate: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub publishes: Option<Vec<String>>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub extensions: Vec<String>,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub provides: Vec<ProvidedExtension>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub required_platform_features: Vec<String>,
56 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
59 pub graphql: bool,
60 #[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
63 pub routing: RoutingMode,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub errors: Option<ErrorProfile>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub openapi: Option<OpenApiOptions>,
72 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75 pub components: BTreeMap<String, Value>,
76 #[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 #[serde(default, skip_serializing_if = "SchemaOwnership::is_managed")]
85 pub schema: SchemaOwnership,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub schema_name: Option<String>,
89 #[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 #[default]
101 Legacy,
102 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 #[default]
117 Managed,
118 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 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub title: Option<String>,
133 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub servers: Vec<ServerDoc>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub tags: Vec<TagDoc>,
144 #[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 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 Native,
216}
217
218impl ServiceMode {
219 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct ProvidedExtension {
244 pub name: String,
245 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 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
272 self.tables.iter().find(|t| t.name == name)
273 }
274
275 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 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 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 pub fn http_route_index(&self, handler: &str) -> Option<usize> {
355 self.custom_routes.iter().position(|r| r.handler == handler)
356 }
357
358 pub fn is_posture_aware(&self) -> bool {
363 self.custom_routes.iter().any(|r| r.posture.is_some())
364 }
365
366 pub fn schema(&self) -> String {
369 service_schema(&self.name, self.schema_name.as_deref())
370 }
371
372 pub fn migration_history(&self) -> MigrationHistory {
374 self.migration_history.clone().unwrap_or_default()
375 }
376
377 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 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 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 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
447pub 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()), ..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 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}