Skip to main content

cufflink_types/
lib.rs

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