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        });
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()), // duplicate handler name
752            ..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        // Deduplicated
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            ("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}