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")]
49 pub required_platform_features: Vec<String>,
50 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53 pub graphql: bool,
54 #[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
57 pub routing: RoutingMode,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub errors: Option<ErrorProfile>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub openapi: Option<OpenApiOptions>,
66 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69 pub components: BTreeMap<String, Value>,
70 #[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 #[serde(default, skip_serializing_if = "SchemaOwnership::is_managed")]
79 pub schema: SchemaOwnership,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub schema_name: Option<String>,
83 #[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 #[default]
95 Legacy,
96 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 #[default]
111 Managed,
112 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 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub title: Option<String>,
127 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub servers: Vec<ServerDoc>,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub tags: Vec<TagDoc>,
138 #[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 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 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
232 self.tables.iter().find(|t| t.name == name)
233 }
234
235 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 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 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 pub fn http_route_index(&self, handler: &str) -> Option<usize> {
321 self.custom_routes.iter().position(|r| r.handler == handler)
322 }
323
324 pub fn is_posture_aware(&self) -> bool {
329 self.custom_routes.iter().any(|r| r.posture.is_some())
330 }
331
332 pub fn schema(&self) -> String {
335 service_schema(&self.name, self.schema_name.as_deref())
336 }
337
338 pub fn migration_history(&self) -> MigrationHistory {
340 self.migration_history.clone().unwrap_or_default()
341 }
342
343 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 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 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 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
411pub 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()), ..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 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}