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, skip_serializing_if = "Vec::is_empty")]
20 pub private_tables: Vec<TableDefinition>,
21 #[serde(default)]
22 pub cells: Vec<CellDefinition>,
23 #[serde(default)]
24 pub events: Vec<EventDefinition>,
25 #[serde(default)]
26 pub subscriptions: Vec<SubscriptionDefinition>,
27 #[serde(default)]
28 pub custom_routes: Vec<CustomRouteDefinition>,
29 #[serde(default)]
30 pub mode: ServiceMode,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub authorization: Option<AuthorizationConfig>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub on_migrate: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub publishes: Option<Vec<String>>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub extensions: Vec<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub provides: Vec<ProvidedExtension>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub required_platform_features: Vec<String>,
60 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
63 pub graphql: bool,
64 #[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
67 pub routing: RoutingMode,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub errors: Option<ErrorProfile>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub openapi: Option<OpenApiOptions>,
76 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79 pub components: BTreeMap<String, Value>,
80 #[serde(default, skip_serializing_if = "ConsumerNaming::is_legacy")]
82 pub consumers: ConsumerNaming,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub streams: Vec<StreamDefinition>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub schedules: Vec<ScheduleDefinition>,
87 #[serde(default, skip_serializing_if = "SchemaOwnership::is_managed")]
89 pub schema: SchemaOwnership,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub schema_name: Option<String>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub migrations: Vec<SqlMigration>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub migration_history: Option<MigrationHistory>,
98}
99
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ConsumerNaming {
103 #[default]
105 Legacy,
106 Scoped,
108}
109
110impl ConsumerNaming {
111 pub fn is_legacy(&self) -> bool {
112 *self == ConsumerNaming::Legacy
113 }
114}
115
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum SchemaOwnership {
119 #[default]
121 Managed,
122 Adopted,
124}
125
126impl SchemaOwnership {
127 pub fn is_managed(&self) -> bool {
128 *self == SchemaOwnership::Managed
129 }
130}
131
132#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
133pub struct OpenApiOptions {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub title: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub version: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub description: Option<String>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub servers: Vec<ServerDoc>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub tags: Vec<TagDoc>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub security_scheme: Option<String>,
152}
153
154impl OpenApiOptions {
155 pub const DEFAULT_SECURITY_SCHEME: &'static str = "bearer_auth";
156
157 pub fn security_scheme(&self) -> &str {
158 self.security_scheme
159 .as_deref()
160 .unwrap_or(Self::DEFAULT_SECURITY_SCHEME)
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ServerDoc {
166 pub url: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub description: Option<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct TagDoc {
173 pub name: String,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub description: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ErrorProfile {
180 pub key: String,
181}
182
183impl ErrorProfile {
184 pub fn is_valid_key(key: &str) -> bool {
186 let mut bytes = key.bytes();
187 key.len() <= 64
188 && bytes
189 .next()
190 .is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
191 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
192 }
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum RoutingMode {
198 #[default]
199 Legacy,
200 Strict,
201}
202
203impl RoutingMode {
204 pub fn is_legacy(&self) -> bool {
205 *self == RoutingMode::Legacy
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
210#[serde(rename_all = "lowercase")]
211pub enum ServiceMode {
212 #[default]
213 Crud,
214 Wasm,
215 Container,
216 Web,
217 Native,
220}
221
222impl ServiceMode {
223 pub fn as_str(&self) -> &'static str {
225 match self {
226 ServiceMode::Crud => "crud",
227 ServiceMode::Wasm => "wasm",
228 ServiceMode::Container => "container",
229 ServiceMode::Web => "web",
230 ServiceMode::Native => "native",
231 }
232 }
233
234 pub fn runs_on_platform(&self) -> bool {
238 match self {
239 ServiceMode::Crud | ServiceMode::Wasm | ServiceMode::Container => true,
240 ServiceMode::Web | ServiceMode::Native => false,
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ProvidedExtension {
248 pub name: String,
249 pub callers: Vec<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CellDefinition {
255 pub name: String,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct EventDefinition {
260 pub name: String,
261 pub table: String,
262 pub action: EventAction,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum EventAction {
268 Create,
269 Update,
270 Delete,
271}
272
273impl ServiceManifest {
274 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
276 self.tables.iter().find(|t| t.name == name)
277 }
278
279 pub fn declared_tables(&self) -> impl Iterator<Item = &TableDefinition> {
281 self.tables.iter().chain(&self.private_tables)
282 }
283
284 pub fn requires_wasm(&self) -> bool {
292 match self.mode {
293 ServiceMode::Wasm => true,
294 ServiceMode::Native => false,
295 ServiceMode::Crud | ServiceMode::Container | ServiceMode::Web => {
296 self.on_migrate.is_some()
297 || self.tables.iter().any(|t| t.hooks.is_some())
298 || !self.custom_routes.is_empty()
299 || !self.schedules.is_empty()
300 || self
301 .subscriptions
302 .iter()
303 .any(|s| matches!(s.handler_type, HandlerType::Wasm))
304 }
305 }
306 }
307
308 pub fn declared_wasm_handlers(&self) -> Vec<&str> {
312 let mut handlers: Vec<&str> = Vec::new();
313 if let Some(ref h) = self.on_migrate {
314 handlers.push(h.as_str());
315 }
316 for route in &self.custom_routes {
317 handlers.push(route.handler.as_str());
318 if let Some(reducer) = route.sse.as_ref().and_then(|sse| sse.reducer.as_deref()) {
319 handlers.push(reducer);
320 }
321 }
322 for sub in &self.subscriptions {
323 if matches!(sub.handler_type, HandlerType::Wasm) {
324 handlers.push(sub.handler.as_str());
325 }
326 }
327 handlers.extend(self.schedules.iter().map(|s| s.handler.as_str()));
328 for table in &self.tables {
329 if let Some(ref hooks) = table.hooks {
330 for h in [
331 hooks.before_create.as_deref(),
332 hooks.after_create.as_deref(),
333 hooks.before_update.as_deref(),
334 hooks.after_update.as_deref(),
335 hooks.before_delete.as_deref(),
336 hooks.after_delete.as_deref(),
337 ]
338 .iter()
339 .flatten()
340 {
341 handlers.push(h);
342 }
343 }
344 }
345 handlers.sort();
346 handlers.dedup();
347 handlers
348 }
349
350 pub fn http_route(&self, handler: &str) -> Option<&CustomRouteDefinition> {
358 self.http_route_index(handler)
359 .and_then(|index| self.custom_routes.get(index))
360 }
361
362 pub fn http_route_index(&self, handler: &str) -> Option<usize> {
364 self.custom_routes.iter().position(|r| r.handler == handler)
365 }
366
367 pub fn is_posture_aware(&self) -> bool {
372 self.custom_routes.iter().any(|r| r.posture.is_some())
373 }
374
375 pub fn schema(&self) -> String {
378 service_schema(&self.name, self.schema_name.as_deref())
379 }
380
381 pub fn migration_history(&self) -> MigrationHistory {
383 self.migration_history.clone().unwrap_or_default()
384 }
385
386 pub fn without_migration_bodies(mut self) -> Self {
389 self.migrations
390 .iter_mut()
391 .for_each(|migration| migration.sql = String::new());
392 self
393 }
394
395 pub(crate) fn service_features(&self) -> impl Iterator<Item = &'static str> {
397 let adopted = !self.schema.is_managed() || self.schema_name.is_some();
398 [
399 (self.routing == RoutingMode::Strict).then_some(features::ROUTE_MATCHING),
400 self.errors.is_some().then_some(features::ERROR_PROFILE),
401 (!self.consumers.is_legacy()).then_some(features::NATS_CONSUMER_CONFIG),
402 (!self.streams.is_empty()).then_some(features::STREAMS),
403 (!self.schedules.is_empty()).then_some(features::SCHEDULES),
404 adopted.then_some(features::SCHEMA_ADOPTED),
405 (!self.migrations.is_empty()).then_some(features::SQL_MIGRATIONS),
406 (!self.extensions.is_empty()).then_some(features::EXT_CALL),
407 (self.mode == ServiceMode::Native).then_some(features::NATIVE_SERVICES),
408 (!self.private_tables.is_empty()).then_some(features::PRIVATE_TABLES),
409 ]
410 .into_iter()
411 .flatten()
412 }
413
414 pub fn deploy_hash(&self) -> serde_json::Result<String> {
417 use sha2::{Digest, Sha256};
418 serde_json::to_vec(self).map(|bytes| hex::encode(Sha256::digest(bytes)))
419 }
420
421 pub fn schema_hash(&self) -> serde_json::Result<String> {
425 use sha2::{Digest, Sha256};
426 let ownership = [
427 (!self.schema.is_managed())
428 .then(|| serde_json::to_string(&self.schema))
429 .transpose()?,
430 self.schema_name
431 .as_ref()
432 .map(serde_json::to_string)
433 .transpose()?,
434 (!self.migrations.is_empty())
435 .then(|| serde_json::to_string(&self.migrations))
436 .transpose()?,
437 self.migration_history
438 .as_ref()
439 .map(serde_json::to_string)
440 .transpose()?,
441 ];
442 let private_tables = (!self.private_tables.is_empty())
443 .then(|| serde_json::to_string(&self.private_tables))
444 .transpose()?;
445 let digest = [
446 serde_json::to_string(&self.tables)?,
447 serde_json::to_string(&self.custom_routes)?,
448 serde_json::to_string(&self.subscriptions)?,
449 serde_json::to_string(&self.authorization)?,
450 serde_json::to_string(&self.on_migrate)?,
451 ]
452 .into_iter()
453 .chain(ownership.into_iter().flatten())
454 .chain(private_tables)
455 .fold(Sha256::new(), |hasher, part| hasher.chain_update(part))
456 .finalize();
457 Ok(hex::encode(digest))
458 }
459}
460
461pub fn service_schema(name: &str, schema_name: Option<&str>) -> String {
464 schema_name.map_or_else(|| name.replace('-', "_"), str::to_string)
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::manifest::fixtures::sample_manifest;
471 use crate::manifest::{
472 ColumnDefinition, ColumnType, ForeignKeyAction, JobConfig, RoutePosture, TableHooks,
473 };
474
475 #[test]
476 fn test_manifest_serialization_roundtrip() {
477 let manifest = sample_manifest();
478 let json = serde_json::to_string(&manifest).unwrap();
479 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
480 assert_eq!(parsed.name, "test-service");
481 assert_eq!(parsed.tables.len(), 1);
482 assert_eq!(parsed.tables[0].columns.len(), 3);
483 }
484
485 #[test]
486 fn routing_is_serialized_only_when_strict() {
487 let mut manifest = sample_manifest();
488 let json = serde_json::to_value(&manifest).unwrap();
489 assert!(json.get("routing").is_none(), "{json}");
490 manifest.routing = RoutingMode::Strict;
491 let json = serde_json::to_value(&manifest).unwrap();
492 assert_eq!(json["routing"], "strict");
493 let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
494 assert_eq!(parsed.routing, RoutingMode::Strict);
495 }
496
497 #[test]
498 fn error_profile_keys_are_snake_case_identifiers() {
499 for key in ["error_message", "_e", "e1", &"e".repeat(64)] {
500 assert!(ErrorProfile::is_valid_key(key), "{key}");
501 }
502 for key in [
503 "",
504 "1e",
505 "Error",
506 "error-message",
507 "error message",
508 &"e".repeat(65),
509 ] {
510 assert!(!ErrorProfile::is_valid_key(key), "{key}");
511 }
512 let mut manifest = sample_manifest();
513 assert!(serde_json::to_value(&manifest)
514 .unwrap()
515 .get("errors")
516 .is_none());
517 manifest.errors = Some(ErrorProfile {
518 key: "error_message".into(),
519 });
520 assert_eq!(
521 serde_json::to_value(&manifest).unwrap()["errors"],
522 serde_json::json!({"key": "error_message"})
523 );
524 }
525
526 #[test]
527 fn test_get_table() {
528 let manifest = sample_manifest();
529 assert!(manifest.get_table("todos").is_some());
530 assert!(manifest.get_table("nonexistent").is_none());
531 }
532
533 #[test]
534 fn test_schema_hash_deterministic() {
535 let m1 = sample_manifest();
536 let m2 = sample_manifest();
537 assert_eq!(
538 m1.schema_hash().expect("schema hash"),
539 m2.schema_hash().expect("schema hash")
540 );
541 }
542
543 #[test]
544 fn test_schema_hash_changes() {
545 let mut m1 = sample_manifest();
546 let m2 = sample_manifest();
547 m1.tables[0].columns.push(ColumnDefinition {
548 name: "extra".into(),
549 column_type: ColumnType::Text,
550 primary_key: false,
551 nullable: true,
552 auto_generate: false,
553 default_value: None,
554 references: None,
555 on_delete: None,
556 unique: false,
557 validations: vec![],
558 });
559 assert_ne!(
560 m1.schema_hash().expect("schema hash"),
561 m2.schema_hash().expect("schema hash")
562 );
563 }
564
565 #[test]
566 fn test_fk_serialization() {
567 let manifest = sample_manifest();
568 let json = serde_json::to_string(&manifest).unwrap();
569 assert!(json.contains("\"references\":\"users.id\""));
570 assert!(json.contains("\"on_delete\":\"cascade\""));
571
572 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
573 let user_id_col = &parsed.tables[0].columns[2];
574 assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
575 assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
576 }
577
578 #[test]
579 fn test_service_mode_default() {
580 let json = r#"{"name":"svc","tables":[]}"#;
581 let m: ServiceManifest = serde_json::from_str(json).unwrap();
582 assert_eq!(m.mode, ServiceMode::Crud);
583 }
584
585 #[test]
586 fn test_on_migrate_defaults_to_none() {
587 let json = r#"{"name":"svc","tables":[]}"#;
588 let m: ServiceManifest = serde_json::from_str(json).unwrap();
589 assert!(m.on_migrate.is_none());
590 }
591
592 #[test]
593 fn test_on_migrate_serialization_roundtrip() {
594 let mut m = sample_manifest();
595 m.on_migrate = Some("handle_on_migrate".into());
596 let json = serde_json::to_string(&m).unwrap();
597 assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
598 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
599 assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
600 }
601
602 #[test]
603 fn test_on_migrate_skipped_when_none() {
604 let m = sample_manifest();
605 let json = serde_json::to_string(&m).unwrap();
606 assert!(!json.contains("on_migrate"));
607 }
608
609 #[test]
610 fn deploy_hash_covers_fields_schema_hash_ignores() {
611 let m1 = sample_manifest();
612 let mut m2 = sample_manifest();
613 let hash = |m: &ServiceManifest| m.deploy_hash().expect("hash");
614 assert_eq!(hash(&m1), hash(&m2));
615 m2.publishes = Some(vec!["acme.orders.>".into()]);
616 assert_ne!(hash(&m1), hash(&m2));
617 assert_eq!(
618 m1.schema_hash().expect("schema hash"),
619 m2.schema_hash().expect("schema hash")
620 );
621 }
622
623 #[test]
624 fn test_on_migrate_changes_schema_hash() {
625 let m1 = sample_manifest();
626 let mut m2 = sample_manifest();
627 m2.on_migrate = Some("handle_on_migrate".into());
628 assert_ne!(
629 m1.schema_hash().expect("schema hash"),
630 m2.schema_hash().expect("schema hash")
631 );
632 }
633
634 #[test]
635 fn test_requires_wasm_false_for_plain_crud() {
636 let m = sample_manifest();
637 assert!(!m.requires_wasm());
638 }
639
640 #[test]
641 fn test_requires_wasm_true_for_wasm_mode() {
642 let mut m = sample_manifest();
643 m.mode = ServiceMode::Wasm;
644 assert!(m.requires_wasm());
645 }
646
647 #[test]
648 fn a_mode_is_named_as_a_manifest_names_it() {
649 for (mode, on_platform) in [
650 (ServiceMode::Crud, true),
651 (ServiceMode::Wasm, true),
652 (ServiceMode::Container, true),
653 (ServiceMode::Web, false),
654 (ServiceMode::Native, false),
655 ] {
656 assert_eq!(serde_json::to_value(&mode).unwrap(), mode.as_str());
657 assert_eq!(mode.runs_on_platform(), on_platform, "{}", mode.as_str());
658 }
659 }
660
661 #[test]
662 fn a_native_service_runs_no_wasm_and_demands_native_services() {
663 let mut m = sample_manifest();
664 m.on_migrate = Some("handle_on_migrate".into());
665 m.mode = ServiceMode::Native;
666 assert!(!m.requires_wasm());
667 assert!(m.derived_features().contains(features::NATIVE_SERVICES));
668 let json = serde_json::to_value(&m).unwrap();
669 assert_eq!(json["mode"], "native");
670 let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
671 assert_eq!(parsed.mode, ServiceMode::Native);
672 }
673
674 #[test]
675 fn test_requires_wasm_true_for_on_migrate() {
676 let mut m = sample_manifest();
677 m.on_migrate = Some("handle_on_migrate".into());
678 assert!(m.requires_wasm());
679 }
680
681 #[test]
682 fn test_requires_wasm_true_for_table_hooks() {
683 let mut m = sample_manifest();
684 m.tables[0].hooks = Some(TableHooks {
685 before_create: Some("validate".into()),
686 ..Default::default()
687 });
688 assert!(m.requires_wasm());
689 }
690
691 #[test]
692 fn test_requires_wasm_true_for_custom_routes() {
693 let mut m = sample_manifest();
694 m.custom_routes.push(CustomRouteDefinition {
695 method: "GET".into(),
696 path: "/hello".into(),
697 handler: "hello".into(),
698 job: None,
699 posture: None,
700 forward_auth: false,
701 ..Default::default()
702 });
703 assert!(m.requires_wasm());
704 }
705
706 #[test]
707 fn test_requires_wasm_true_for_wasm_subscription() {
708 let mut m = sample_manifest();
709 m.subscriptions.push(SubscriptionDefinition::new(
710 "events.foo",
711 "on_foo",
712 HandlerType::Wasm,
713 ));
714 assert!(m.requires_wasm());
715 }
716
717 #[test]
718 fn test_requires_wasm_false_for_non_wasm_subscription() {
719 let mut m = sample_manifest();
720 m.subscriptions.push(SubscriptionDefinition::new(
721 "events.foo",
722 "on_foo",
723 HandlerType::Webhook,
724 ));
725 assert!(!m.requires_wasm());
726 }
727
728 #[test]
729 fn test_declared_wasm_handlers_collects_all_sources() {
730 let mut m = sample_manifest();
731 m.on_migrate = Some("on_mig".into());
732 m.custom_routes.push(CustomRouteDefinition {
733 method: "GET".into(),
734 path: "/h".into(),
735 handler: "route_h".into(),
736 job: None,
737 posture: None,
738 forward_auth: false,
739 ..Default::default()
740 });
741 m.subscriptions
742 .push(SubscriptionDefinition::new("x", "sub_h", HandlerType::Wasm));
743 m.subscriptions.push(SubscriptionDefinition::new(
744 "y",
745 "wh_ignored",
746 HandlerType::Webhook,
747 ));
748 m.tables[0].hooks = Some(TableHooks {
749 before_create: Some("bc".into()),
750 after_create: Some("ac".into()),
751 before_delete: Some("bc".into()), ..Default::default()
753 });
754 let handlers = m.declared_wasm_handlers();
755 assert!(handlers.contains(&"on_mig"));
756 assert!(handlers.contains(&"route_h"));
757 assert!(handlers.contains(&"sub_h"));
758 assert!(handlers.contains(&"bc"));
759 assert!(handlers.contains(&"ac"));
760 assert!(!handlers.contains(&"wh_ignored"));
761 assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
763 }
764
765 #[test]
766 fn http_route_exposes_only_custom_routes() {
767 let mut m = sample_manifest();
768 m.on_migrate = Some("on_mig".into());
769 m.custom_routes.push(CustomRouteDefinition {
770 method: "GET".into(),
771 path: "/h".into(),
772 handler: "route_h".into(),
773 job: None,
774 posture: None,
775 forward_auth: false,
776 ..Default::default()
777 });
778 m.subscriptions.push(SubscriptionDefinition::new(
779 "evt.sub",
780 "sub_h",
781 HandlerType::Wasm,
782 ));
783 m.tables[0].hooks = Some(TableHooks {
784 after_create: Some("hook_h".into()),
785 ..Default::default()
786 });
787
788 assert_eq!(
789 m.http_route("route_h").map(|r| r.handler.as_str()),
790 Some("route_h")
791 );
792 assert!(m.http_route("sub_h").is_none());
793 assert!(m.http_route("on_mig").is_none());
794 assert!(m.http_route("hook_h").is_none());
795 assert!(m.http_route("unknown").is_none());
796 }
797
798 #[test]
799 fn http_route_allows_handler_declared_as_both_route_and_subscription() {
800 let mut m = sample_manifest();
801 m.custom_routes.push(CustomRouteDefinition {
802 method: "POST".into(),
803 path: "/dual".into(),
804 handler: "dual".into(),
805 job: Some(JobConfig::default()),
806 posture: None,
807 forward_auth: false,
808 ..Default::default()
809 });
810 m.subscriptions.push(SubscriptionDefinition::new(
811 "evt.dual",
812 "dual",
813 HandlerType::Wasm,
814 ));
815
816 let route = m
817 .http_route("dual")
818 .expect("dual-purpose handler stays HTTP-invocable");
819 assert!(route.job.is_some());
820 }
821
822 #[test]
823 fn posture_aware_detects_any_declared_route() {
824 let mut m = sample_manifest();
825 assert!(!m.is_posture_aware());
826 m.custom_routes.push(CustomRouteDefinition {
827 method: "GET".into(),
828 path: "/a".into(),
829 handler: "a".into(),
830 job: None,
831 posture: None,
832 forward_auth: false,
833 ..Default::default()
834 });
835 assert!(!m.is_posture_aware());
836 m.custom_routes.push(CustomRouteDefinition {
837 method: "GET".into(),
838 path: "/b".into(),
839 handler: "b".into(),
840 job: None,
841 posture: Some(RoutePosture::Authenticated),
842 forward_auth: false,
843 ..Default::default()
844 });
845 assert!(m.is_posture_aware());
846 }
847
848 #[test]
849 fn manifest_publishes_and_features_default_absent() {
850 let json = serde_json::to_value(sample_manifest()).unwrap();
851 assert!(json.get("publishes").is_none());
852 assert!(json.get("required_platform_features").is_none());
853 let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
854 let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
855 assert!(parsed.publishes.is_none());
856 assert!(parsed.required_platform_features.is_empty());
857 }
858
859 #[test]
860 fn the_schema_is_the_override_or_the_name_with_underscores() {
861 let mut m = sample_manifest();
862 for (name, schema) in [
863 ("asset-service", "asset_service"),
864 ("note_service", "note_service"),
865 ("Blog-Service", "Blog_Service"),
866 ("a-b-c", "a_b_c"),
867 ] {
868 m.name = name.into();
869 assert_eq!(m.schema(), schema);
870 assert_eq!(service_schema(name, None), schema);
871 assert_eq!(service_schema(name, Some("notes")), "notes");
872 }
873 m.schema_name = Some("notes".into());
874 assert_eq!(m.schema(), "notes");
875 }
876
877 #[test]
878 fn migration_bodies_are_stripped_and_nothing_else() {
879 let mut m = sample_manifest();
880 m.migrations = vec![
881 SqlMigration {
882 version: 1,
883 name: "initial_tables".into(),
884 sql: "CREATE TABLE notes ();".into(),
885 no_transaction: false,
886 },
887 SqlMigration {
888 version: 6,
889 name: "add_note_kind".into(),
890 sql: "-- cufflink:no-transaction\nSELECT 1;".into(),
891 no_transaction: true,
892 },
893 ];
894 let stripped = m.clone().without_migration_bodies();
895 assert!(stripped.migrations.iter().all(|m| m.sql.is_empty()));
896 let shape = |m: &ServiceManifest| -> Vec<(u32, String, bool)> {
897 m.migrations
898 .iter()
899 .map(|m| (m.version, m.name.clone(), m.no_transaction))
900 .collect()
901 };
902 assert_eq!(shape(&stripped), shape(&m));
903 assert_eq!(stripped.name, m.name);
904 assert_eq!(stripped.tables.len(), m.tables.len());
905 }
906
907 #[test]
908 fn the_history_defaults_to_refinerys_table() {
909 let mut m = sample_manifest();
910 assert_eq!(m.migration_history(), MigrationHistory::default());
911 let declared = MigrationHistory {
912 table: "schema_history".into(),
913 baseline: Some(6),
914 ..Default::default()
915 };
916 m.migration_history = Some(declared.clone());
917 assert_eq!(m.migration_history(), declared);
918 }
919
920 #[test]
921 fn schema_ownership_changes_the_schema_hash_only_when_set() {
922 let base = sample_manifest();
923 let hash = |m: &ServiceManifest| m.schema_hash().expect("schema hash");
924 let changed: Vec<ServiceManifest> = vec![
925 ServiceManifest {
926 schema: SchemaOwnership::Adopted,
927 ..base.clone()
928 },
929 ServiceManifest {
930 schema_name: Some("notes".into()),
931 ..base.clone()
932 },
933 ServiceManifest {
934 migrations: vec![SqlMigration {
935 version: 1,
936 name: "a".into(),
937 sql: "SELECT 1;".into(),
938 no_transaction: false,
939 }],
940 ..base.clone()
941 },
942 ServiceManifest {
943 migration_history: Some(MigrationHistory::default()),
944 ..base.clone()
945 },
946 ];
947 let hashes: std::collections::BTreeSet<String> = changed.iter().map(hash).collect();
948 assert_eq!(hashes.len(), changed.len());
949 assert!(!hashes.contains(&hash(&base)));
950 }
951
952 #[test]
953 fn a_schedule_runs_user_code() {
954 let mut m = sample_manifest();
955 m.schedules = serde_json::from_value(serde_json::json!([
956 {"name": "order_retry", "handler": "retry_orders", "spec": {"kind": "every", "secs": 30}}
957 ]))
958 .unwrap();
959 assert!(m.requires_wasm());
960 assert_eq!(m.declared_wasm_handlers(), ["retry_orders"]);
961 assert!(m.http_route("retry_orders").is_none());
962 }
963
964 fn private(name: &str) -> TableDefinition {
965 TableDefinition {
966 name: name.into(),
967 columns: vec![ColumnDefinition {
968 name: "body".into(),
969 column_type: ColumnType::Text,
970 ..Default::default()
971 }],
972 managed: false,
973 ..Default::default()
974 }
975 }
976
977 #[test]
978 fn declared_tables_chains_both_lists() {
979 let mut m = sample_manifest();
980 let names = |m: &ServiceManifest| -> Vec<String> {
981 m.declared_tables().map(|t| t.name.clone()).collect()
982 };
983 assert_eq!(names(&m), ["todos"]);
984 m.private_tables = vec![private("notes"), private("audit_log")];
985 assert_eq!(names(&m), ["todos", "notes", "audit_log"]);
986 m.tables.clear();
987 assert_eq!(names(&m), ["notes", "audit_log"]);
988 }
989
990 #[test]
991 fn get_table_does_not_find_a_private_table() {
992 let mut m = sample_manifest();
993 m.private_tables = vec![private("notes")];
994 assert!(m.get_table("todos").is_some());
995 assert!(m.get_table("notes").is_none());
996 }
997
998 #[test]
999 fn a_reader_without_the_key_sees_no_private_table() {
1000 let mut m = sample_manifest();
1001 m.private_tables = vec![private("notes")];
1002 let mut wire = serde_json::to_value(&m).unwrap();
1003 assert_eq!(wire["private_tables"][0]["name"], "notes");
1004 wire.as_object_mut().unwrap().remove("private_tables");
1005 let older: ServiceManifest = serde_json::from_value(wire).unwrap();
1006 assert!(older.private_tables.is_empty());
1007 assert!(older.get_table("notes").is_none());
1008 assert_eq!(older.declared_tables().count(), 1);
1009 }
1010
1011 #[test]
1012 fn private_tables_join_the_schema_hash_last_and_only_when_declared() {
1013 use sha2::{Digest, Sha256};
1014 let base = sample_manifest();
1015 let mut declaring = base.clone();
1016 declaring.private_tables = vec![private("notes")];
1017 let parts = |m: &ServiceManifest| {
1018 [
1019 serde_json::to_string(&m.tables).unwrap(),
1020 serde_json::to_string(&m.custom_routes).unwrap(),
1021 serde_json::to_string(&m.subscriptions).unwrap(),
1022 serde_json::to_string(&m.authorization).unwrap(),
1023 serde_json::to_string(&m.on_migrate).unwrap(),
1024 ]
1025 .concat()
1026 };
1027 let hash = |text: String| hex::encode(Sha256::digest(text));
1028 assert_eq!(base.schema_hash().unwrap(), hash(parts(&base)));
1029 assert_eq!(
1030 declaring.schema_hash().unwrap(),
1031 hash(parts(&declaring) + &serde_json::to_string(&declaring.private_tables).unwrap())
1032 );
1033 let mut moved = declaring.clone();
1034 moved.private_tables[0].name = "memos".into();
1035 assert_ne!(
1036 moved.schema_hash().unwrap(),
1037 declaring.schema_hash().unwrap()
1038 );
1039 }
1040}