Skip to main content

cufflink_types/manifest/
service.rs

1use super::{
2    AuthorizationConfig, CustomRouteDefinition, HandlerType, MigrationHistory, ScheduleDefinition,
3    SqlMigration, StreamDefinition, SubscriptionDefinition, TableDefinition,
4};
5use crate::features;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10/// A service manifest describes the complete definition of a cufflink service.
11/// This is generated by the SDK macros from the user's Rust struct definitions.
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13pub struct ServiceManifest {
14    pub name: String,
15    pub version: Option<String>,
16    pub tables: Vec<TableDefinition>,
17    /// Tables only the service's own SQL reaches. The platform checks them
18    /// against the database as it does `tables`, and serves none of them.
19    #[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    /// Authorization configuration (permission areas and default roles).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub authorization: Option<AuthorizationConfig>,
34    /// Optional WASM handler invoked between additive and destructive schema
35    /// phases of a deploy. Used to backfill or restructure data when columns
36    /// are split, renamed, or have their semantics changed. The handler must
37    /// be idempotent — it may be re-invoked on retried deploys.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub on_migrate: Option<String>,
40    /// NATS subject patterns (with `*`/`>` wildcards) this service is allowed to
41    /// publish or request outside its own `dw.{tenant}.*` namespace. `None` marks
42    /// a manifest built before publish authorization existed: the platform then
43    /// runs in legacy mode (allow + warn). `Some` — always emitted by the current
44    /// SDK — enables enforcement.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub publishes: Option<Vec<String>>,
47    /// Extensions the service's handlers may call through `ext_call`.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub extensions: Vec<String>,
50    /// Extensions a native service serves, and who may call each.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub provides: Vec<ProvidedExtension>,
53    /// Platform capabilities this service requires to run safely. The platform
54    /// rejects a deploy whose required features it does not advertise at
55    /// `/api/version`. The SDK auto-derives this (e.g. `route_posture` when any
56    /// route declares a posture) so a security-relevant manifest cannot be
57    /// silently deployed to a platform too old to enforce it.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub required_platform_features: Vec<String>,
60    /// Serve the auto-generated GraphQL endpoint. Off unless the service
61    /// opts in.
62    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
63    pub graphql: bool,
64    /// How custom routes are reached: `legacy` serves each only by handler
65    /// name under `_fn/`; `strict` also matches their declared paths.
66    #[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
67    pub routing: RoutingMode,
68    /// The JSON key the platform's own error responses for this service
69    /// carry their message under, instead of `error`.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub errors: Option<ErrorProfile>,
72    /// The service's OpenAPI document settings; its presence (or any
73    /// documented route) makes the spec server-relative.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub openapi: Option<OpenApiOptions>,
76    /// Shared JSON Schemas the route docs refer to as
77    /// `#/components/schemas/{name}`.
78    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79    pub components: BTreeMap<String, Value>,
80    /// How the durable consumers of the service's subscriptions are named.
81    #[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    /// Who owns the DDL of the service's schema.
88    #[serde(default, skip_serializing_if = "SchemaOwnership::is_managed")]
89    pub schema: SchemaOwnership,
90    /// The service's Postgres schema; derived from its name when unset.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub schema_name: Option<String>,
93    /// The service's SQL migrations, in version order.
94    #[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    /// `{service}_{handler}`.
104    #[default]
105    Legacy,
106    /// Named for the tenant, the service, the handler and what it consumes.
107    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    /// The platform creates and alters the schema from the manifest.
120    #[default]
121    Managed,
122    /// The schema exists already; the platform never emits DDL for it.
123    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    /// Defaults to the service name.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub title: Option<String>,
137    /// Defaults to the manifest version.
138    #[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    /// Defaults to the service's own `/svc/{tenant}/{service}` base.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub servers: Vec<ServerDoc>,
145    /// The document's tag list.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub tags: Vec<TagDoc>,
148    /// The HTTP bearer scheme authenticated routes name; `bearer_auth`
149    /// when unset.
150    #[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    /// Whether `key` is a valid error key: `[a-z_][a-z0-9_]{0,63}`.
185    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    /// A Rust binary supervised by the extension runner that serves the
218    /// extensions it `provides`.
219    Native,
220}
221
222impl ServiceMode {
223    /// The mode as a manifest names it.
224    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    /// Whether the platform runs the service itself: its schema, module,
235    /// database role, subscriptions and schedules. A web app and a native
236    /// service run in a runner of their own and have none of them.
237    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/// An extension a native service serves.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ProvidedExtension {
248    pub name: String,
249    /// Service names, or [`ANY_CALLER`](crate::ext::ANY_CALLER).
250    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    /// The served table named `name`; a private table is never found.
275    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
276        self.tables.iter().find(|t| t.name == name)
277    }
278
279    /// Every table the service declares, served or not.
280    pub fn declared_tables(&self) -> impl Iterator<Item = &TableDefinition> {
281        self.tables.iter().chain(&self.private_tables)
282    }
283
284    /// Whether this manifest needs a WASM artifact bundled at deploy time.
285    ///
286    /// A WASM module is required when the service runs user code: explicit
287    /// `mode: wasm`, an `on_migrate` schema hook, table CRUD lifecycle hooks,
288    /// a schedule, or any `Wasm` subscription/custom-route handler. The CLI uses this to
289    /// decide whether to compile a WASM artifact; the platform uses it to
290    /// reject deploys that arrive without one.
291    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    /// All handler names this manifest expects the WASM module to export.
309    /// Used at deploy time to reject manifests whose declared handlers are
310    /// missing from the bundled binary.
311    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    /// The `custom_routes` entry exposing `handler` over the HTTP `_fn/`
351    /// surface, if any. Only custom routes are HTTP-invocable; subscription,
352    /// `on_migrate`, and table-hook handlers run solely via their internal
353    /// triggers, so a handler absent here must not be dispatched from an
354    /// inbound request. Matched by name alone — the `_fn/` route accepts every
355    /// method, so a verb-aware match would demote a job handler to inline
356    /// execution on the wrong verb.
357    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    /// The position of [`http_route`](Self::http_route) in `custom_routes`.
363    pub fn http_route_index(&self, handler: &str) -> Option<usize> {
364        self.custom_routes.iter().position(|r| r.handler == handler)
365    }
366
367    /// Whether this manifest was built by a posture-aware SDK. True when any
368    /// custom route carries a posture — the current SDK emits a posture on
369    /// every route, so a posture-aware manifest is all-or-nothing and a lone
370    /// undeclared route among declared ones signals tampering or a bug.
371    pub fn is_posture_aware(&self) -> bool {
372        self.custom_routes.iter().any(|r| r.posture.is_some())
373    }
374
375    /// The service's Postgres schema: `schema_name`, or the service name
376    /// with `-` as `_`. Every other derivation of it is a bug.
377    pub fn schema(&self) -> String {
378        service_schema(&self.name, self.schema_name.as_deref())
379    }
380
381    /// Where the service's applied SQL migrations are recorded.
382    pub fn migration_history(&self) -> MigrationHistory {
383        self.migration_history.clone().unwrap_or_default()
384    }
385
386    /// The manifest without the text of its SQL migrations, which only a
387    /// deploy reads.
388    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    /// Platform features required by service-level fields.
396    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    /// SHA-256 of the whole manifest: the deploy skip check compares this, so
415    /// a change to any field (`publishes`, `events`, `mode`, …) deploys.
416    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    /// SHA-256 of the schema-bearing fields, stored on schema snapshots. A
422    /// field added since contributes only when it is set, so the hash of a
423    /// manifest without it never changes.
424    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
461/// The Postgres schema of the service `name`: the `schema_name` it
462/// declares, or its name with `-` as `_`.
463pub 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()),
753            ..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);
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            ("stock-service", "stock_service"),
864            ("loan_service", "loan_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_loan_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}