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 ..Default::default()
559 });
560 assert_ne!(
561 m1.schema_hash().expect("schema hash"),
562 m2.schema_hash().expect("schema hash")
563 );
564 }
565
566 #[test]
567 fn test_fk_serialization() {
568 let manifest = sample_manifest();
569 let json = serde_json::to_string(&manifest).unwrap();
570 assert!(json.contains("\"references\":\"users.id\""));
571 assert!(json.contains("\"on_delete\":\"cascade\""));
572
573 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
574 let user_id_col = &parsed.tables[0].columns[2];
575 assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
576 assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
577 }
578
579 #[test]
580 fn test_service_mode_default() {
581 let json = r#"{"name":"svc","tables":[]}"#;
582 let m: ServiceManifest = serde_json::from_str(json).unwrap();
583 assert_eq!(m.mode, ServiceMode::Crud);
584 }
585
586 #[test]
587 fn test_on_migrate_defaults_to_none() {
588 let json = r#"{"name":"svc","tables":[]}"#;
589 let m: ServiceManifest = serde_json::from_str(json).unwrap();
590 assert!(m.on_migrate.is_none());
591 }
592
593 #[test]
594 fn test_on_migrate_serialization_roundtrip() {
595 let mut m = sample_manifest();
596 m.on_migrate = Some("handle_on_migrate".into());
597 let json = serde_json::to_string(&m).unwrap();
598 assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
599 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
600 assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
601 }
602
603 #[test]
604 fn test_on_migrate_skipped_when_none() {
605 let m = sample_manifest();
606 let json = serde_json::to_string(&m).unwrap();
607 assert!(!json.contains("on_migrate"));
608 }
609
610 #[test]
611 fn deploy_hash_covers_fields_schema_hash_ignores() {
612 let m1 = sample_manifest();
613 let mut m2 = sample_manifest();
614 let hash = |m: &ServiceManifest| m.deploy_hash().expect("hash");
615 assert_eq!(hash(&m1), hash(&m2));
616 m2.publishes = Some(vec!["acme.orders.>".into()]);
617 assert_ne!(hash(&m1), hash(&m2));
618 assert_eq!(
619 m1.schema_hash().expect("schema hash"),
620 m2.schema_hash().expect("schema hash")
621 );
622 }
623
624 #[test]
625 fn test_on_migrate_changes_schema_hash() {
626 let m1 = sample_manifest();
627 let mut m2 = sample_manifest();
628 m2.on_migrate = Some("handle_on_migrate".into());
629 assert_ne!(
630 m1.schema_hash().expect("schema hash"),
631 m2.schema_hash().expect("schema hash")
632 );
633 }
634
635 #[test]
636 fn test_requires_wasm_false_for_plain_crud() {
637 let m = sample_manifest();
638 assert!(!m.requires_wasm());
639 }
640
641 #[test]
642 fn test_requires_wasm_true_for_wasm_mode() {
643 let mut m = sample_manifest();
644 m.mode = ServiceMode::Wasm;
645 assert!(m.requires_wasm());
646 }
647
648 #[test]
649 fn a_mode_is_named_as_a_manifest_names_it() {
650 for (mode, on_platform) in [
651 (ServiceMode::Crud, true),
652 (ServiceMode::Wasm, true),
653 (ServiceMode::Container, true),
654 (ServiceMode::Web, false),
655 (ServiceMode::Native, false),
656 ] {
657 assert_eq!(serde_json::to_value(&mode).unwrap(), mode.as_str());
658 assert_eq!(mode.runs_on_platform(), on_platform, "{}", mode.as_str());
659 }
660 }
661
662 #[test]
663 fn a_native_service_runs_no_wasm_and_demands_native_services() {
664 let mut m = sample_manifest();
665 m.on_migrate = Some("handle_on_migrate".into());
666 m.mode = ServiceMode::Native;
667 assert!(!m.requires_wasm());
668 assert!(m.derived_features().contains(features::NATIVE_SERVICES));
669 let json = serde_json::to_value(&m).unwrap();
670 assert_eq!(json["mode"], "native");
671 let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
672 assert_eq!(parsed.mode, ServiceMode::Native);
673 }
674
675 #[test]
676 fn test_requires_wasm_true_for_on_migrate() {
677 let mut m = sample_manifest();
678 m.on_migrate = Some("handle_on_migrate".into());
679 assert!(m.requires_wasm());
680 }
681
682 #[test]
683 fn test_requires_wasm_true_for_table_hooks() {
684 let mut m = sample_manifest();
685 m.tables[0].hooks = Some(TableHooks {
686 before_create: Some("validate".into()),
687 ..Default::default()
688 });
689 assert!(m.requires_wasm());
690 }
691
692 #[test]
693 fn test_requires_wasm_true_for_custom_routes() {
694 let mut m = sample_manifest();
695 m.custom_routes.push(CustomRouteDefinition {
696 method: "GET".into(),
697 path: "/hello".into(),
698 handler: "hello".into(),
699 job: None,
700 posture: None,
701 forward_auth: false,
702 ..Default::default()
703 });
704 assert!(m.requires_wasm());
705 }
706
707 #[test]
708 fn test_requires_wasm_true_for_wasm_subscription() {
709 let mut m = sample_manifest();
710 m.subscriptions.push(SubscriptionDefinition::new(
711 "events.foo",
712 "on_foo",
713 HandlerType::Wasm,
714 ));
715 assert!(m.requires_wasm());
716 }
717
718 #[test]
719 fn test_requires_wasm_false_for_non_wasm_subscription() {
720 let mut m = sample_manifest();
721 m.subscriptions.push(SubscriptionDefinition::new(
722 "events.foo",
723 "on_foo",
724 HandlerType::Webhook,
725 ));
726 assert!(!m.requires_wasm());
727 }
728
729 #[test]
730 fn test_declared_wasm_handlers_collects_all_sources() {
731 let mut m = sample_manifest();
732 m.on_migrate = Some("on_mig".into());
733 m.custom_routes.push(CustomRouteDefinition {
734 method: "GET".into(),
735 path: "/h".into(),
736 handler: "route_h".into(),
737 job: None,
738 posture: None,
739 forward_auth: false,
740 ..Default::default()
741 });
742 m.subscriptions
743 .push(SubscriptionDefinition::new("x", "sub_h", HandlerType::Wasm));
744 m.subscriptions.push(SubscriptionDefinition::new(
745 "y",
746 "wh_ignored",
747 HandlerType::Webhook,
748 ));
749 m.tables[0].hooks = Some(TableHooks {
750 before_create: Some("bc".into()),
751 after_create: Some("ac".into()),
752 before_delete: Some("bc".into()), ..Default::default()
754 });
755 let handlers = m.declared_wasm_handlers();
756 assert!(handlers.contains(&"on_mig"));
757 assert!(handlers.contains(&"route_h"));
758 assert!(handlers.contains(&"sub_h"));
759 assert!(handlers.contains(&"bc"));
760 assert!(handlers.contains(&"ac"));
761 assert!(!handlers.contains(&"wh_ignored"));
762 assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
764 }
765
766 #[test]
767 fn http_route_exposes_only_custom_routes() {
768 let mut m = sample_manifest();
769 m.on_migrate = Some("on_mig".into());
770 m.custom_routes.push(CustomRouteDefinition {
771 method: "GET".into(),
772 path: "/h".into(),
773 handler: "route_h".into(),
774 job: None,
775 posture: None,
776 forward_auth: false,
777 ..Default::default()
778 });
779 m.subscriptions.push(SubscriptionDefinition::new(
780 "evt.sub",
781 "sub_h",
782 HandlerType::Wasm,
783 ));
784 m.tables[0].hooks = Some(TableHooks {
785 after_create: Some("hook_h".into()),
786 ..Default::default()
787 });
788
789 assert_eq!(
790 m.http_route("route_h").map(|r| r.handler.as_str()),
791 Some("route_h")
792 );
793 assert!(m.http_route("sub_h").is_none());
794 assert!(m.http_route("on_mig").is_none());
795 assert!(m.http_route("hook_h").is_none());
796 assert!(m.http_route("unknown").is_none());
797 }
798
799 #[test]
800 fn http_route_allows_handler_declared_as_both_route_and_subscription() {
801 let mut m = sample_manifest();
802 m.custom_routes.push(CustomRouteDefinition {
803 method: "POST".into(),
804 path: "/dual".into(),
805 handler: "dual".into(),
806 job: Some(JobConfig::default()),
807 posture: None,
808 forward_auth: false,
809 ..Default::default()
810 });
811 m.subscriptions.push(SubscriptionDefinition::new(
812 "evt.dual",
813 "dual",
814 HandlerType::Wasm,
815 ));
816
817 let route = m
818 .http_route("dual")
819 .expect("dual-purpose handler stays HTTP-invocable");
820 assert!(route.job.is_some());
821 }
822
823 #[test]
824 fn posture_aware_detects_any_declared_route() {
825 let mut m = sample_manifest();
826 assert!(!m.is_posture_aware());
827 m.custom_routes.push(CustomRouteDefinition {
828 method: "GET".into(),
829 path: "/a".into(),
830 handler: "a".into(),
831 job: None,
832 posture: None,
833 forward_auth: false,
834 ..Default::default()
835 });
836 assert!(!m.is_posture_aware());
837 m.custom_routes.push(CustomRouteDefinition {
838 method: "GET".into(),
839 path: "/b".into(),
840 handler: "b".into(),
841 job: None,
842 posture: Some(RoutePosture::Authenticated),
843 forward_auth: false,
844 ..Default::default()
845 });
846 assert!(m.is_posture_aware());
847 }
848
849 #[test]
850 fn manifest_publishes_and_features_default_absent() {
851 let json = serde_json::to_value(sample_manifest()).unwrap();
852 assert!(json.get("publishes").is_none());
853 assert!(json.get("required_platform_features").is_none());
854 let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
855 let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
856 assert!(parsed.publishes.is_none());
857 assert!(parsed.required_platform_features.is_empty());
858 }
859
860 #[test]
861 fn the_schema_is_the_override_or_the_name_with_underscores() {
862 let mut m = sample_manifest();
863 for (name, schema) in [
864 ("asset-service", "asset_service"),
865 ("note_service", "note_service"),
866 ("Blog-Service", "Blog_Service"),
867 ("a-b-c", "a_b_c"),
868 ] {
869 m.name = name.into();
870 assert_eq!(m.schema(), schema);
871 assert_eq!(service_schema(name, None), schema);
872 assert_eq!(service_schema(name, Some("notes")), "notes");
873 }
874 m.schema_name = Some("notes".into());
875 assert_eq!(m.schema(), "notes");
876 }
877
878 #[test]
879 fn migration_bodies_are_stripped_and_nothing_else() {
880 let mut m = sample_manifest();
881 m.migrations = vec![
882 SqlMigration {
883 version: 1,
884 name: "initial_tables".into(),
885 sql: "CREATE TABLE notes ();".into(),
886 no_transaction: false,
887 },
888 SqlMigration {
889 version: 6,
890 name: "add_note_kind".into(),
891 sql: "-- cufflink:no-transaction\nSELECT 1;".into(),
892 no_transaction: true,
893 },
894 ];
895 let stripped = m.clone().without_migration_bodies();
896 assert!(stripped.migrations.iter().all(|m| m.sql.is_empty()));
897 let shape = |m: &ServiceManifest| -> Vec<(u32, String, bool)> {
898 m.migrations
899 .iter()
900 .map(|m| (m.version, m.name.clone(), m.no_transaction))
901 .collect()
902 };
903 assert_eq!(shape(&stripped), shape(&m));
904 assert_eq!(stripped.name, m.name);
905 assert_eq!(stripped.tables.len(), m.tables.len());
906 }
907
908 #[test]
909 fn the_history_defaults_to_refinerys_table() {
910 let mut m = sample_manifest();
911 assert_eq!(m.migration_history(), MigrationHistory::default());
912 let declared = MigrationHistory {
913 table: "schema_history".into(),
914 baseline: Some(6),
915 ..Default::default()
916 };
917 m.migration_history = Some(declared.clone());
918 assert_eq!(m.migration_history(), declared);
919 }
920
921 #[test]
922 fn schema_ownership_changes_the_schema_hash_only_when_set() {
923 let base = sample_manifest();
924 let hash = |m: &ServiceManifest| m.schema_hash().expect("schema hash");
925 let changed: Vec<ServiceManifest> = vec![
926 ServiceManifest {
927 schema: SchemaOwnership::Adopted,
928 ..base.clone()
929 },
930 ServiceManifest {
931 schema_name: Some("notes".into()),
932 ..base.clone()
933 },
934 ServiceManifest {
935 migrations: vec![SqlMigration {
936 version: 1,
937 name: "a".into(),
938 sql: "SELECT 1;".into(),
939 no_transaction: false,
940 }],
941 ..base.clone()
942 },
943 ServiceManifest {
944 migration_history: Some(MigrationHistory::default()),
945 ..base.clone()
946 },
947 ];
948 let hashes: std::collections::BTreeSet<String> = changed.iter().map(hash).collect();
949 assert_eq!(hashes.len(), changed.len());
950 assert!(!hashes.contains(&hash(&base)));
951 }
952
953 #[test]
954 fn a_schedule_runs_user_code() {
955 let mut m = sample_manifest();
956 m.schedules = serde_json::from_value(serde_json::json!([
957 {"name": "order_retry", "handler": "retry_orders", "spec": {"kind": "every", "secs": 30}}
958 ]))
959 .unwrap();
960 assert!(m.requires_wasm());
961 assert_eq!(m.declared_wasm_handlers(), ["retry_orders"]);
962 assert!(m.http_route("retry_orders").is_none());
963 }
964
965 fn private(name: &str) -> TableDefinition {
966 TableDefinition {
967 name: name.into(),
968 columns: vec![ColumnDefinition {
969 name: "body".into(),
970 column_type: ColumnType::Text,
971 ..Default::default()
972 }],
973 managed: false,
974 ..Default::default()
975 }
976 }
977
978 #[test]
979 fn declared_tables_chains_both_lists() {
980 let mut m = sample_manifest();
981 let names = |m: &ServiceManifest| -> Vec<String> {
982 m.declared_tables().map(|t| t.name.clone()).collect()
983 };
984 assert_eq!(names(&m), ["todos"]);
985 m.private_tables = vec![private("notes"), private("audit_log")];
986 assert_eq!(names(&m), ["todos", "notes", "audit_log"]);
987 m.tables.clear();
988 assert_eq!(names(&m), ["notes", "audit_log"]);
989 }
990
991 #[test]
992 fn get_table_does_not_find_a_private_table() {
993 let mut m = sample_manifest();
994 m.private_tables = vec![private("notes")];
995 assert!(m.get_table("todos").is_some());
996 assert!(m.get_table("notes").is_none());
997 }
998
999 #[test]
1000 fn a_reader_without_the_key_sees_no_private_table() {
1001 let mut m = sample_manifest();
1002 m.private_tables = vec![private("notes")];
1003 let mut wire = serde_json::to_value(&m).unwrap();
1004 assert_eq!(wire["private_tables"][0]["name"], "notes");
1005 wire.as_object_mut().unwrap().remove("private_tables");
1006 let older: ServiceManifest = serde_json::from_value(wire).unwrap();
1007 assert!(older.private_tables.is_empty());
1008 assert!(older.get_table("notes").is_none());
1009 assert_eq!(older.declared_tables().count(), 1);
1010 }
1011
1012 #[test]
1013 fn private_tables_join_the_schema_hash_last_and_only_when_declared() {
1014 use sha2::{Digest, Sha256};
1015 let base = sample_manifest();
1016 let mut declaring = base.clone();
1017 declaring.private_tables = vec![private("notes")];
1018 let parts = |m: &ServiceManifest| {
1019 [
1020 serde_json::to_string(&m.tables).unwrap(),
1021 serde_json::to_string(&m.custom_routes).unwrap(),
1022 serde_json::to_string(&m.subscriptions).unwrap(),
1023 serde_json::to_string(&m.authorization).unwrap(),
1024 serde_json::to_string(&m.on_migrate).unwrap(),
1025 ]
1026 .concat()
1027 };
1028 let hash = |text: String| hex::encode(Sha256::digest(text));
1029 assert_eq!(base.schema_hash().unwrap(), hash(parts(&base)));
1030 assert_eq!(
1031 declaring.schema_hash().unwrap(),
1032 hash(parts(&declaring) + &serde_json::to_string(&declaring.private_tables).unwrap())
1033 );
1034 let mut moved = declaring.clone();
1035 moved.private_tables[0].name = "memos".into();
1036 assert_ne!(
1037 moved.schema_hash().unwrap(),
1038 declaring.schema_hash().unwrap()
1039 );
1040 }
1041}