Skip to main content

syncular_runtime/core/
app_schema.rs

1use crate::client::{SubscriptionSpec, SyncularClientConfig};
2use crate::encryption::FieldEncryptionRule;
3#[cfg(feature = "native")]
4use crate::error::ErrorKind;
5use crate::error::{Result, SyncularError};
6use crate::protocol::{
7    AuthLeaseIssueRequest, AuthLeaseIssueResponse, AuthLeasePayload, AuthLeaseScope,
8    AUTH_LEASE_PROTOCOL_VERSION, AUTH_LEASE_VERSION,
9};
10#[cfg(feature = "native")]
11use crate::protocol::{ScopeValues, SyncChange};
12#[cfg(feature = "native")]
13use diesel::sqlite::SqliteConnection;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub enum ScopeSource {
21    ActorId,
22    ProjectId,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26pub struct ScopeMetadata {
27    pub name: &'static str,
28    pub column: &'static str,
29    pub source: ScopeSource,
30    pub required: bool,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34pub struct ColumnMetadata {
35    pub name: &'static str,
36    pub type_family: &'static str,
37    pub notnull_required: bool,
38    pub primary_key: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42pub struct CrdtYjsFieldMetadata {
43    pub field: &'static str,
44    pub state_column: &'static str,
45    pub container_key: &'static str,
46    pub row_id_field: &'static str,
47    pub kind: &'static str,
48    pub sync_mode: &'static str,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52pub struct EncryptedFieldMetadata {
53    pub field: &'static str,
54    pub scope: &'static str,
55    pub row_id_field: &'static str,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59pub struct AppTableMetadata {
60    pub name: &'static str,
61    pub primary_key_column: &'static str,
62    pub server_version_column: &'static str,
63    pub soft_delete_column: Option<&'static str>,
64    pub subscription_id: &'static str,
65    pub columns: &'static [ColumnMetadata],
66    pub blob_columns: &'static [&'static str],
67    pub crdt_yjs_fields: &'static [CrdtYjsFieldMetadata],
68    pub encrypted_fields: &'static [EncryptedFieldMetadata],
69    pub scopes: &'static [ScopeMetadata],
70}
71
72#[derive(Debug, Clone, Copy)]
73pub struct EmbeddedMigration {
74    pub version: &'static str,
75    pub schema_version: i32,
76    pub name: &'static str,
77    pub up_sql: &'static str,
78}
79
80#[cfg(feature = "native")]
81pub trait DieselTableAdapter: Sync {
82    fn name(&self) -> &'static str;
83    fn list_rows_json(&self, conn: &mut SqliteConnection) -> Result<Vec<Value>>;
84    fn clear_for_scopes(&self, conn: &mut SqliteConnection, scopes: &ScopeValues) -> Result<()>;
85    fn upsert_row(
86        &self,
87        conn: &mut SqliteConnection,
88        row: &Value,
89        fallback_version: Option<i64>,
90    ) -> Result<()>;
91    fn upsert_rows(
92        &self,
93        conn: &mut SqliteConnection,
94        rows: &[Value],
95        fallback_version: Option<i64>,
96    ) -> Result<()> {
97        for row in rows {
98            self.upsert_row(conn, row, fallback_version)?;
99        }
100        Ok(())
101    }
102    fn apply_change(&self, conn: &mut SqliteConnection, change: &SyncChange) -> Result<()>;
103}
104
105#[derive(Clone, Copy)]
106pub struct AppSchema {
107    pub app_tables: &'static [&'static str],
108    pub app_table_metadata: &'static [AppTableMetadata],
109    pub migrations: &'static [EmbeddedMigration],
110    pub schema_version: Option<i32>,
111    pub default_subscriptions: fn(&SyncularClientConfig) -> Vec<SubscriptionSpec>,
112    #[cfg(feature = "native")]
113    pub adapter_for: fn(&str) -> Result<&'static dyn DieselTableAdapter>,
114}
115
116impl AppSchema {
117    pub fn current_schema_version(&self) -> i32 {
118        self.schema_version
119            .unwrap_or_else(|| current_schema_version(self.migrations))
120    }
121
122    pub fn table_metadata(&self, table: &str) -> Option<&'static AppTableMetadata> {
123        self.app_table_metadata
124            .iter()
125            .find(|metadata| metadata.name == table)
126    }
127
128    pub fn default_subscriptions(&self, config: &SyncularClientConfig) -> Vec<SubscriptionSpec> {
129        (self.default_subscriptions)(config)
130    }
131
132    #[cfg(feature = "native")]
133    pub fn adapter_for(&self, table: &str) -> Result<&'static dyn DieselTableAdapter> {
134        (self.adapter_for)(table)
135    }
136}
137
138pub fn validate_app_schema_runtime_features(app_schema: &AppSchema) -> Result<()> {
139    for table in app_schema.app_table_metadata {
140        if !cfg!(any(feature = "native", feature = "web-blobs")) && !table.blob_columns.is_empty() {
141            return Err(SyncularError::config(format!(
142                "app schema table {} requires blobs runtime feature",
143                table.name
144            )));
145        }
146
147        if !cfg!(feature = "crdt-yjs") && !table.crdt_yjs_fields.is_empty() {
148            return Err(SyncularError::config(format!(
149                "app schema table {} requires crdt-yjs runtime feature",
150                table.name
151            )));
152        }
153
154        if !cfg!(feature = "e2ee")
155            && (!table.encrypted_fields.is_empty()
156                || table
157                    .crdt_yjs_fields
158                    .iter()
159                    .any(|field| field.sync_mode == "encrypted-update-log"))
160        {
161            return Err(SyncularError::config(format!(
162                "app schema table {} requires e2ee runtime feature",
163                table.name
164            )));
165        }
166    }
167
168    Ok(())
169}
170
171pub fn validate_field_encryption_rules_against_app_schema(
172    app_schema: AppSchema,
173    rules: &[FieldEncryptionRule],
174) -> Result<()> {
175    for rule in rules {
176        let table_name = rule.table.as_deref().ok_or_else(|| {
177            SyncularError::config(format!(
178                "field encryption rule for scope {} must specify a generated table",
179                rule.scope
180            ))
181        })?;
182        let metadata = app_schema.table_metadata(table_name).ok_or_else(|| {
183            SyncularError::config(format!(
184                "field encryption rule references unknown generated table {table_name}"
185            ))
186        })?;
187        let row_id_field = rule
188            .row_id_field
189            .as_deref()
190            .unwrap_or(metadata.primary_key_column);
191        if !metadata
192            .columns
193            .iter()
194            .any(|column| column.name == row_id_field)
195        {
196            return Err(SyncularError::config(format!(
197                "field encryption rule for {}.{} references unknown rowIdField {}",
198                rule.scope, table_name, row_id_field
199            )));
200        }
201
202        for field in &rule.fields {
203            let declared = metadata.encrypted_fields.iter().any(|candidate| {
204                candidate.field == field
205                    && candidate.scope == rule.scope
206                    && candidate.row_id_field == row_id_field
207            });
208            if !declared {
209                return Err(SyncularError::config(format!(
210                    "field encryption rule for {}.{} is not declared in the generated app schema",
211                    table_name, field
212                )));
213            }
214        }
215    }
216
217    Ok(())
218}
219
220pub fn validate_blob_encryption_against_app_schema(app_schema: AppSchema) -> Result<()> {
221    if validate_blob_runtime_against_app_schema(app_schema).is_ok() {
222        return Ok(());
223    }
224
225    Err(SyncularError::config(
226        "blob encryption requires at least one generated blob column",
227    ))
228}
229
230pub fn validate_blob_runtime_against_app_schema(app_schema: AppSchema) -> Result<()> {
231    if app_schema
232        .app_table_metadata
233        .iter()
234        .any(|table| !table.blob_columns.is_empty())
235    {
236        return Ok(());
237    }
238
239    Err(SyncularError::config(
240        "blob operations require at least one generated blob column",
241    ))
242}
243
244pub fn validate_encrypted_crdt_against_app_schema(app_schema: AppSchema) -> Result<()> {
245    if app_schema.app_table_metadata.iter().any(|table| {
246        table
247            .crdt_yjs_fields
248            .iter()
249            .any(|field| field.sync_mode == "encrypted-update-log")
250    }) {
251        return Ok(());
252    }
253
254    Err(SyncularError::config(
255        "encrypted CRDT config requires at least one generated encrypted-update-log CRDT field",
256    ))
257}
258
259pub fn validate_auth_lease_issue_request_against_app_schema(
260    app_schema: AppSchema,
261    request: &AuthLeaseIssueRequest,
262) -> Result<()> {
263    validate_auth_lease_schema_version(app_schema, request.schema_version, "auth lease request")?;
264    if let Some(ttl_ms) = request.ttl_ms {
265        if ttl_ms <= 0 {
266            return Err(SyncularError::config(
267                "auth lease request ttlMs must be positive",
268            ));
269        }
270    }
271    validate_auth_lease_scopes_against_app_schema(app_schema, &request.scopes, "auth lease request")
272}
273
274pub fn validate_auth_lease_issue_response_against_app_schema(
275    app_schema: AppSchema,
276    response: &AuthLeaseIssueResponse,
277    request_schema_version: i32,
278) -> Result<()> {
279    if response.payload.schema_version != request_schema_version {
280        return Err(SyncularError::protocol_message(format!(
281            "auth lease response schemaVersion {} does not match request schemaVersion {}",
282            response.payload.schema_version, request_schema_version
283        )));
284    }
285    validate_auth_lease_payload_against_app_schema(app_schema, &response.payload)
286}
287
288pub fn validate_auth_lease_payload_against_app_schema(
289    app_schema: AppSchema,
290    payload: &AuthLeasePayload,
291) -> Result<()> {
292    if payload.version != AUTH_LEASE_VERSION {
293        return Err(SyncularError::protocol_message(
294            "auth lease payload version is unsupported",
295        ));
296    }
297    if payload.protocol_version != AUTH_LEASE_PROTOCOL_VERSION {
298        return Err(SyncularError::protocol_message(
299            "auth lease payload protocolVersion is unsupported",
300        ));
301    }
302    validate_auth_lease_schema_version(app_schema, payload.schema_version, "auth lease payload")?;
303    validate_auth_lease_scopes_against_app_schema(app_schema, &payload.scopes, "auth lease payload")
304}
305
306fn validate_auth_lease_schema_version(
307    app_schema: AppSchema,
308    schema_version: i32,
309    source: &str,
310) -> Result<()> {
311    let current = app_schema.current_schema_version();
312    if schema_version == current {
313        return Ok(());
314    }
315    Err(SyncularError::config(format!(
316        "{source} schemaVersion {schema_version} does not match generated app schema version {current}"
317    )))
318}
319
320fn validate_auth_lease_scopes_against_app_schema(
321    app_schema: AppSchema,
322    scopes: &[AuthLeaseScope],
323    source: &str,
324) -> Result<()> {
325    if scopes.is_empty() {
326        return Err(SyncularError::config(format!(
327            "{source} must contain at least one generated table scope"
328        )));
329    }
330    for scope in scopes {
331        validate_auth_lease_scope_against_app_schema(app_schema, scope, source)?;
332    }
333    Ok(())
334}
335
336fn validate_auth_lease_scope_against_app_schema(
337    app_schema: AppSchema,
338    scope: &AuthLeaseScope,
339    source: &str,
340) -> Result<()> {
341    if scope.subscription_id.trim().is_empty() {
342        return Err(SyncularError::config(format!(
343            "{source} scope subscriptionId must not be empty"
344        )));
345    }
346    let table = scope.table.trim();
347    if table.is_empty() {
348        return Err(SyncularError::config(format!(
349            "{source} scope table must not be empty"
350        )));
351    }
352    let metadata = app_schema.table_metadata(table).ok_or_else(|| {
353        SyncularError::config(format!(
354            "{source} scope references unknown generated table {table}"
355        ))
356    })?;
357    if scope.operations.is_empty() {
358        return Err(SyncularError::config(format!(
359            "{source} scope for table {table} must include at least one operation"
360        )));
361    }
362    for operation in &scope.operations {
363        match operation.as_str() {
364            "upsert" | "delete" => {}
365            other => {
366                return Err(SyncularError::config(format!(
367                    "{source} scope for table {table} references unsupported operation {other}"
368                )));
369            }
370        }
371    }
372
373    for scope_key in scope.values.keys() {
374        if !metadata
375            .scopes
376            .iter()
377            .any(|metadata_scope| metadata_scope.name == scope_key)
378        {
379            return Err(SyncularError::config(format!(
380                "{source} scope for table {table} references unknown generated scope {scope_key}"
381            )));
382        }
383    }
384    for required_scope in metadata.scopes.iter().filter(|scope| scope.required) {
385        if !scope.values.contains_key(required_scope.name) {
386            return Err(SyncularError::config(format!(
387                "{source} scope for table {table} is missing required generated scope {}",
388                required_scope.name
389            )));
390        }
391    }
392    for (name, value) in &scope.values {
393        validate_auth_lease_scope_value(source, table, name, value)?;
394    }
395    Ok(())
396}
397
398fn validate_auth_lease_scope_value(
399    source: &str,
400    table: &str,
401    name: &str,
402    value: &Value,
403) -> Result<()> {
404    match value {
405        Value::String(value) if !value.is_empty() => Ok(()),
406        Value::Array(values)
407            if !values.is_empty()
408                && values
409                    .iter()
410                    .all(|value| matches!(value, Value::String(value) if !value.is_empty())) =>
411        {
412            Ok(())
413        }
414        _ => Err(SyncularError::config(format!(
415            "{source} scope {table}.{name} must be a non-empty string or non-empty string array"
416        ))),
417    }
418}
419
420#[derive(Debug, Clone, Deserialize)]
421#[serde(rename_all = "camelCase")]
422pub struct AppSchemaJson {
423    pub schema_version: i32,
424    #[serde(default)]
425    pub tables: Vec<AppTableMetadataJson>,
426    #[serde(default)]
427    pub migrations: Vec<EmbeddedMigrationJson>,
428}
429
430#[derive(Debug, Clone, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct EmbeddedMigrationJson {
433    pub version: String,
434    pub schema_version: i32,
435    pub name: String,
436    pub up_sql: String,
437}
438
439#[derive(Debug, Clone, Deserialize)]
440#[serde(rename_all = "camelCase")]
441pub struct AppTableMetadataJson {
442    pub name: String,
443    pub primary_key_column: String,
444    pub server_version_column: String,
445    pub soft_delete_column: Option<String>,
446    pub subscription_id: String,
447    #[serde(default)]
448    pub columns: Vec<ColumnMetadataJson>,
449    #[serde(default)]
450    pub blob_columns: Vec<String>,
451    #[serde(default)]
452    pub crdt_yjs_fields: Vec<CrdtYjsFieldMetadataJson>,
453    #[serde(default)]
454    pub encrypted_fields: Vec<EncryptedFieldMetadataJson>,
455    #[serde(default)]
456    pub scopes: Vec<ScopeMetadataJson>,
457}
458
459#[derive(Debug, Clone, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct ScopeMetadataJson {
462    pub name: String,
463    pub column: String,
464    pub source: ScopeSource,
465    #[serde(default)]
466    pub required: bool,
467}
468
469#[derive(Debug, Clone, Deserialize)]
470#[serde(rename_all = "camelCase")]
471pub struct ColumnMetadataJson {
472    pub name: String,
473    pub type_family: String,
474    #[serde(default)]
475    pub notnull_required: bool,
476    #[serde(default)]
477    pub primary_key: bool,
478}
479
480#[derive(Debug, Clone, Deserialize)]
481#[serde(rename_all = "camelCase")]
482pub struct CrdtYjsFieldMetadataJson {
483    pub field: String,
484    pub state_column: String,
485    pub container_key: String,
486    pub row_id_field: String,
487    pub kind: String,
488    pub sync_mode: String,
489}
490
491#[derive(Debug, Clone, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct EncryptedFieldMetadataJson {
494    pub field: String,
495    pub scope: String,
496    pub row_id_field: String,
497}
498
499pub fn app_schema_from_json(schema_json: &str) -> crate::error::Result<AppSchema> {
500    let schema: AppSchemaJson = serde_json::from_str(schema_json)?;
501    Ok(app_schema_from_config(schema))
502}
503
504pub fn app_schema_from_config(schema: AppSchemaJson) -> AppSchema {
505    let migrations = leak_static_slice(
506        schema
507            .migrations
508            .into_iter()
509            .map(leak_embedded_migration)
510            .collect(),
511    );
512    let app_tables = leak_static_slice(
513        schema
514            .tables
515            .iter()
516            .map(|table| leak_static_str(table.name.clone()))
517            .collect(),
518    );
519    let app_table_metadata = leak_static_slice(
520        schema
521            .tables
522            .into_iter()
523            .map(leak_app_table_metadata)
524            .collect(),
525    );
526
527    AppSchema {
528        app_tables,
529        app_table_metadata,
530        migrations,
531        schema_version: Some(schema.schema_version),
532        default_subscriptions: empty_default_subscriptions,
533        #[cfg(feature = "native")]
534        adapter_for: unknown_table_adapter,
535    }
536}
537
538pub fn empty_default_subscriptions(_: &SyncularClientConfig) -> Vec<SubscriptionSpec> {
539    Vec::new()
540}
541
542pub fn empty_app_schema(schema_version: i32) -> AppSchema {
543    AppSchema {
544        app_tables: &[],
545        app_table_metadata: &[],
546        migrations: &[],
547        schema_version: Some(schema_version),
548        default_subscriptions: empty_default_subscriptions,
549        #[cfg(feature = "native")]
550        adapter_for: unknown_table_adapter,
551    }
552}
553
554fn leak_app_table_metadata(table: AppTableMetadataJson) -> AppTableMetadata {
555    AppTableMetadata {
556        name: leak_static_str(table.name),
557        primary_key_column: leak_static_str(table.primary_key_column),
558        server_version_column: leak_static_str(table.server_version_column),
559        soft_delete_column: table.soft_delete_column.map(leak_static_str),
560        subscription_id: leak_static_str(table.subscription_id),
561        columns: leak_static_slice(
562            table
563                .columns
564                .into_iter()
565                .map(leak_column_metadata)
566                .collect(),
567        ),
568        blob_columns: leak_static_slice(
569            table
570                .blob_columns
571                .into_iter()
572                .map(leak_static_str)
573                .collect(),
574        ),
575        crdt_yjs_fields: leak_static_slice(
576            table
577                .crdt_yjs_fields
578                .into_iter()
579                .map(leak_crdt_yjs_field_metadata)
580                .collect(),
581        ),
582        encrypted_fields: leak_static_slice(
583            table
584                .encrypted_fields
585                .into_iter()
586                .map(leak_encrypted_field_metadata)
587                .collect(),
588        ),
589        scopes: leak_static_slice(table.scopes.into_iter().map(leak_scope_metadata).collect()),
590    }
591}
592
593fn leak_embedded_migration(migration: EmbeddedMigrationJson) -> EmbeddedMigration {
594    EmbeddedMigration {
595        version: leak_static_str(migration.version),
596        schema_version: migration.schema_version,
597        name: leak_static_str(migration.name),
598        up_sql: leak_static_str(migration.up_sql),
599    }
600}
601
602fn leak_scope_metadata(scope: ScopeMetadataJson) -> ScopeMetadata {
603    ScopeMetadata {
604        name: leak_static_str(scope.name),
605        column: leak_static_str(scope.column),
606        source: scope.source,
607        required: scope.required,
608    }
609}
610
611fn leak_column_metadata(column: ColumnMetadataJson) -> ColumnMetadata {
612    ColumnMetadata {
613        name: leak_static_str(column.name),
614        type_family: leak_static_str(column.type_family),
615        notnull_required: column.notnull_required,
616        primary_key: column.primary_key,
617    }
618}
619
620fn leak_crdt_yjs_field_metadata(field: CrdtYjsFieldMetadataJson) -> CrdtYjsFieldMetadata {
621    CrdtYjsFieldMetadata {
622        field: leak_static_str(field.field),
623        state_column: leak_static_str(field.state_column),
624        container_key: leak_static_str(field.container_key),
625        row_id_field: leak_static_str(field.row_id_field),
626        kind: leak_static_str(field.kind),
627        sync_mode: leak_static_str(field.sync_mode),
628    }
629}
630
631fn leak_encrypted_field_metadata(field: EncryptedFieldMetadataJson) -> EncryptedFieldMetadata {
632    EncryptedFieldMetadata {
633        field: leak_static_str(field.field),
634        scope: leak_static_str(field.scope),
635        row_id_field: leak_static_str(field.row_id_field),
636    }
637}
638
639fn leak_static_str(value: String) -> &'static str {
640    Box::leak(value.into_boxed_str())
641}
642
643fn leak_static_slice<T>(value: Vec<T>) -> &'static [T] {
644    Box::leak(value.into_boxed_slice())
645}
646
647pub fn current_schema_version(migrations: &[EmbeddedMigration]) -> i32 {
648    migrations
649        .last()
650        .map(|migration| migration.schema_version)
651        .unwrap_or(1)
652}
653
654pub fn split_sql_statements(sql: &str) -> impl Iterator<Item = String> + '_ {
655    sql.split(';')
656        .map(str::trim)
657        .filter(|statement| !statement.is_empty())
658        .map(|statement| format!("{statement};"))
659}
660
661pub fn checksum(sql: &str) -> String {
662    let digest = Sha256::digest(sql.as_bytes());
663    hex::encode(digest)
664}
665
666#[cfg(feature = "native")]
667pub fn default_app_schema() -> AppSchema {
668    empty_app_schema(crate::runtime_schema::runtime_schema_version())
669}
670
671#[cfg(not(feature = "native"))]
672pub fn default_app_schema() -> AppSchema {
673    empty_app_schema(crate::runtime_schema::runtime_schema_version())
674}
675
676#[cfg(feature = "native")]
677pub fn unknown_table_adapter(table: &str) -> Result<&'static dyn DieselTableAdapter> {
678    Err(SyncularError::message(
679        ErrorKind::Config,
680        format!("no Diesel table adapter registered for {table}"),
681    ))
682}
683
684#[cfg(test)]
685mod runtime_feature_tests {
686    use super::*;
687
688    const CRDT_FIELDS: &[CrdtYjsFieldMetadata] = &[CrdtYjsFieldMetadata {
689        field: "title",
690        state_column: "title_yjs_state",
691        container_key: "title",
692        row_id_field: "id",
693        kind: "text",
694        sync_mode: "server-merge",
695    }];
696    const ENCRYPTED_CRDT_FIELDS: &[CrdtYjsFieldMetadata] = &[CrdtYjsFieldMetadata {
697        field: "body",
698        state_column: "body_yjs_state",
699        container_key: "body",
700        row_id_field: "id",
701        kind: "text",
702        sync_mode: "encrypted-update-log",
703    }];
704    const ENCRYPTED_FIELDS: &[EncryptedFieldMetadata] = &[EncryptedFieldMetadata {
705        field: "secret",
706        scope: "tasks",
707        row_id_field: "id",
708    }];
709    const TABLES: &[&str] = &["tasks"];
710    const TABLE_COLUMNS: &[ColumnMetadata] = &[
711        ColumnMetadata {
712            name: "id",
713            type_family: "text",
714            notnull_required: false,
715            primary_key: true,
716        },
717        ColumnMetadata {
718            name: "title",
719            type_family: "text",
720            notnull_required: true,
721            primary_key: false,
722        },
723        ColumnMetadata {
724            name: "title_yjs_state",
725            type_family: "text",
726            notnull_required: false,
727            primary_key: false,
728        },
729        ColumnMetadata {
730            name: "secret",
731            type_family: "text",
732            notnull_required: false,
733            primary_key: false,
734        },
735        ColumnMetadata {
736            name: "image",
737            type_family: "text",
738            notnull_required: false,
739            primary_key: false,
740        },
741        ColumnMetadata {
742            name: "server_version",
743            type_family: "integer",
744            notnull_required: true,
745            primary_key: false,
746        },
747    ];
748    const EMPTY_SCOPES: &[ScopeMetadata] = &[];
749    const REQUIRED_SCOPES: &[ScopeMetadata] = &[ScopeMetadata {
750        name: "user_id",
751        column: "user_id",
752        source: ScopeSource::ActorId,
753        required: true,
754    }];
755    const EMPTY_BLOBS: &[&str] = &[];
756    const BLOB_COLUMNS: &[&str] = &["image"];
757    const EMPTY_CRDT_FIELDS: &[CrdtYjsFieldMetadata] = &[];
758    const EMPTY_ENCRYPTED_FIELDS: &[EncryptedFieldMetadata] = &[];
759
760    const PLAIN_TABLE_METADATA: AppTableMetadata =
761        table_metadata(EMPTY_BLOBS, EMPTY_CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
762    const BLOB_TABLE_METADATA: AppTableMetadata =
763        table_metadata(BLOB_COLUMNS, EMPTY_CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
764    const CRDT_TABLE_METADATA: AppTableMetadata =
765        table_metadata(EMPTY_BLOBS, CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
766    const ENCRYPTED_FIELD_TABLE_METADATA: AppTableMetadata =
767        table_metadata(EMPTY_BLOBS, EMPTY_CRDT_FIELDS, ENCRYPTED_FIELDS);
768    const ENCRYPTED_CRDT_TABLE_METADATA: AppTableMetadata =
769        table_metadata(EMPTY_BLOBS, ENCRYPTED_CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
770    const SCOPED_TABLE_METADATA: AppTableMetadata = table_metadata_with_scopes(
771        EMPTY_BLOBS,
772        EMPTY_CRDT_FIELDS,
773        EMPTY_ENCRYPTED_FIELDS,
774        REQUIRED_SCOPES,
775    );
776    const PLAIN_SCHEMA_TABLES: &[AppTableMetadata] = &[PLAIN_TABLE_METADATA];
777    const BLOB_SCHEMA_TABLES: &[AppTableMetadata] = &[BLOB_TABLE_METADATA];
778    const CRDT_SCHEMA_TABLES: &[AppTableMetadata] = &[CRDT_TABLE_METADATA];
779    const ENCRYPTED_FIELD_SCHEMA_TABLES: &[AppTableMetadata] = &[ENCRYPTED_FIELD_TABLE_METADATA];
780    const ENCRYPTED_CRDT_SCHEMA_TABLES: &[AppTableMetadata] = &[ENCRYPTED_CRDT_TABLE_METADATA];
781    const SCOPED_SCHEMA_TABLES: &[AppTableMetadata] = &[SCOPED_TABLE_METADATA];
782
783    const fn table_metadata(
784        blob_columns: &'static [&'static str],
785        crdt_yjs_fields: &'static [CrdtYjsFieldMetadata],
786        encrypted_fields: &'static [EncryptedFieldMetadata],
787    ) -> AppTableMetadata {
788        table_metadata_with_scopes(
789            blob_columns,
790            crdt_yjs_fields,
791            encrypted_fields,
792            EMPTY_SCOPES,
793        )
794    }
795
796    const fn table_metadata_with_scopes(
797        blob_columns: &'static [&'static str],
798        crdt_yjs_fields: &'static [CrdtYjsFieldMetadata],
799        encrypted_fields: &'static [EncryptedFieldMetadata],
800        scopes: &'static [ScopeMetadata],
801    ) -> AppTableMetadata {
802        AppTableMetadata {
803            name: "tasks",
804            primary_key_column: "id",
805            server_version_column: "server_version",
806            soft_delete_column: None,
807            subscription_id: "sub-tasks",
808            columns: TABLE_COLUMNS,
809            blob_columns,
810            crdt_yjs_fields,
811            encrypted_fields,
812            scopes,
813        }
814    }
815
816    fn schema(metadata: &'static [AppTableMetadata]) -> AppSchema {
817        AppSchema {
818            app_tables: TABLES,
819            app_table_metadata: metadata,
820            migrations: &[],
821            schema_version: Some(1),
822            default_subscriptions: empty_default_subscriptions,
823            #[cfg(feature = "native")]
824            adapter_for: unknown_table_adapter,
825        }
826    }
827
828    #[test]
829    fn plain_schema_needs_no_optional_features() {
830        assert!(validate_app_schema_runtime_features(&schema(PLAIN_SCHEMA_TABLES)).is_ok());
831    }
832
833    #[test]
834    fn blob_schema_matches_blobs_feature() {
835        let result = validate_app_schema_runtime_features(&schema(BLOB_SCHEMA_TABLES));
836        if cfg!(any(feature = "native", feature = "web-blobs")) {
837            assert!(result.is_ok());
838        } else {
839            assert!(result
840                .expect_err("blob schema should require blobs")
841                .message_text()
842                .contains("blobs"));
843        }
844    }
845
846    #[test]
847    fn crdt_schema_matches_crdt_yjs_feature() {
848        let result = validate_app_schema_runtime_features(&schema(CRDT_SCHEMA_TABLES));
849        if cfg!(feature = "crdt-yjs") {
850            assert!(result.is_ok());
851        } else {
852            assert!(result
853                .expect_err("CRDT schema should require crdt-yjs")
854                .message_text()
855                .contains("crdt-yjs"));
856        }
857    }
858
859    #[test]
860    fn encrypted_schema_matches_e2ee_feature() {
861        let result = validate_app_schema_runtime_features(&schema(ENCRYPTED_FIELD_SCHEMA_TABLES));
862        if cfg!(feature = "e2ee") {
863            assert!(result.is_ok());
864        } else {
865            assert!(result
866                .expect_err("encrypted schema should require e2ee")
867                .message_text()
868                .contains("e2ee"));
869        }
870    }
871
872    #[test]
873    fn encrypted_crdt_schema_matches_crdt_and_e2ee_features() {
874        let result = validate_app_schema_runtime_features(&schema(ENCRYPTED_CRDT_SCHEMA_TABLES));
875        if cfg!(feature = "crdt-yjs") && cfg!(feature = "e2ee") {
876            assert!(result.is_ok());
877        } else {
878            let message = result
879                .expect_err("encrypted CRDT schema should require optional features")
880                .message_text();
881            assert!(message.contains("crdt-yjs") || message.contains("e2ee"));
882        }
883    }
884
885    #[test]
886    fn field_encryption_rules_must_match_generated_metadata() {
887        let valid = FieldEncryptionRule {
888            scope: "tasks".to_string(),
889            table: Some("tasks".to_string()),
890            fields: vec!["secret".to_string()],
891            row_id_field: Some("id".to_string()),
892        };
893        assert!(validate_field_encryption_rules_against_app_schema(
894            schema(ENCRYPTED_FIELD_SCHEMA_TABLES),
895            &[valid]
896        )
897        .is_ok());
898
899        let unknown_field = FieldEncryptionRule {
900            scope: "tasks".to_string(),
901            table: Some("tasks".to_string()),
902            fields: vec!["title".to_string()],
903            row_id_field: Some("id".to_string()),
904        };
905        assert!(validate_field_encryption_rules_against_app_schema(
906            schema(ENCRYPTED_FIELD_SCHEMA_TABLES),
907            &[unknown_field]
908        )
909        .expect_err("runtime-only encryption fields should fail")
910        .message_text()
911        .contains("not declared"));
912
913        let wildcard_table = FieldEncryptionRule {
914            scope: "tasks".to_string(),
915            table: None,
916            fields: vec!["secret".to_string()],
917            row_id_field: Some("id".to_string()),
918        };
919        assert!(validate_field_encryption_rules_against_app_schema(
920            schema(ENCRYPTED_FIELD_SCHEMA_TABLES),
921            &[wildcard_table]
922        )
923        .expect_err("field encryption rules must be table-specific")
924        .message_text()
925        .contains("must specify"));
926    }
927
928    #[test]
929    fn blob_encryption_requires_generated_blob_column() {
930        assert!(validate_blob_encryption_against_app_schema(schema(BLOB_SCHEMA_TABLES)).is_ok());
931        assert!(
932            validate_blob_encryption_against_app_schema(schema(PLAIN_SCHEMA_TABLES))
933                .expect_err("blob encryption without blob fields should fail")
934                .message_text()
935                .contains("blob column")
936        );
937    }
938
939    #[test]
940    fn encrypted_crdt_requires_generated_encrypted_crdt_field() {
941        assert!(
942            validate_encrypted_crdt_against_app_schema(schema(ENCRYPTED_CRDT_SCHEMA_TABLES))
943                .is_ok()
944        );
945        assert!(
946            validate_encrypted_crdt_against_app_schema(schema(CRDT_SCHEMA_TABLES))
947                .expect_err("encrypted CRDT config without encrypted-update-log fields should fail")
948                .message_text()
949                .contains("encrypted-update-log")
950        );
951    }
952
953    #[test]
954    fn auth_lease_issue_request_must_match_generated_scope_metadata() {
955        let valid = AuthLeaseIssueRequest {
956            schema_version: 1,
957            ttl_ms: Some(60_000),
958            scopes: vec![AuthLeaseScope {
959                subscription_id: "custom-sub-tasks".to_string(),
960                table: "tasks".to_string(),
961                values: serde_json::json!({ "user_id": ["user-rust"] })
962                    .as_object()
963                    .expect("scope object")
964                    .clone(),
965                operations: vec!["upsert".to_string(), "delete".to_string()],
966            }],
967        };
968        assert!(validate_auth_lease_issue_request_against_app_schema(
969            schema(SCOPED_SCHEMA_TABLES),
970            &valid
971        )
972        .is_ok());
973
974        let unknown_scope = AuthLeaseIssueRequest {
975            schema_version: 1,
976            ttl_ms: None,
977            scopes: vec![AuthLeaseScope {
978                subscription_id: "sub-tasks".to_string(),
979                table: "tasks".to_string(),
980                values: serde_json::json!({ "project_id": "p0" })
981                    .as_object()
982                    .expect("scope object")
983                    .clone(),
984                operations: vec!["upsert".to_string()],
985            }],
986        };
987        assert!(validate_auth_lease_issue_request_against_app_schema(
988            schema(SCOPED_SCHEMA_TABLES),
989            &unknown_scope
990        )
991        .expect_err("unknown generated lease scopes should fail")
992        .message_text()
993        .contains("unknown generated scope project_id"));
994
995        let missing_required_scope = AuthLeaseIssueRequest {
996            schema_version: 1,
997            ttl_ms: None,
998            scopes: vec![AuthLeaseScope {
999                subscription_id: "sub-tasks".to_string(),
1000                table: "tasks".to_string(),
1001                values: serde_json::Map::new(),
1002                operations: vec!["upsert".to_string()],
1003            }],
1004        };
1005        assert!(validate_auth_lease_issue_request_against_app_schema(
1006            schema(SCOPED_SCHEMA_TABLES),
1007            &missing_required_scope
1008        )
1009        .expect_err("missing generated lease scopes should fail")
1010        .message_text()
1011        .contains("missing required generated scope user_id"));
1012    }
1013}