Skip to main content

cufflink_types/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub mod openapi;
5pub mod policy;
6pub mod slug;
7
8/// A service manifest describes the complete definition of a cufflink service.
9/// This is generated by the SDK macros from the user's Rust struct definitions.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ServiceManifest {
12    pub name: String,
13    pub version: Option<String>,
14    pub tables: Vec<TableDefinition>,
15    #[serde(default)]
16    pub cells: Vec<CellDefinition>,
17    #[serde(default)]
18    pub events: Vec<EventDefinition>,
19    #[serde(default)]
20    pub subscriptions: Vec<SubscriptionDefinition>,
21    #[serde(default)]
22    pub custom_routes: Vec<CustomRouteDefinition>,
23    #[serde(default)]
24    pub mode: ServiceMode,
25    /// Authorization configuration (permission areas and default roles).
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub authorization: Option<AuthorizationConfig>,
28    /// Optional WASM handler invoked between additive and destructive schema
29    /// phases of a deploy. Used to backfill or restructure data when columns
30    /// are split, renamed, or have their semantics changed. The handler must
31    /// be idempotent — it may be re-invoked on retried deploys.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub on_migrate: Option<String>,
34    /// NATS subject patterns (with `*`/`>` wildcards) this service is allowed to
35    /// publish or request outside its own `dw.{tenant}.*` namespace. `None` marks
36    /// a manifest built before publish authorization existed: the platform then
37    /// runs in legacy mode (allow + warn). `Some` — always emitted by the current
38    /// SDK — enables enforcement.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub publishes: Option<Vec<String>>,
41    /// Platform capabilities this service requires to run safely. The platform
42    /// rejects a deploy whose required features it does not advertise at
43    /// `/api/version`. The SDK auto-derives this (e.g. `route_posture` when any
44    /// route declares a posture) so a security-relevant manifest cannot be
45    /// silently deployed to a platform too old to enforce it.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub required_platform_features: Vec<String>,
48    /// Serve the auto-generated GraphQL endpoint. Off unless the service
49    /// opts in.
50    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51    pub graphql: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
55#[serde(rename_all = "lowercase")]
56pub enum ServiceMode {
57    #[default]
58    Crud,
59    Wasm,
60    Container,
61    Web,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TableDefinition {
66    pub name: String,
67    pub columns: Vec<ColumnDefinition>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub indexes: Vec<IndexDefinition>,
70    #[serde(default)]
71    pub soft_delete: bool,
72    /// Column name that stores the owner's user ID for row-level security.
73    /// When set, CRUD operations are scoped to the authenticated user's records.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub owner_field: Option<String>,
76    /// When true, all CRUD operations on this table require authentication.
77    /// Unauthenticated requests receive a 401 Unauthorized response.
78    #[serde(default)]
79    pub auth_required: bool,
80    /// Permission area that controls access to this table's CRUD operations.
81    /// When set, platform checks "{area}:view" for GET, "{area}:create" for POST,
82    /// "{area}:edit" for PUT, "{area}:delete" for DELETE.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub permission_area: Option<String>,
85    /// Self-service ownership: when true (requires `owner_field`), owning a row
86    /// authorizes access to it — the row-level scoping by `owner_field` IS the
87    /// authorization, so the `permission_area` grant is NOT required for these
88    /// (inherently owner-scoped) CRUD operations. Use for user-owned data a user
89    /// manages themselves (their items, addresses, orders). Defaults false, so
90    /// existing tables keep requiring `permission_area`; cross-owner/staff access
91    /// stays gated by RBAC (typically via dedicated handlers, not owner-scoped CRUD).
92    #[serde(default)]
93    pub owner_self_service: bool,
94    /// CRUD lifecycle hooks — WASM handler names called before/after operations.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub hooks: Option<TableHooks>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ColumnDefinition {
101    pub name: String,
102    pub column_type: ColumnType,
103    #[serde(default)]
104    pub primary_key: bool,
105    #[serde(default)]
106    pub nullable: bool,
107    #[serde(default)]
108    pub auto_generate: bool,
109    pub default_value: Option<String>,
110    /// Foreign key reference in "table.column" format
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub references: Option<String>,
113    /// Whether to cascade deletes on the FK
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub on_delete: Option<ForeignKeyAction>,
116    /// Whether this column has a unique constraint
117    #[serde(default)]
118    pub unique: bool,
119    /// Validation rules for this column
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub validations: Vec<ValidationRule>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125#[serde(tag = "rule", rename_all = "snake_case")]
126// Note: ValidationRule contains f64 fields (Min/Max), so Eq cannot be derived
127pub enum ValidationRule {
128    /// Regex pattern match
129    Regex { pattern: String },
130    /// Minimum numeric value
131    Min { value: f64 },
132    /// Maximum numeric value
133    Max { value: f64 },
134    /// Minimum string length
135    MinLength { value: usize },
136    /// Maximum string length
137    MaxLength { value: usize },
138    /// Must be one of the listed values
139    OneOf { values: Vec<String> },
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct IndexDefinition {
144    pub name: String,
145    pub columns: Vec<String>,
146    #[serde(default)]
147    pub unique: bool,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "snake_case")]
152pub enum ForeignKeyAction {
153    Cascade,
154    SetNull,
155    Restrict,
156    NoAction,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160#[serde(rename_all = "lowercase")]
161pub enum ColumnType {
162    Uuid,
163    Text,
164    Integer,
165    BigInteger,
166    Float,
167    Double,
168    Boolean,
169    Timestamp,
170    Date,
171    Jsonb,
172}
173
174impl ColumnType {
175    pub fn to_sql(&self) -> &str {
176        match self {
177            ColumnType::Uuid => "UUID",
178            ColumnType::Text => "TEXT",
179            ColumnType::Integer => "INTEGER",
180            ColumnType::BigInteger => "BIGINT",
181            ColumnType::Float => "REAL",
182            ColumnType::Double => "DOUBLE PRECISION",
183            ColumnType::Boolean => "BOOLEAN",
184            ColumnType::Timestamp => "TIMESTAMPTZ",
185            ColumnType::Date => "DATE",
186            ColumnType::Jsonb => "JSONB",
187        }
188    }
189
190    /// Map to the filter column type for query param parsing
191    pub fn filter_type(&self) -> &str {
192        match self {
193            ColumnType::Uuid => "uuid",
194            ColumnType::Text => "text",
195            ColumnType::Integer | ColumnType::BigInteger => "integer",
196            ColumnType::Float | ColumnType::Double => "double",
197            ColumnType::Boolean => "boolean",
198            ColumnType::Timestamp => "timestamp",
199            ColumnType::Date => "date",
200            ColumnType::Jsonb => "jsonb",
201        }
202    }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct CellDefinition {
207    pub name: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct EventDefinition {
212    pub name: String,
213    pub table: String,
214    pub action: EventAction,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum EventAction {
220    Create,
221    Update,
222    Delete,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct SubscriptionDefinition {
227    pub subject: String,
228    pub handler: String,
229    pub handler_type: HandlerType,
230    #[serde(default)]
231    pub config: HashMap<String, String>,
232    /// Explicit JetStream stream to host this subscription's consumer. When
233    /// `None`, the platform uses the tenant stream (`DW_{tenant}`).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub stream: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum HandlerType {
241    DeleteCascade,
242    UpdateField,
243    Webhook,
244    Wasm,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct CustomRouteDefinition {
249    pub method: String,
250    pub path: String,
251    pub handler: String,
252    /// When present, the HTTP route accepts the request, persists a
253    /// `wasm_jobs` row, publishes to JetStream, and returns `202 Accepted`
254    /// with `{job_id}`. The handler executes asynchronously on a worker
255    /// with no inline 30s wall.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub job: Option<JobConfig>,
258    /// Who may call this route. `None` marks a manifest built before route
259    /// postures existed: the platform runs it in legacy mode (best-effort auth,
260    /// no enforcement) and warns. `Some` — always emitted by the current SDK —
261    /// makes the platform enforce the declared posture.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub posture: Option<RoutePosture>,
264    /// Pass the caller's `authorization`, `cookie` and `proxy-authorization`
265    /// headers into the guest. Without it they are stripped once the tenant's
266    /// `tenant_binding` policy is enforced.
267    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
268    pub forward_auth: bool,
269}
270
271/// The authorization posture of a custom route, enforced by the platform so a
272/// guest handler cannot forget it.
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(tag = "kind", rename_all = "snake_case")]
275pub enum RoutePosture {
276    /// Deliberately anonymous. `reason` is mandatory so every public route is
277    /// greppable and reviewable (third-party webhooks, signup, public profiles).
278    Public { reason: String },
279    /// Any authenticated tenant principal.
280    Authenticated,
281    /// Authenticated and holding the `{area}:{operation}` permission.
282    Permission { area: String, operation: String },
283    /// Authenticated; the guest scopes the operation to rows whose `field`
284    /// equals the caller's subject. The platform enforces authentication.
285    Owner { field: String },
286}
287
288impl RoutePosture {
289    /// Whether a caller with no credentials can reach this route.
290    pub fn is_anonymous(&self) -> bool {
291        matches!(self, RoutePosture::Public { .. })
292    }
293
294    /// Short human label for audit output.
295    pub fn label(&self) -> &'static str {
296        match self {
297            RoutePosture::Public { .. } => "public",
298            RoutePosture::Authenticated => "authenticated",
299            RoutePosture::Permission { .. } => "permission",
300            RoutePosture::Owner { .. } => "owner",
301        }
302    }
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
306pub struct JobConfig {
307    /// Per-handler wall-clock ceiling enforced by the worker.
308    pub timeout_secs: u32,
309    /// Maximum delivery attempts before the job is permanently failed.
310    /// `1` disables retry-on-crash.
311    pub max_attempts: u32,
312}
313
314impl JobConfig {
315    pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
316    pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
317}
318
319impl Default for JobConfig {
320    fn default() -> Self {
321        Self {
322            timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
323            max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
324        }
325    }
326}
327
328/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
329#[derive(Debug, Clone, Serialize, Deserialize, Default)]
330pub struct TableHooks {
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub before_create: Option<String>,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub after_create: Option<String>,
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub before_update: Option<String>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub after_update: Option<String>,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub before_delete: Option<String>,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub after_delete: Option<String>,
343}
344
345/// Authorization configuration declared in the service manifest.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct AuthorizationConfig {
348    /// Permission areas declared by this service.
349    #[serde(default)]
350    pub areas: Vec<PermissionAreaDef>,
351    /// Default roles to seed on first deployment.
352    #[serde(default)]
353    pub default_roles: Vec<DefaultRoleDef>,
354}
355
356/// A permission area with its supported operations.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct PermissionAreaDef {
359    pub name: String,
360    #[serde(default)]
361    pub operations: Vec<String>,
362}
363
364/// A default role seeded on deployment.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct DefaultRoleDef {
367    pub name: String,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub description: Option<String>,
370    #[serde(default)]
371    pub permissions: Vec<String>,
372}
373
374/// Description of the changes a deploy is about to apply, passed as the
375/// payload to the `on_migrate` hook so user code can decide which backfill
376/// blocks should fire.
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378pub struct SchemaDiff {
379    #[serde(default)]
380    pub added_tables: Vec<String>,
381    #[serde(default)]
382    pub dropped_tables: Vec<String>,
383    /// (table, column) pairs for newly added columns. In phase 2 these
384    /// already exist in the database (added in phase 1) but may be NULL on
385    /// existing rows; the hook is the place to backfill them.
386    #[serde(default)]
387    pub added_columns: Vec<(String, String)>,
388    /// (table, column) pairs for columns that will be dropped in phase 3.
389    /// During phase 2 these are still present in the database, so the hook
390    /// can read them to populate new columns before they disappear.
391    #[serde(default)]
392    pub dropped_columns: Vec<(String, String)>,
393    /// (table, column, old_type, new_type)
394    #[serde(default)]
395    pub type_changes: Vec<(String, String, String, String)>,
396    /// (table, column, new_nullable)
397    #[serde(default)]
398    pub nullability_changes: Vec<(String, String, bool)>,
399}
400
401impl SchemaDiff {
402    pub fn added_column(&self, table: &str, col: &str) -> bool {
403        self.added_columns
404            .iter()
405            .any(|(t, c)| t == table && c == col)
406    }
407
408    pub fn dropped_column(&self, table: &str, col: &str) -> bool {
409        self.dropped_columns
410            .iter()
411            .any(|(t, c)| t == table && c == col)
412    }
413
414    pub fn added_table(&self, table: &str) -> bool {
415        self.added_tables.iter().any(|t| t == table)
416    }
417
418    pub fn dropped_table(&self, table: &str) -> bool {
419        self.dropped_tables.iter().any(|t| t == table)
420    }
421
422    pub fn is_empty(&self) -> bool {
423        self.added_tables.is_empty()
424            && self.dropped_tables.is_empty()
425            && self.added_columns.is_empty()
426            && self.dropped_columns.is_empty()
427            && self.type_changes.is_empty()
428            && self.nullability_changes.is_empty()
429    }
430}
431
432impl ServiceManifest {
433    /// Get table definition by name
434    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
435        self.tables.iter().find(|t| t.name == name)
436    }
437
438    /// Whether this manifest needs a WASM artifact bundled at deploy time.
439    ///
440    /// A WASM module is required when the service runs user code: explicit
441    /// `mode: wasm`, an `on_migrate` schema hook, table CRUD lifecycle hooks,
442    /// or any `Wasm` subscription/custom-route handler. The CLI uses this to
443    /// decide whether to compile a WASM artifact; the platform uses it to
444    /// reject deploys that arrive without one.
445    pub fn requires_wasm(&self) -> bool {
446        if self.mode == ServiceMode::Wasm {
447            return true;
448        }
449        if self.on_migrate.is_some() {
450            return true;
451        }
452        if self.tables.iter().any(|t| t.hooks.is_some()) {
453            return true;
454        }
455        if !self.custom_routes.is_empty() {
456            return true;
457        }
458        if self
459            .subscriptions
460            .iter()
461            .any(|s| matches!(s.handler_type, HandlerType::Wasm))
462        {
463            return true;
464        }
465        false
466    }
467
468    /// All handler names this manifest expects the WASM module to export.
469    /// Used at deploy time to reject manifests whose declared handlers are
470    /// missing from the bundled binary.
471    pub fn declared_wasm_handlers(&self) -> Vec<&str> {
472        let mut handlers: Vec<&str> = Vec::new();
473        if let Some(ref h) = self.on_migrate {
474            handlers.push(h.as_str());
475        }
476        for route in &self.custom_routes {
477            handlers.push(route.handler.as_str());
478        }
479        for sub in &self.subscriptions {
480            if matches!(sub.handler_type, HandlerType::Wasm) {
481                handlers.push(sub.handler.as_str());
482            }
483        }
484        for table in &self.tables {
485            if let Some(ref hooks) = table.hooks {
486                for h in [
487                    hooks.before_create.as_deref(),
488                    hooks.after_create.as_deref(),
489                    hooks.before_update.as_deref(),
490                    hooks.after_update.as_deref(),
491                    hooks.before_delete.as_deref(),
492                    hooks.after_delete.as_deref(),
493                ]
494                .iter()
495                .flatten()
496                {
497                    handlers.push(h);
498                }
499            }
500        }
501        handlers.sort();
502        handlers.dedup();
503        handlers
504    }
505
506    /// The `custom_routes` entry exposing `handler` over the HTTP `_fn/`
507    /// surface, if any. Only custom routes are HTTP-invocable; subscription,
508    /// `on_migrate`, and table-hook handlers run solely via their internal
509    /// triggers, so a handler absent here must not be dispatched from an
510    /// inbound request. Matched by name alone — the `_fn/` route accepts every
511    /// method, so a verb-aware match would demote a job handler to inline
512    /// execution on the wrong verb.
513    pub fn http_route(&self, handler: &str) -> Option<&CustomRouteDefinition> {
514        self.custom_routes.iter().find(|r| r.handler == handler)
515    }
516
517    /// Whether this manifest was built by a posture-aware SDK. True when any
518    /// custom route carries a posture — the current SDK emits a posture on
519    /// every route, so a posture-aware manifest is all-or-nothing and a lone
520    /// undeclared route among declared ones signals tampering or a bug.
521    pub fn is_posture_aware(&self) -> bool {
522        self.custom_routes.iter().any(|r| r.posture.is_some())
523    }
524
525    /// SHA-256 of the whole manifest: the deploy skip check compares this, so
526    /// a change to any field (`publishes`, `events`, `mode`, …) deploys.
527    pub fn deploy_hash(&self) -> String {
528        use sha2::{Digest, Sha256};
529        hex::encode(Sha256::digest(serde_json::to_vec(self).unwrap_or_default()))
530    }
531
532    /// SHA-256 of the schema-bearing fields, stored on schema snapshots.
533    pub fn schema_hash(&self) -> String {
534        use sha2::{Digest, Sha256};
535        let mut hasher = Sha256::new();
536        hasher.update(
537            serde_json::to_string(&self.tables)
538                .unwrap_or_default()
539                .as_bytes(),
540        );
541        hasher.update(
542            serde_json::to_string(&self.custom_routes)
543                .unwrap_or_default()
544                .as_bytes(),
545        );
546        hasher.update(
547            serde_json::to_string(&self.subscriptions)
548                .unwrap_or_default()
549                .as_bytes(),
550        );
551        hasher.update(
552            serde_json::to_string(&self.authorization)
553                .unwrap_or_default()
554                .as_bytes(),
555        );
556        hasher.update(
557            serde_json::to_string(&self.on_migrate)
558                .unwrap_or_default()
559                .as_bytes(),
560        );
561        hex::encode(hasher.finalize())
562    }
563}
564
565impl TableDefinition {
566    /// Get the primary key column
567    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
568        self.columns.iter().find(|c| c.primary_key)
569    }
570
571    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
572    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
573        self.columns
574            .iter()
575            .filter(|c| !c.auto_generate || !c.primary_key)
576            .collect()
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    fn sample_manifest() -> ServiceManifest {
585        ServiceManifest {
586            name: "test-service".into(),
587            version: Some("1.0.0".into()),
588            tables: vec![TableDefinition {
589                name: "todos".into(),
590                columns: vec![
591                    ColumnDefinition {
592                        name: "id".into(),
593                        column_type: ColumnType::Uuid,
594                        primary_key: true,
595                        nullable: false,
596                        auto_generate: true,
597                        default_value: None,
598                        references: None,
599                        on_delete: None,
600                        unique: false,
601                        validations: vec![],
602                    },
603                    ColumnDefinition {
604                        name: "title".into(),
605                        column_type: ColumnType::Text,
606                        primary_key: false,
607                        nullable: false,
608                        auto_generate: false,
609                        default_value: None,
610                        references: None,
611                        on_delete: None,
612                        unique: false,
613                        validations: vec![],
614                    },
615                    ColumnDefinition {
616                        name: "user_id".into(),
617                        column_type: ColumnType::Uuid,
618                        primary_key: false,
619                        nullable: false,
620                        auto_generate: false,
621                        default_value: None,
622                        references: Some("users.id".into()),
623                        on_delete: Some(ForeignKeyAction::Cascade),
624                        unique: false,
625                        validations: vec![],
626                    },
627                ],
628                indexes: vec![],
629                soft_delete: false,
630                owner_field: None,
631                auth_required: false,
632                permission_area: None,
633                owner_self_service: false,
634                hooks: None,
635            }],
636            cells: vec![],
637            events: vec![],
638            subscriptions: vec![],
639            custom_routes: vec![],
640            mode: ServiceMode::Crud,
641            authorization: None,
642            on_migrate: None,
643            publishes: None,
644            required_platform_features: vec![],
645            graphql: false,
646        }
647    }
648
649    #[test]
650    fn test_manifest_serialization_roundtrip() {
651        let manifest = sample_manifest();
652        let json = serde_json::to_string(&manifest).unwrap();
653        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
654        assert_eq!(parsed.name, "test-service");
655        assert_eq!(parsed.tables.len(), 1);
656        assert_eq!(parsed.tables[0].columns.len(), 3);
657    }
658
659    #[test]
660    fn test_get_table() {
661        let manifest = sample_manifest();
662        assert!(manifest.get_table("todos").is_some());
663        assert!(manifest.get_table("nonexistent").is_none());
664    }
665
666    #[test]
667    fn test_primary_key() {
668        let manifest = sample_manifest();
669        let table = manifest.get_table("todos").unwrap();
670        let pk = table.primary_key().unwrap();
671        assert_eq!(pk.name, "id");
672        assert!(pk.auto_generate);
673    }
674
675    #[test]
676    fn test_schema_hash_deterministic() {
677        let m1 = sample_manifest();
678        let m2 = sample_manifest();
679        assert_eq!(m1.schema_hash(), m2.schema_hash());
680    }
681
682    #[test]
683    fn test_schema_hash_changes() {
684        let mut m1 = sample_manifest();
685        let m2 = sample_manifest();
686        m1.tables[0].columns.push(ColumnDefinition {
687            name: "extra".into(),
688            column_type: ColumnType::Text,
689            primary_key: false,
690            nullable: true,
691            auto_generate: false,
692            default_value: None,
693            references: None,
694            on_delete: None,
695            unique: false,
696            validations: vec![],
697        });
698        assert_ne!(m1.schema_hash(), m2.schema_hash());
699    }
700
701    #[test]
702    fn test_column_type_to_sql() {
703        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
704        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
705        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
706        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
707        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
708        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
709        assert_eq!(ColumnType::Date.to_sql(), "DATE");
710        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
711    }
712
713    #[test]
714    fn test_fk_serialization() {
715        let manifest = sample_manifest();
716        let json = serde_json::to_string(&manifest).unwrap();
717        assert!(json.contains("\"references\":\"users.id\""));
718        assert!(json.contains("\"on_delete\":\"cascade\""));
719
720        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
721        let user_id_col = &parsed.tables[0].columns[2];
722        assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
723        assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
724    }
725
726    #[test]
727    fn test_fk_not_serialized_when_none() {
728        let col = ColumnDefinition {
729            name: "title".into(),
730            column_type: ColumnType::Text,
731            primary_key: false,
732            nullable: false,
733            auto_generate: false,
734            default_value: None,
735            references: None,
736            on_delete: None,
737            unique: false,
738            validations: vec![],
739        };
740        let json = serde_json::to_string(&col).unwrap();
741        assert!(!json.contains("references"));
742        assert!(!json.contains("on_delete"));
743    }
744
745    #[test]
746    fn test_unique_column_serialization() {
747        let col = ColumnDefinition {
748            name: "email".into(),
749            column_type: ColumnType::Text,
750            primary_key: false,
751            nullable: false,
752            auto_generate: false,
753            default_value: None,
754            references: None,
755            on_delete: None,
756            unique: true,
757            validations: vec![],
758        };
759        let json = serde_json::to_string(&col).unwrap();
760        assert!(json.contains("\"unique\":true"));
761        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
762        assert!(parsed.unique);
763    }
764
765    #[test]
766    fn test_index_serialization() {
767        let table = TableDefinition {
768            name: "users".into(),
769            columns: vec![],
770            indexes: vec![IndexDefinition {
771                name: "idx_users_email".into(),
772                columns: vec!["email".into()],
773                unique: true,
774            }],
775            soft_delete: false,
776            owner_field: None,
777            auth_required: false,
778            permission_area: None,
779            owner_self_service: false,
780            hooks: None,
781        };
782        let json = serde_json::to_string(&table).unwrap();
783        assert!(json.contains("idx_users_email"));
784        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
785        assert_eq!(parsed.indexes.len(), 1);
786        assert!(parsed.indexes[0].unique);
787    }
788
789    #[test]
790    fn test_indexes_not_serialized_when_empty() {
791        let table = TableDefinition {
792            name: "users".into(),
793            columns: vec![],
794            indexes: vec![],
795            soft_delete: false,
796            owner_field: None,
797            auth_required: false,
798            permission_area: None,
799            owner_self_service: false,
800            hooks: None,
801        };
802        let json = serde_json::to_string(&table).unwrap();
803        assert!(!json.contains("indexes"));
804    }
805
806    #[test]
807    fn test_service_mode_default() {
808        let json = r#"{"name":"svc","tables":[]}"#;
809        let m: ServiceManifest = serde_json::from_str(json).unwrap();
810        assert_eq!(m.mode, ServiceMode::Crud);
811    }
812
813    #[test]
814    fn test_owner_field_serialization() {
815        let table = TableDefinition {
816            name: "notes".into(),
817            columns: vec![],
818            indexes: vec![],
819            soft_delete: false,
820            owner_field: Some("user_id".into()),
821            auth_required: false,
822            permission_area: None,
823            owner_self_service: false,
824            hooks: None,
825        };
826        let json = serde_json::to_string(&table).unwrap();
827        assert!(json.contains("\"owner_field\":\"user_id\""));
828        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
829        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
830    }
831
832    #[test]
833    fn test_owner_field_not_serialized_when_none() {
834        let table = TableDefinition {
835            name: "notes".into(),
836            columns: vec![],
837            indexes: vec![],
838            soft_delete: false,
839            owner_field: None,
840            auth_required: false,
841            permission_area: None,
842            owner_self_service: false,
843            hooks: None,
844        };
845        let json = serde_json::to_string(&table).unwrap();
846        assert!(!json.contains("owner_field"));
847    }
848
849    #[test]
850    fn test_owner_field_defaults_to_none() {
851        let json = r#"{"name":"notes","columns":[]}"#;
852        let table: TableDefinition = serde_json::from_str(json).unwrap();
853        assert!(table.owner_field.is_none());
854    }
855
856    #[test]
857    fn test_auth_required_serialization() {
858        let table = TableDefinition {
859            name: "orders".into(),
860            columns: vec![],
861            indexes: vec![],
862            soft_delete: false,
863            owner_field: None,
864            auth_required: true,
865            permission_area: None,
866            owner_self_service: false,
867            hooks: None,
868        };
869        let json = serde_json::to_string(&table).unwrap();
870        assert!(json.contains("\"auth_required\":true"));
871        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
872        assert!(parsed.auth_required);
873    }
874
875    #[test]
876    fn test_auth_required_defaults_to_false() {
877        let json = r#"{"name":"orders","columns":[]}"#;
878        let table: TableDefinition = serde_json::from_str(json).unwrap();
879        assert!(!table.auth_required);
880    }
881
882    #[test]
883    fn test_on_migrate_defaults_to_none() {
884        let json = r#"{"name":"svc","tables":[]}"#;
885        let m: ServiceManifest = serde_json::from_str(json).unwrap();
886        assert!(m.on_migrate.is_none());
887    }
888
889    #[test]
890    fn test_on_migrate_serialization_roundtrip() {
891        let mut m = sample_manifest();
892        m.on_migrate = Some("handle_on_migrate".into());
893        let json = serde_json::to_string(&m).unwrap();
894        assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
895        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
896        assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
897    }
898
899    #[test]
900    fn test_on_migrate_skipped_when_none() {
901        let m = sample_manifest();
902        let json = serde_json::to_string(&m).unwrap();
903        assert!(!json.contains("on_migrate"));
904    }
905
906    #[test]
907    fn deploy_hash_covers_fields_schema_hash_ignores() {
908        let m1 = sample_manifest();
909        let mut m2 = sample_manifest();
910        assert_eq!(m1.deploy_hash(), m2.deploy_hash());
911        m2.publishes = Some(vec!["acme.orders.>".into()]);
912        assert_ne!(m1.deploy_hash(), m2.deploy_hash());
913        assert_eq!(m1.schema_hash(), m2.schema_hash());
914    }
915
916    #[test]
917    fn test_on_migrate_changes_schema_hash() {
918        let m1 = sample_manifest();
919        let mut m2 = sample_manifest();
920        m2.on_migrate = Some("handle_on_migrate".into());
921        assert_ne!(m1.schema_hash(), m2.schema_hash());
922    }
923
924    #[test]
925    fn test_subscription_stream_defaults_to_none_in_legacy_json() {
926        let json = r#"{"subject":"x","handler":"h","handler_type":"wasm"}"#;
927        let sub: SubscriptionDefinition = serde_json::from_str(json).unwrap();
928        assert!(sub.stream.is_none());
929    }
930
931    #[test]
932    fn test_subscription_stream_roundtrip_when_set() {
933        let sub = SubscriptionDefinition {
934            subject: "cufflink.tick.5min".into(),
935            handler: "handle_tick_resync".into(),
936            handler_type: HandlerType::Wasm,
937            config: HashMap::new(),
938            stream: Some("CUFFLINK_TICKS".into()),
939        };
940        let json = serde_json::to_string(&sub).unwrap();
941        assert!(json.contains("\"stream\":\"CUFFLINK_TICKS\""));
942        let parsed: SubscriptionDefinition = serde_json::from_str(&json).unwrap();
943        assert_eq!(parsed.stream.as_deref(), Some("CUFFLINK_TICKS"));
944    }
945
946    #[test]
947    fn test_subscription_stream_skipped_when_none() {
948        let sub = SubscriptionDefinition {
949            subject: "x".into(),
950            handler: "h".into(),
951            handler_type: HandlerType::Wasm,
952            config: HashMap::new(),
953            stream: None,
954        };
955        let json = serde_json::to_string(&sub).unwrap();
956        assert!(!json.contains("stream"));
957    }
958
959    #[test]
960    fn test_schema_diff_helpers() {
961        let diff = SchemaDiff {
962            added_tables: vec!["batches".into()],
963            dropped_tables: vec!["legacy_batches".into()],
964            added_columns: vec![("pickups".into(), "min".into())],
965            dropped_columns: vec![("pickups".into(), "midpoint".into())],
966            type_changes: vec![],
967            nullability_changes: vec![],
968        };
969        assert!(!diff.is_empty());
970        assert!(diff.added_table("batches"));
971        assert!(!diff.added_table("nope"));
972        assert!(diff.dropped_table("legacy_batches"));
973        assert!(diff.added_column("pickups", "min"));
974        assert!(!diff.added_column("pickups", "max"));
975        assert!(diff.dropped_column("pickups", "midpoint"));
976    }
977
978    #[test]
979    fn test_schema_diff_is_empty_default() {
980        assert!(SchemaDiff::default().is_empty());
981    }
982
983    #[test]
984    fn test_schema_diff_serialization_roundtrip() {
985        let diff = SchemaDiff {
986            added_tables: vec![],
987            dropped_tables: vec![],
988            added_columns: vec![("t".into(), "c".into())],
989            dropped_columns: vec![],
990            type_changes: vec![("t".into(), "c".into(), "TEXT".into(), "INTEGER".into())],
991            nullability_changes: vec![("t".into(), "c".into(), false)],
992        };
993        let json = serde_json::to_string(&diff).unwrap();
994        let parsed: SchemaDiff = serde_json::from_str(&json).unwrap();
995        assert!(parsed.added_column("t", "c"));
996        assert_eq!(parsed.type_changes.len(), 1);
997        assert_eq!(parsed.nullability_changes.len(), 1);
998    }
999
1000    #[test]
1001    fn test_requires_wasm_false_for_plain_crud() {
1002        let m = sample_manifest();
1003        assert!(!m.requires_wasm());
1004    }
1005
1006    #[test]
1007    fn test_requires_wasm_true_for_wasm_mode() {
1008        let mut m = sample_manifest();
1009        m.mode = ServiceMode::Wasm;
1010        assert!(m.requires_wasm());
1011    }
1012
1013    #[test]
1014    fn test_requires_wasm_true_for_on_migrate() {
1015        let mut m = sample_manifest();
1016        m.on_migrate = Some("handle_on_migrate".into());
1017        assert!(m.requires_wasm());
1018    }
1019
1020    #[test]
1021    fn test_requires_wasm_true_for_table_hooks() {
1022        let mut m = sample_manifest();
1023        m.tables[0].hooks = Some(TableHooks {
1024            before_create: Some("validate".into()),
1025            ..Default::default()
1026        });
1027        assert!(m.requires_wasm());
1028    }
1029
1030    #[test]
1031    fn test_requires_wasm_true_for_custom_routes() {
1032        let mut m = sample_manifest();
1033        m.custom_routes.push(CustomRouteDefinition {
1034            method: "GET".into(),
1035            path: "/hello".into(),
1036            handler: "hello".into(),
1037            job: None,
1038            posture: None,
1039            forward_auth: false,
1040        });
1041        assert!(m.requires_wasm());
1042    }
1043
1044    #[test]
1045    fn test_requires_wasm_true_for_wasm_subscription() {
1046        let mut m = sample_manifest();
1047        m.subscriptions.push(SubscriptionDefinition {
1048            subject: "events.foo".into(),
1049            handler: "on_foo".into(),
1050            handler_type: HandlerType::Wasm,
1051            config: HashMap::new(),
1052            stream: None,
1053        });
1054        assert!(m.requires_wasm());
1055    }
1056
1057    #[test]
1058    fn test_requires_wasm_false_for_non_wasm_subscription() {
1059        let mut m = sample_manifest();
1060        m.subscriptions.push(SubscriptionDefinition {
1061            subject: "events.foo".into(),
1062            handler: "on_foo".into(),
1063            handler_type: HandlerType::Webhook,
1064            config: HashMap::new(),
1065            stream: None,
1066        });
1067        assert!(!m.requires_wasm());
1068    }
1069
1070    #[test]
1071    fn test_declared_wasm_handlers_collects_all_sources() {
1072        let mut m = sample_manifest();
1073        m.on_migrate = Some("on_mig".into());
1074        m.custom_routes.push(CustomRouteDefinition {
1075            method: "GET".into(),
1076            path: "/h".into(),
1077            handler: "route_h".into(),
1078            job: None,
1079            posture: None,
1080            forward_auth: false,
1081        });
1082        m.subscriptions.push(SubscriptionDefinition {
1083            subject: "x".into(),
1084            handler: "sub_h".into(),
1085            handler_type: HandlerType::Wasm,
1086            config: HashMap::new(),
1087            stream: None,
1088        });
1089        m.subscriptions.push(SubscriptionDefinition {
1090            subject: "y".into(),
1091            handler: "wh_ignored".into(),
1092            handler_type: HandlerType::Webhook,
1093            config: HashMap::new(),
1094            stream: None,
1095        });
1096        m.tables[0].hooks = Some(TableHooks {
1097            before_create: Some("bc".into()),
1098            after_create: Some("ac".into()),
1099            before_delete: Some("bc".into()), // duplicate handler name
1100            ..Default::default()
1101        });
1102        let handlers = m.declared_wasm_handlers();
1103        assert!(handlers.contains(&"on_mig"));
1104        assert!(handlers.contains(&"route_h"));
1105        assert!(handlers.contains(&"sub_h"));
1106        assert!(handlers.contains(&"bc"));
1107        assert!(handlers.contains(&"ac"));
1108        assert!(!handlers.contains(&"wh_ignored"));
1109        // Deduplicated
1110        assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
1111    }
1112
1113    #[test]
1114    fn http_route_exposes_only_custom_routes() {
1115        let mut m = sample_manifest();
1116        m.on_migrate = Some("on_mig".into());
1117        m.custom_routes.push(CustomRouteDefinition {
1118            method: "GET".into(),
1119            path: "/h".into(),
1120            handler: "route_h".into(),
1121            job: None,
1122            posture: None,
1123            forward_auth: false,
1124        });
1125        m.subscriptions.push(SubscriptionDefinition {
1126            subject: "evt.sub".into(),
1127            handler: "sub_h".into(),
1128            handler_type: HandlerType::Wasm,
1129            config: HashMap::new(),
1130            stream: None,
1131        });
1132        m.tables[0].hooks = Some(TableHooks {
1133            after_create: Some("hook_h".into()),
1134            ..Default::default()
1135        });
1136
1137        assert_eq!(
1138            m.http_route("route_h").map(|r| r.handler.as_str()),
1139            Some("route_h")
1140        );
1141        assert!(m.http_route("sub_h").is_none());
1142        assert!(m.http_route("on_mig").is_none());
1143        assert!(m.http_route("hook_h").is_none());
1144        assert!(m.http_route("unknown").is_none());
1145    }
1146
1147    #[test]
1148    fn http_route_allows_handler_declared_as_both_route_and_subscription() {
1149        let mut m = sample_manifest();
1150        m.custom_routes.push(CustomRouteDefinition {
1151            method: "POST".into(),
1152            path: "/dual".into(),
1153            handler: "dual".into(),
1154            job: Some(JobConfig::default()),
1155            posture: None,
1156            forward_auth: false,
1157        });
1158        m.subscriptions.push(SubscriptionDefinition {
1159            subject: "evt.dual".into(),
1160            handler: "dual".into(),
1161            handler_type: HandlerType::Wasm,
1162            config: HashMap::new(),
1163            stream: None,
1164        });
1165
1166        let route = m
1167            .http_route("dual")
1168            .expect("dual-purpose handler stays HTTP-invocable");
1169        assert!(route.job.is_some());
1170    }
1171
1172    #[test]
1173    fn job_config_defaults() {
1174        let cfg = JobConfig::default();
1175        assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
1176        assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
1177    }
1178
1179    #[test]
1180    fn custom_route_serde_round_trip_with_job() {
1181        let route = CustomRouteDefinition {
1182            method: "POST".into(),
1183            path: "/run".into(),
1184            handler: "do_thing".into(),
1185            job: Some(JobConfig {
1186                timeout_secs: 900,
1187                max_attempts: 2,
1188            }),
1189            posture: None,
1190            forward_auth: false,
1191        };
1192        let json = serde_json::to_string(&route).unwrap();
1193        let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
1194        assert_eq!(round.method, "POST");
1195        let job = round.job.unwrap();
1196        assert_eq!(job.timeout_secs, 900);
1197        assert_eq!(job.max_attempts, 2);
1198    }
1199
1200    #[test]
1201    fn forward_auth_defaults_off_and_is_only_serialized_when_set() {
1202        let stored: CustomRouteDefinition =
1203            serde_json::from_str(r#"{"method":"GET","path":"/x","handler":"x"}"#).unwrap();
1204        assert!(!stored.forward_auth);
1205        let json = serde_json::to_string(&stored).unwrap();
1206        assert!(!json.contains("forward_auth"), "{json}");
1207        let forwarding = CustomRouteDefinition {
1208            forward_auth: true,
1209            ..stored
1210        };
1211        let json = serde_json::to_value(&forwarding).unwrap();
1212        assert_eq!(json["forward_auth"], true);
1213    }
1214
1215    #[test]
1216    fn custom_route_without_job_serializes_without_field() {
1217        let route = CustomRouteDefinition {
1218            method: "GET".into(),
1219            path: "/x".into(),
1220            handler: "x".into(),
1221            job: None,
1222            posture: None,
1223            forward_auth: false,
1224        };
1225        let json = serde_json::to_value(&route).unwrap();
1226        assert!(json.get("job").is_none(), "absent job should be omitted");
1227        assert!(
1228            json.get("posture").is_none(),
1229            "absent posture should be omitted"
1230        );
1231    }
1232
1233    #[test]
1234    fn custom_route_legacy_json_without_job_deserializes() {
1235        let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
1236        let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
1237        assert!(route.job.is_none());
1238        assert!(route.posture.is_none());
1239    }
1240
1241    #[test]
1242    fn route_posture_serde_tagged_round_trip() {
1243        let cases = [
1244            RoutePosture::Public {
1245                reason: "stripe webhook".into(),
1246            },
1247            RoutePosture::Authenticated,
1248            RoutePosture::Permission {
1249                area: "items".into(),
1250                operation: "edit".into(),
1251            },
1252            RoutePosture::Owner {
1253                field: "customer_id".into(),
1254            },
1255        ];
1256        for p in cases {
1257            let json = serde_json::to_string(&p).unwrap();
1258            let round: RoutePosture = serde_json::from_str(&json).unwrap();
1259            assert_eq!(round, p);
1260        }
1261        let public = serde_json::to_value(RoutePosture::Authenticated).unwrap();
1262        assert_eq!(public["kind"], "authenticated");
1263    }
1264
1265    #[test]
1266    fn posture_aware_detects_any_declared_route() {
1267        let mut m = sample_manifest();
1268        assert!(!m.is_posture_aware());
1269        m.custom_routes.push(CustomRouteDefinition {
1270            method: "GET".into(),
1271            path: "/a".into(),
1272            handler: "a".into(),
1273            job: None,
1274            posture: None,
1275            forward_auth: false,
1276        });
1277        assert!(!m.is_posture_aware());
1278        m.custom_routes.push(CustomRouteDefinition {
1279            method: "GET".into(),
1280            path: "/b".into(),
1281            handler: "b".into(),
1282            job: None,
1283            posture: Some(RoutePosture::Authenticated),
1284            forward_auth: false,
1285        });
1286        assert!(m.is_posture_aware());
1287    }
1288
1289    #[test]
1290    fn manifest_publishes_and_features_default_absent() {
1291        let json = serde_json::to_value(sample_manifest()).unwrap();
1292        assert!(json.get("publishes").is_none());
1293        assert!(json.get("required_platform_features").is_none());
1294        let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
1295        let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
1296        assert!(parsed.publishes.is_none());
1297        assert!(parsed.required_platform_features.is_empty());
1298    }
1299}