Skip to main content

syncular_command/
lib.rs

1//! # syncular-command — one JSON command surface over the Rust client core
2//!
3//! The command router the conformance shim proved (JSON in, JSON out, bytes
4//! as `{"$bytes": hex}`) factored into a transport-agnostic module so BOTH
5//! the stdio conformance shim AND the FFI native core dispatch through the
6//! same code. That keeps a single command surface, conformance-locked via
7//! the shim: whatever the shim exercises, the FFI core inherits.
8//!
9//! The router is generic over the `Transport` seam. The shim binds it to a
10//! stdio host (transport inverted to the harness); the FFI crate binds it to
11//! a real native HTTP+WS transport. Everything host-specific — realtime
12//! notification draining, deferred requests, event queues — stays in each
13//! host; only the pure `method → result` dispatch (and its JSON parsing) is
14//! shared here.
15
16use serde_json::{json, Value};
17use ssp2::segment::{decode_rows_segment, encode_rows_segment};
18use ssp2::{
19    decode_message, encode_message, parse_control, render_message, render_rows_segment,
20    ControlMessage,
21};
22use syncular_client::previous_version::{PreviousVersionContextConfig, PreviousVersionReadSpec};
23use syncular_client::{
24    ClientDiagnosticsRequest, ClientError, ClientLimits, CommandEffects, CommitOutcomeQuery,
25    LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, QueryOwner, QueryReadFailure,
26    RealtimePolicy, ResolveCommitOutcomeInput, SubscriptionRequest, SyncClient, Transport,
27    WindowBase, WindowCoverage,
28};
29
30// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
31
32pub fn bytes_to_hex(bytes: &[u8]) -> String {
33    syncular_client::values::bytes_to_hex(bytes)
34}
35
36pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
37    syncular_client::values::hex_to_bytes(hex)
38}
39
40pub fn bytes_value(bytes: &[u8]) -> Value {
41    Value::Object(serde_json::Map::from_iter([(
42        "$bytes".to_owned(),
43        Value::from(bytes_to_hex(bytes)),
44    )]))
45}
46
47pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
48    let hex = value
49        .and_then(|v| v.get("$bytes"))
50        .and_then(Value::as_str)
51        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
52    hex_to_bytes(hex)
53}
54
55/// The structured failure the driver protocol carries in an `error`. `details`
56/// and `retryable` carry the typed mutation fields to the host; non-mutation
57/// failures leave both at their defaults.
58#[derive(Debug, Clone)]
59pub struct CommandError {
60    pub code: String,
61    pub message: String,
62    pub details: Option<Value>,
63    pub retryable: bool,
64}
65
66impl CommandError {
67    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
68        Self {
69            code: code.into(),
70            message: message.into(),
71            details: None,
72            retryable: false,
73        }
74    }
75
76    /// Consume a structured client authoring failure without re-parsing text.
77    pub fn from_client(error: ClientError) -> Self {
78        Self {
79            code: error.code,
80            message: error.message,
81            details: error.details,
82            retryable: error.retryable,
83        }
84    }
85}
86
87impl From<(String, String)> for CommandError {
88    fn from((code, message): (String, String)) -> Self {
89        Self {
90            code,
91            message,
92            details: None,
93            retryable: false,
94        }
95    }
96}
97
98/// Parsed side effects of a `create` command that the host must apply to its
99/// own transport/clock (the router stays transport-agnostic). The client is
100/// already installed into the `Option<SyncClient>` slot by `dispatch`.
101#[derive(Debug, Default, Clone)]
102pub struct CreateEffects {
103    /// §5.4 capability the harness/host announced for its endpoints — the
104    /// host sets its transport's `supports_url_fetch` accordingly.
105    pub signed_urls: bool,
106    /// True while a security preflight is pending: a preflighted client was
107    /// installed (or entered preflight, or was shut down mid-preflight) and
108    /// `activateSecurity` has yet to complete. `dispatch` maintains this so a
109    /// replacement `create` without the `securityPreflight` flag is refused
110    /// across `shutdown`, where the client slot is empty, on a host that reuses
111    /// one `CreateEffects` across creates (the Tauri plugin, the conformance
112    /// shim, the bench harness).
113    ///
114    /// A host that allocates a fresh `CreateEffects` per create — the React
115    /// Native native module rebuilds its FFI handle on every `create` — starts
116    /// each create with this flag clear. For those hosts the persisted marker
117    /// in the client core carries the gate: a file-backed replica reopens in
118    /// preflight, and the `create` path refuses a plain re-create against it.
119    /// This in-memory flag covers the same-handle case where no file persists
120    /// the marker.
121    pub security_preflight_pending: bool,
122}
123
124/// §7.5: a read failure keeps its structured code, static message, SQLite
125/// details, and retryability across the command boundary.
126fn read_command_error(failure: QueryReadFailure) -> CommandError {
127    let details = failure.details();
128    let retryable = failure.retryable();
129    CommandError {
130        code: failure.code.unwrap_or("client.failed").to_owned(),
131        message: failure.message,
132        details,
133        retryable,
134    }
135}
136
137fn client_err(message: String) -> CommandError {
138    let code = message
139        .split_once(':')
140        .map(|(candidate, _)| candidate)
141        .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
142        .unwrap_or("client.failed");
143    let retryable = code == "client.storage_busy";
144    CommandError {
145        code: code.to_owned(),
146        message,
147        details: None,
148        retryable,
149    }
150}
151
152fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
153    client
154        .as_mut()
155        .ok_or_else(|| client_err("no client instance created".to_owned()))
156}
157
158/// RFC 0005 D8: parse the host's `previousVersionContext` config with the
159/// TS keys and defaults: `enabled` (default false), `maxBytes` 8 MiB,
160/// `maxRows` 20000, `maxTables` 32, `maxRowBytes` 1 MiB, `maxAgeMs` 24h.
161/// Invalid values fail the request loudly rather than silently defaulting.
162pub fn parse_previous_version_context(
163    value: Option<&Value>,
164) -> Result<Option<PreviousVersionContextConfig>, CommandError> {
165    let Some(value) = value else {
166        return Ok(None);
167    };
168    let object = value.as_object().ok_or_else(|| {
169        client_err("sync.invalid_request: previousVersionContext must be an object".to_owned())
170    })?;
171    let enabled = match object.get("enabled") {
172        None => false,
173        Some(Value::Bool(enabled)) => *enabled,
174        Some(_) => {
175            return Err(client_err(
176                "sync.invalid_request: previousVersionContext.enabled must be a boolean".to_owned(),
177            ))
178        }
179    };
180    let mut config = PreviousVersionContextConfig {
181        enabled,
182        ..PreviousVersionContextConfig::default()
183    };
184    for (key, slot) in [
185        ("maxBytes", &mut config.max_bytes),
186        ("maxRows", &mut config.max_rows),
187        ("maxTables", &mut config.max_tables),
188        ("maxRowBytes", &mut config.max_row_bytes),
189        ("maxAgeMs", &mut config.max_age_ms),
190    ] {
191        if let Some(raw) = object.get(key) {
192            *slot = raw.as_i64().ok_or_else(|| {
193                client_err(format!(
194                    "sync.invalid_request: previousVersionContext.{key} must be a positive safe integer"
195                ))
196            })?;
197        }
198    }
199    config.validate().map_err(client_err)?;
200    Ok(Some(config))
201}
202
203/// RFC 0005 D7: `previousVersionSnapshot` params — `{table, rowIds?, limit?}`.
204fn parse_previous_version_snapshot_spec(
205    params: &Value,
206) -> Result<PreviousVersionReadSpec, CommandError> {
207    let table = params.get("table").and_then(Value::as_str).ok_or_else(|| {
208        client_err("sync.invalid_request: previousVersionSnapshot missing table".to_owned())
209    })?;
210    let row_ids = match params.get("rowIds") {
211        None | Some(Value::Null) => Vec::new(),
212        Some(Value::Array(ids)) => ids
213            .iter()
214            .map(|id| match id {
215                Value::String(id) => Ok(id.clone()),
216                Value::Number(number) => Ok(number.to_string()),
217                Value::Bool(boolean) => Ok(boolean.to_string()),
218                _ => Err(client_err(
219                    "sync.invalid_request: previousVersionSnapshot rowIds must be strings"
220                        .to_owned(),
221                )),
222            })
223            .collect::<Result<Vec<_>, _>>()?,
224        Some(_) => {
225            return Err(client_err(
226                "sync.invalid_request: previousVersionSnapshot rowIds must be an array".to_owned(),
227            ))
228        }
229    };
230    let limit = match params.get("limit") {
231        None | Some(Value::Null) => None,
232        Some(value) => Some(value.as_i64().ok_or_else(|| {
233            client_err(
234                "sync.invalid_request: previousVersionSnapshot limit must be a positive number"
235                    .to_owned(),
236            )
237        })?),
238    };
239    Ok(PreviousVersionReadSpec {
240        table: table.to_owned(),
241        row_ids,
242        limit,
243    })
244}
245
246pub fn parse_limits(value: Option<&Value>) -> Result<ClientLimits, CommandError> {
247    let mut limits = ClientLimits::default();
248    let Some(object) = value.and_then(Value::as_object) else {
249        return Ok(limits);
250    };
251    limits.limit_commits = object
252        .get("limitCommits")
253        .and_then(Value::as_i64)
254        .map(|v| v as i32);
255    limits.limit_snapshot_rows = object
256        .get("limitSnapshotRows")
257        .and_then(Value::as_i64)
258        .map(|v| v as i32);
259    limits.max_snapshot_pages = object
260        .get("maxSnapshotPages")
261        .and_then(Value::as_i64)
262        .map(|v| v as i32);
263    limits.accept = object
264        .get("accept")
265        .and_then(Value::as_u64)
266        .map(|v| v as u8);
267    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
268    limits.outcome_retention_max_entries = object
269        .get("outcomeRetentionMaxEntries")
270        .and_then(Value::as_u64)
271        .map(|value| value as usize);
272    limits.max_push_commits_per_request = parse_push_limit(object, "maxPushCommitsPerRequest")?;
273    limits.max_push_operations_per_request =
274        parse_push_limit(object, "maxPushOperationsPerRequest")?;
275    limits.max_push_request_bytes = parse_push_limit(object, "maxPushRequestBytes")?;
276    Ok(limits)
277}
278
279/// §7.1: a configured push limit is a positive integer. Null or absent uses
280/// the default for that limit; invalid values fail with a structured error.
281fn parse_push_limit(
282    object: &serde_json::Map<String, Value>,
283    key: &str,
284) -> Result<Option<usize>, CommandError> {
285    match object.get(key) {
286        None | Some(Value::Null) => Ok(None),
287        Some(value) => value
288            .as_u64()
289            .and_then(|n| usize::try_from(n).ok())
290            .filter(|n| *n >= 1 && *n <= u32::MAX as usize)
291            .map(Some)
292            .ok_or_else(|| CommandError {
293                code: "sync.invalid_request".to_owned(),
294                message: "push limit must be an integer in 1..=4294967295".to_owned(),
295                details: Some(serde_json::json!({ "limit": key })),
296                retryable: false,
297            }),
298    }
299}
300
301/// §8.8: parse the `realtimePolicy` create param. Absent ⇒ `optional`.
302fn parse_realtime_policy(value: Option<&Value>) -> Result<RealtimePolicy, CommandError> {
303    match value {
304        None => Ok(RealtimePolicy::Optional),
305        Some(Value::String(text)) => match text.as_str() {
306            "required" => Ok(RealtimePolicy::Required),
307            "optional" => Ok(RealtimePolicy::Optional),
308            "off" => Ok(RealtimePolicy::Off),
309            other => Err(client_err(format!(
310                "sync.invalid_request: realtimePolicy must be 'required', 'optional', or 'off', got {other:?}"
311            ))),
312        },
313        Some(_) => Err(client_err(
314            "sync.invalid_request: realtimePolicy must be a string".to_owned(),
315        )),
316    }
317}
318
319/// §5.11: parse the `encryption` config into the client's portable keyring.
320/// Shape: `{ keys: { "<keyId>": {"$bytes": "<hex>"} },
321/// keyIdColumns: { "<table>": "<column>" } }`. Keys are 32 bytes.
322pub fn parse_encryption(
323    value: &Value,
324) -> Result<syncular_client::values::EncryptionConfig, String> {
325    let mut config = syncular_client::values::EncryptionConfig::default();
326    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
327        return Ok(config);
328    };
329    for (key_id, key_val) in keys {
330        let bytes =
331            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
332        if bytes.len() != 32 {
333            return Err(format!(
334                "encryption key {key_id:?} must be 32 bytes, got {}",
335                bytes.len()
336            ));
337        }
338        config.keys.insert(key_id.clone(), bytes);
339    }
340    if let Some(columns) = value.get("keyIdColumns") {
341        let columns = columns
342            .as_object()
343            .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
344        for (table, column) in columns {
345            let column = column.as_str().ok_or_else(|| {
346                format!("encryption keyIdColumns entry for {table:?} must be a string")
347            })?;
348            if column.is_empty() {
349                return Err(format!(
350                    "encryption keyIdColumns entry for {table:?} must not be empty"
351                ));
352            }
353            config
354                .key_id_columns
355                .insert(table.clone(), column.to_owned());
356        }
357    }
358    Ok(config)
359}
360
361/// Parse an `activateSecurity` (or rotation) `headers` param: an object of
362/// string values, replacing the transport's FULL header set.
363/// The router validates the shape at the shared chokepoint; each host applies
364/// the parsed set to its own transport (the router stays transport-agnostic).
365pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
366    let object = value.as_object().ok_or_else(|| {
367        "sync.invalid_request: headers must be an object of string values".to_owned()
368    })?;
369    let mut headers = Vec::with_capacity(object.len());
370    for (name, value) in object {
371        let value = value
372            .as_str()
373            .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
374        headers.push((name.clone(), value.to_owned()));
375    }
376    Ok(headers)
377}
378
379pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
380    let list = value
381        .and_then(Value::as_array)
382        .ok_or_else(|| "mutations must be a list".to_owned())?;
383    let mut out = Vec::with_capacity(list.len());
384    for entry in list {
385        let op = entry
386            .get("op")
387            .and_then(Value::as_str)
388            .ok_or_else(|| "mutation missing op".to_owned())?;
389        let table = entry
390            .get("table")
391            .and_then(Value::as_str)
392            .ok_or_else(|| "mutation missing table".to_owned())?
393            .to_owned();
394        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
395        match op {
396            "upsert" | "patch" => {
397                let mut values = entry
398                    .get("values")
399                    .and_then(Value::as_object)
400                    .cloned()
401                    .ok_or_else(|| "upsert missing values".to_owned())?;
402                decode_bigint_members(&mut values)?;
403                out.push(if op == "patch" {
404                    Mutation::Patch {
405                        table,
406                        values,
407                        base_version,
408                    }
409                } else {
410                    Mutation::Upsert {
411                        table,
412                        values,
413                        base_version,
414                    }
415                });
416            }
417            "delete" => {
418                let row_id = entry
419                    .get("rowId")
420                    .and_then(Value::as_str)
421                    .ok_or_else(|| "delete missing rowId".to_owned())?
422                    .to_owned();
423                out.push(Mutation::Delete {
424                    table,
425                    row_id,
426                    base_version,
427                });
428            }
429            other => return Err(format!("unknown mutation op {other:?}")),
430        }
431    }
432    Ok(out)
433}
434
435fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
436    for value in values.values_mut() {
437        let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
438            continue;
439        };
440        let integer = decimal
441            .parse::<i64>()
442            .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
443        *value = Value::from(integer);
444    }
445    Ok(())
446}
447
448fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
449    match value {
450        Some(v) => syncular_client::values::json_to_scope_map(v),
451        None => Ok(Vec::new()),
452    }
453}
454
455/// Parse one `{ id, table, scopes?, params? }` registration.
456fn subscription_from_params(value: &Value) -> Result<SubscriptionRequest, String> {
457    let id = value
458        .get("id")
459        .and_then(Value::as_str)
460        .ok_or_else(|| "subscribe missing id".to_owned())?
461        .to_owned();
462    let table = value
463        .get("table")
464        .and_then(Value::as_str)
465        .ok_or_else(|| "subscribe missing table".to_owned())?
466        .to_owned();
467    Ok(SubscriptionRequest {
468        id,
469        table,
470        scopes: scopes_from_params(value.get("scopes"))?,
471        params: value
472            .get("params")
473            .and_then(Value::as_str)
474            .map(str::to_owned),
475    })
476}
477
478/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
479/// params? }` from a command's `base` param.
480fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
481    let object = value
482        .and_then(Value::as_object)
483        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
484    let table = object
485        .get("table")
486        .and_then(Value::as_str)
487        .ok_or_else(|| "window base missing table".to_owned())?
488        .to_owned();
489    let variable = object
490        .get("variable")
491        .and_then(Value::as_str)
492        .ok_or_else(|| "window base missing variable".to_owned())?
493        .to_owned();
494    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
495    let params = object
496        .get("params")
497        .and_then(Value::as_str)
498        .map(str::to_owned);
499    Ok(WindowBase {
500        table,
501        variable,
502        fixed_scopes,
503        params,
504    })
505}
506
507/// §7.5: parse a `coverage` array of `{base, units}` entries. Absent or null is
508/// empty; a non-array, a malformed base, or non-string units are rejected
509/// explicitly instead of silently defaulting.
510fn parse_window_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
511    let entries = match value {
512        None | Some(Value::Null) => return Ok(Vec::new()),
513        Some(Value::Array(entries)) => entries,
514        Some(_) => return Err("coverage must be a list".to_owned()),
515    };
516    let mut coverage = Vec::with_capacity(entries.len());
517    for entry in entries {
518        let units = match entry.get("units") {
519            None | Some(Value::Null) => Vec::new(),
520            Some(Value::Array(values)) => values
521                .iter()
522                .map(|value| value.as_str().map(str::to_owned))
523                .collect::<Option<Vec<_>>>()
524                .ok_or_else(|| "coverage units must be a list of strings".to_owned())?,
525            Some(_) => return Err("coverage units must be a list of strings".to_owned()),
526        };
527        coverage.push(WindowCoverage {
528            base: window_base_from_params(entry.get("base"))?,
529            units,
530        });
531    }
532    Ok(coverage)
533}
534
535/// §7.5 owner: `{id, tables}`, both required when present. Shared by
536/// `querySnapshot` and `snapshotRead`.
537fn parse_query_owner<'a>(
538    params: &'a Value,
539    method: &str,
540) -> Result<Option<(&'a str, Vec<&'a str>)>, CommandError> {
541    match params.get("owner") {
542        None | Some(Value::Null) => Ok(None),
543        Some(value) => {
544            let id = value
545                .get("id")
546                .and_then(Value::as_str)
547                .filter(|id| !id.is_empty());
548            let tables = value
549                .get("tables")
550                .and_then(Value::as_array)
551                .and_then(|list| {
552                    list.iter()
553                        .map(Value::as_str)
554                        .collect::<Option<Vec<&str>>>()
555                });
556            match (id, tables) {
557                (Some(id), Some(tables)) => Ok(Some((id, tables))),
558                _ => Err(client_err(format!(
559                    "sync.invalid_request: {method} owner must be {{id: non-empty string, tables: string[]}}"
560                ))),
561            }
562        }
563    }
564}
565
566/// §7.5: parse a `subscriptions`/`commitIds` list. Absent or null is empty; a
567/// non-array or a non-string entry is rejected explicitly.
568fn snapshot_string_list(value: Option<&Value>) -> Result<Vec<String>, CommandError> {
569    match value {
570        None | Some(Value::Null) => Ok(Vec::new()),
571        Some(Value::Array(items)) => items
572            .iter()
573            .map(|item| item.as_str().map(str::to_owned))
574            .collect::<Option<Vec<String>>>()
575            .ok_or_else(|| {
576                client_err(
577                    "sync.invalid_request: snapshotRead list must contain strings".to_owned(),
578                )
579            }),
580        Some(_) => Err(client_err(
581            "sync.invalid_request: snapshotRead list must be a list of strings".to_owned(),
582        )),
583    }
584}
585
586/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
587/// command. `name` selects the shared type inside the doc (default `"text"`,
588/// matching the TS `YjsColumn.text()` default).
589#[cfg(feature = "crdt-yjs")]
590fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
591    let table = params
592        .get("table")
593        .and_then(Value::as_str)
594        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
595        .to_owned();
596    let row_id = params
597        .get("rowId")
598        .and_then(Value::as_str)
599        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
600        .to_owned();
601    let column = params
602        .get("column")
603        .and_then(Value::as_str)
604        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
605        .to_owned();
606    let name = params
607        .get("name")
608        .and_then(Value::as_str)
609        .unwrap_or("text")
610        .to_owned();
611    Ok((table, row_id, column, name))
612}
613
614/// Dispatch one command against the client instance over `transport`.
615///
616/// The `create` command installs a fresh `SyncClient` into `client` and
617/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
618/// other command mutates the existing instance. Errors come back as the
619/// driver-protocol `(code, message)` pair.
620///
621/// Generic over `T: Transport` so the shim (host-inverted transport) and the
622/// FFI core (native HTTP+WS transport) share this exact router.
623pub fn dispatch<T: Transport>(
624    transport: &mut T,
625    client: &mut Option<SyncClient>,
626    effects: &mut CreateEffects,
627    method: &str,
628    params: &Value,
629) -> Result<Value, CommandError> {
630    if method == "create" {
631        // Fail-closed against a compromised webview re-issuing `create` (or
632        // `shutdown` + `create`) WITHOUT the securityPreflight flag to exit
633        // the gate: while a preflight is pending — on the live client, or
634        // carried in `effects` across a `shutdown` — a replacement create must
635        // itself request securityPreflight; the gate opens only through
636        // activateSecurity.
637        let requests_preflight = params
638            .get("securityPreflight")
639            .and_then(Value::as_bool)
640            .unwrap_or(false);
641        let preflight_engaged = client.as_ref().map_or(
642            effects.security_preflight_pending,
643            SyncClient::security_preflight,
644        );
645        if preflight_engaged && !requests_preflight {
646            return Err(CommandError::new(
647                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
648                "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
649            ));
650        }
651    } else {
652        let allowed_during_preflight = matches!(
653            method,
654            "authoritySnapshot"
655                | "securityLifecycle"
656                | "beginSecurityPreflight"
657                | "activateSecurity"
658                | "setTransportEnabled"
659                | "purgeLocalData"
660                | "previousVersionDiscard"
661                | "localRevision"
662                | "statusSnapshot"
663                | "shutdown"
664        );
665        if client
666            .as_ref()
667            .is_some_and(|running| running.security_preflight())
668            && !allowed_during_preflight
669        {
670            return Err(CommandError::new(
671                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
672                "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data",
673            ));
674        }
675    }
676    let mut result = match method {
677        "create" => {
678            let client_id = params
679                .get("clientId")
680                .and_then(Value::as_str)
681                .map(str::to_owned);
682            let schema = params
683                .get("schema")
684                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
685            let limits = parse_limits(params.get("limits"))?;
686            // RFC 0005 D8: the feature config rides the top-level key the TS
687            // client uses; the Rust core carries it on `ClientLimits` so it is
688            // resolved before the opening schema reset runs.
689            let previous_version_context =
690                parse_previous_version_context(params.get("previousVersionContext"))?;
691            let mut limits = limits;
692            limits.previous_version_context = previous_version_context;
693            if let Some(reads) = params.get("authorityReads") {
694                limits.authority_reads = serde_json::from_value(reads.clone()).map_err(|_| {
695                    client_err(
696                        "client.authority_read_forbidden: invalid authority declaration".into(),
697                    )
698                })?;
699            }
700            // §native: a `dbPath` installs a file-backed rusqlite connection so
701            // native hosts (Tauri plugin, FFI file variant) persist across
702            // restarts; absent it, the default in-memory core (the shim's mode).
703            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
704                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
705                    .map_err(client_err)?,
706                None => {
707                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
708                }
709            };
710            instance
711                .set_retain_failed_commits(
712                    params
713                        .get("retainFailedCommits")
714                        .and_then(Value::as_bool)
715                        .unwrap_or(false),
716                )
717                .map_err(client_err)?;
718            // §8.8: the realtime policy rides the create params; absent ⇒
719            // `optional` (today's socket-or-HTTP behavior).
720            let realtime_policy = parse_realtime_policy(params.get("realtimePolicy"))?;
721            instance
722                .set_realtime_policy(realtime_policy)
723                .map_err(client_err)?;
724            // Harness clock pin (§5.4 expiry runs on the virtual clock).
725            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
726                instance.set_now_ms(now_ms);
727            }
728            // §5.4 capability of the host endpoint set (accept bit 3) — the
729            // host applies it to its own transport.
730            effects.signed_urls = params
731                .get("signedUrls")
732                .and_then(Value::as_bool)
733                .unwrap_or(false);
734            // §5.11: install client-side encryption keys. Shape:
735            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} },
736            //                 keyIdColumns: { "<table>": "<column>" } } }.
737            let security_preflight = params
738                .get("securityPreflight")
739                .and_then(Value::as_bool)
740                .unwrap_or(false);
741            // A file-backed replica reopens in preflight when its persisted
742            // quarantine marker is set (client core restores it). Refuse a plain
743            // re-create so a rebuilt host handle — the React Native native module
744            // tears its FFI handle down on every create — cannot downgrade a
745            // quarantined replica to active. A create that itself requests
746            // securityPreflight, or an activated replica whose marker cleared,
747            // proceeds.
748            if instance.security_preflight() && !security_preflight {
749                return Err(CommandError::new(
750                    syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
751                    "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
752                ));
753            }
754            if security_preflight && params.get("encryption").is_some() {
755                return Err(client_err(
756                    "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
757                        .to_owned(),
758                ));
759            }
760            if let Some(enc) = params.get("encryption") {
761                let config = parse_encryption(enc).map_err(client_err)?;
762                instance.set_encryption(config);
763            }
764            if security_preflight {
765                instance.begin_security_preflight();
766            }
767            if let Some(enabled) = params.get("transportEnabled") {
768                let enabled = enabled.as_bool().ok_or_else(|| {
769                    client_err("sync.invalid_request: transportEnabled must be a boolean".into())
770                })?;
771                instance.set_transport_enabled(transport, enabled);
772            }
773            effects.security_preflight_pending = security_preflight;
774            *client = Some(instance);
775            Ok(json!({}))
776        }
777        "securityLifecycle" => Ok(json!({
778            "state": need_client(client)?.security_lifecycle()
779        })),
780        "beginSecurityPreflight" => {
781            let running = need_client(client)?;
782            running.disconnect_realtime(transport);
783            running.begin_security_preflight();
784            effects.security_preflight_pending = true;
785            Ok(json!({}))
786        }
787        "authoritySnapshot" => {
788            if !params.as_object().is_some_and(|p| p.is_empty()) {
789                return Err(client_err(
790                    "client.authority_read_forbidden: authoritySnapshot accepts no arguments"
791                        .into(),
792                ));
793            }
794            client
795                .as_ref()
796                .ok_or_else(|| client_err("client.closed: no authority client is open".into()))?
797                .authority_snapshot()
798                .map_err(client_err)
799        }
800        "activateSecurity" => {
801            let encryption = match params.get("encryption") {
802                Some(value) => parse_encryption(value).map_err(client_err)?,
803                None => syncular_client::values::EncryptionConfig::default(),
804            };
805            // Optional fresh transport headers ride the activation atomically,
806            // so a preflight that outlives the boot token starts its first
807            // sync round with valid credentials. Validated here at the shared
808            // chokepoint (invalid input keeps the gate closed); the host
809            // applies the parsed set to its own transport.
810            if let Some(headers) = params.get("headers") {
811                parse_headers(headers).map_err(client_err)?;
812            }
813            need_client(client)?
814                .activate_security(encryption)
815                .map_err(client_err)?;
816            effects.security_preflight_pending = false;
817            Ok(json!({}))
818        }
819        "setTransportEnabled" => {
820            let enabled = params
821                .get("enabled")
822                .and_then(Value::as_bool)
823                .ok_or_else(|| {
824                    client_err(
825                        "sync.invalid_request: setTransportEnabled.enabled must be a boolean"
826                            .into(),
827                    )
828                })?;
829            need_client(client)?.set_transport_enabled(transport, enabled);
830            Ok(json!({}))
831        }
832        "setHeaders" => {
833            let headers = params
834                .get("headers")
835                .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
836            parse_headers(headers).map_err(client_err)?;
837            Ok(json!({}))
838        }
839        "shutdown" => {
840            if let Some(running) = client.as_mut() {
841                // Capture the gate state BEFORE the shutdown barrier flips the
842                // client into preflight: an unactivated preflight stays
843                // pending across the shutdown, while an activated client may
844                // be recreated plainly afterwards.
845                effects.security_preflight_pending = running.security_preflight();
846                running.disconnect_realtime(transport);
847                // Teardown barrier only: an activated client that shuts down
848                // cleanly must not leave a durable quarantine mark, or its next
849                // plain create would be refused forever.
850                running.seal_security_on_teardown();
851            }
852            *client = None;
853            Ok(json!({}))
854        }
855        "subscribe" => {
856            need_client(client)?
857                .subscribe_many(vec![subscription_from_params(params).map_err(client_err)?])
858                .map_err(client_err)?;
859            Ok(json!({}))
860        }
861        "subscribeMany" => {
862            let requests = params
863                .get("subscriptions")
864                .and_then(Value::as_array)
865                .ok_or_else(|| client_err("subscribeMany missing subscriptions".to_owned()))?
866                .iter()
867                .map(subscription_from_params)
868                .collect::<Result<Vec<_>, _>>()
869                .map_err(client_err)?;
870            need_client(client)?
871                .subscribe_many(requests)
872                .map_err(client_err)?;
873            Ok(json!({}))
874        }
875        "unsubscribe" => {
876            let id = params
877                .get("id")
878                .and_then(Value::as_str)
879                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
880            need_client(client)?.unsubscribe(id);
881            Ok(json!({}))
882        }
883        "setWindow" => {
884            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
885            let units: Vec<String> = params
886                .get("units")
887                .and_then(Value::as_array)
888                .map(|arr| {
889                    arr.iter()
890                        .filter_map(|v| v.as_str().map(str::to_owned))
891                        .collect()
892                })
893                .unwrap_or_default();
894            let command_effects = need_client(client)?
895                .set_window(&base, &units)
896                .map_err(client_err)?;
897            Ok(json!({ "effects": command_effects }))
898        }
899        "windowState" => {
900            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
901            let state = need_client(client)?.window_state(&base);
902            Ok(json!({ "units": state.units, "pending": state.pending }))
903        }
904        "timeWindowSugar" => {
905            let created_at_ms = params
906                .get("createdAtMs")
907                .and_then(Value::as_i64)
908                .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
909            let count = params
910                .get("count")
911                .and_then(Value::as_u64)
912                .and_then(|value| usize::try_from(value).ok())
913                .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
914            let now_ms = params
915                .get("nowMs")
916                .and_then(Value::as_i64)
917                .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
918            if params.get("unit").and_then(Value::as_str) != Some("month") {
919                return Err(client_err(
920                    "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
921                ));
922            }
923            let unit = syncular_client::TimeBucketUnit::Month;
924            Ok(json!({
925                "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
926                    .map_err(client_err)?,
927                "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
928            }))
929        }
930        "mutate" => {
931            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
932            let id = need_client(client)?
933                .mutate(mutations)
934                .map_err(CommandError::from_client)?;
935            Ok(json!({
936                "clientCommitId": id,
937                "effects": CommandEffects::interactive()
938            }))
939        }
940        "patch" => {
941            let table = params
942                .get("table")
943                .and_then(Value::as_str)
944                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
945            let row_id = params
946                .get("rowId")
947                .and_then(Value::as_str)
948                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
949            let mut partial = params
950                .get("partial")
951                .and_then(Value::as_object)
952                .cloned()
953                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
954            decode_bigint_members(&mut partial).map_err(client_err)?;
955            let base_version = params.get("baseVersion").and_then(Value::as_i64);
956            let id = need_client(client)?
957                .patch(table, row_id, partial, base_version)
958                .map_err(CommandError::from_client)?;
959            Ok(json!({
960                "clientCommitId": id,
961                "effects": CommandEffects::interactive()
962            }))
963        }
964        "purgeLocalData" => {
965            let input: LocalDataPurgeInput =
966                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
967                    client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
968                })?)
969                .map_err(|error| {
970                    client_err(format!(
971                        "sync.invalid_request: invalid purgeLocalData input: {error}"
972                    ))
973                })?;
974            let result = need_client(client)?
975                .purge_local_data(&input)
976                .map_err(client_err)?;
977            serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
978        }
979        "rebootstrapLocalData" => {
980            let input: LocalDataRebootstrapInput =
981                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
982                    client_err(
983                        "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
984                    )
985                })?)
986                .map_err(|error| {
987                    client_err(format!(
988                        "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
989                    ))
990                })?;
991            let result = need_client(client)?
992                .rebootstrap_local_data(&input)
993                .map_err(client_err)?;
994            Ok(json!({
995                "alreadyApplied": result.already_applied,
996                "retainedCommits": result.retained_commits,
997                "resetSubscriptions": result.reset_subscriptions,
998                "effects": if result.already_applied {
999                    CommandEffects::none()
1000                } else {
1001                    CommandEffects::interactive()
1002                }
1003            }))
1004        }
1005        "sync" => {
1006            let outcome = need_client(client)?.sync(transport);
1007            Ok(outcome.to_json())
1008        }
1009        "resetClientId" | "reset_client_id" => {
1010            let client_id = need_client(client)?.reset_client_id(transport);
1011            Ok(json!({ "clientId": client_id }))
1012        }
1013        "syncUntilIdle" => {
1014            let max_rounds = match params.get("maxRounds") {
1015                None | Some(Value::Null) => None,
1016                Some(value) => Some(
1017                    value
1018                        .as_u64()
1019                        .and_then(|n| u32::try_from(n).ok())
1020                        .filter(|n| *n >= 1)
1021                        .ok_or_else(|| {
1022                            client_err(
1023                                "sync.invalid_request: maxRounds must be a positive integer"
1024                                    .to_owned(),
1025                            )
1026                        })?,
1027                ),
1028            };
1029            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
1030            Ok(outcome.to_json())
1031        }
1032        "readRows" => {
1033            let table = params
1034                .get("table")
1035                .and_then(Value::as_str)
1036                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
1037            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
1038            Ok(json!({ "rows": rows }))
1039        }
1040        "query" => {
1041            // The React `useSyncQuery` live-query fast path: arbitrary read-only
1042            // SQL over the local visible tables/views. Params ride as the driver
1043            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
1044            let sql = params
1045                .get("sql")
1046                .and_then(Value::as_str)
1047                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
1048            let bind = match params.get("params") {
1049                Some(Value::Array(list)) => list.as_slice(),
1050                None | Some(Value::Null) => &[],
1051                Some(_) => return Err(client_err("query params must be a list".to_owned())),
1052            };
1053            let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
1054            Ok(Value::Object(serde_json::Map::from_iter([(
1055                "rows".to_owned(),
1056                Value::Array(rows.into_iter().map(Value::Object).collect()),
1057            )])))
1058        }
1059        "querySnapshot" => {
1060            let sql = params
1061                .get("sql")
1062                .and_then(Value::as_str)
1063                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
1064            let bind = match params.get("params") {
1065                Some(Value::Array(list)) => list.as_slice(),
1066                None | Some(Value::Null) => &[],
1067                Some(_) => {
1068                    return Err(client_err("querySnapshot params must be a list".to_owned()))
1069                }
1070            };
1071            let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1072            let owner = parse_query_owner(params, "querySnapshot")?;
1073            let mut snapshot = need_client(client)?
1074                .query_snapshot(
1075                    sql,
1076                    bind,
1077                    &coverage,
1078                    owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1079                )
1080                .map_err(client_err)?;
1081            let rows = std::mem::take(&mut snapshot.rows);
1082            let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
1083            result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
1084            Ok(result)
1085        }
1086        "localRevision" => {
1087            // §7.5: the public revision read is fallible. An empty shared
1088            // snapshot read reports a corrupt marker instead of swallowing it
1089            // to zero. The internal infallible counter stays for bookkeeping.
1090            let read = need_client(client)?
1091                .snapshot_read(&syncular_client::SnapshotReadRequest::default(), None)
1092                .map_err(read_command_error)?;
1093            Ok(json!({ "revision": read.revision }))
1094        }
1095        "snapshotRead" => {
1096            let mut statement_sql: Vec<String> = Vec::new();
1097            let mut statement_params: Vec<Vec<Value>> = Vec::new();
1098            match params.get("statements") {
1099                None | Some(Value::Null) => {}
1100                Some(Value::Array(items)) => {
1101                    for item in items {
1102                        let Some(sql) = item.get("sql").and_then(Value::as_str) else {
1103                            return Err(client_err(
1104                                "sync.invalid_request: snapshotRead statements need a sql string"
1105                                    .to_owned(),
1106                            ));
1107                        };
1108                        let bind = match item.get("params") {
1109                            None | Some(Value::Null) => Vec::new(),
1110                            Some(Value::Array(values)) => values.clone(),
1111                            Some(_) => {
1112                                return Err(client_err(
1113                                    "sync.invalid_request: snapshotRead statement params must be a list"
1114                                        .to_owned(),
1115                                ))
1116                            }
1117                        };
1118                        statement_sql.push(sql.to_owned());
1119                        statement_params.push(bind);
1120                    }
1121                }
1122                Some(_) => {
1123                    return Err(client_err(
1124                        "sync.invalid_request: snapshotRead statements must be a list".to_owned(),
1125                    ))
1126                }
1127            }
1128            let subscription_ids = snapshot_string_list(params.get("subscriptions"))?;
1129            let commit_ids = snapshot_string_list(params.get("commitIds"))?;
1130            let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1131            let owner = parse_query_owner(params, "snapshotRead")?;
1132            let statements: Vec<syncular_client::SnapshotStatement<'_>> = statement_sql
1133                .iter()
1134                .zip(&statement_params)
1135                .map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
1136                .collect();
1137            let read = need_client(client)?
1138                .snapshot_read(
1139                    &syncular_client::SnapshotReadRequest {
1140                        statements,
1141                        coverage: &coverage,
1142                        subscriptions: subscription_ids,
1143                        commit_ids,
1144                    },
1145                    owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1146                )
1147                .map_err(read_command_error)?;
1148            serde_json::to_value(read).map_err(|error| client_err(error.to_string()))
1149        }
1150        "previousVersionSnapshot" | "previous_version_snapshot" => {
1151            let spec = parse_previous_version_snapshot_spec(params)?;
1152            let snapshot = need_client(client)?
1153                .previous_version_snapshot(&spec)
1154                .map_err(client_err)?;
1155            serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
1156        }
1157        "previousVersionAudit" | "previous_version_audit" => {
1158            Ok(match need_client(client)?.previous_version_audit() {
1159                Some(audit) => {
1160                    serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
1161                }
1162                None => Value::Null,
1163            })
1164        }
1165        "previousVersionDiscard" | "previous_version_discard" => {
1166            let outcome = need_client(client)?.previous_version_discard();
1167            Ok(json!({
1168                "present": outcome.present,
1169                "discarded": outcome.discarded,
1170            }))
1171        }
1172        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
1173            .expect("status serializes")),
1174        "diagnosticsSnapshot" => {
1175            let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
1176                .map_err(|error| {
1177                    client_err(format!(
1178                        "sync.invalid_request: invalid diagnostics request: {error}"
1179                    ))
1180                })?;
1181            let snapshot = need_client(client)?
1182                .diagnostics_snapshot(&request)
1183                .map_err(client_err)?;
1184            Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
1185        }
1186        // Conformance/debug drains. Production hosts normally drain these
1187        // immediately after every command, but exposing the exact core output
1188        // here lets both client implementations consume one observation
1189        // vector catalog without bridge inference.
1190        "progressSnapshot" => Ok(
1191            serde_json::to_value(need_client(client)?.progress().snapshot())
1192                .expect("progress JSON"),
1193        ),
1194        "drainChangeBatches" => Ok(json!({
1195            "batches": need_client(client)?.drain_change_batches()
1196        })),
1197        "drainSyncIntents" => Ok(json!({
1198            "intents": need_client(client)?.drain_sync_intents()
1199        })),
1200        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
1201        // Thin forwards to the client core's yrs helpers. The command surface
1202        // stays present-but-unavailable in a lean build: without the feature
1203        // these fail loudly (`client.crdt_unavailable`) rather than being an
1204        // unknown method, so a wrapper's typed method gives a clear error.
1205        #[cfg(feature = "crdt-yjs")]
1206        "crdtText" => {
1207            let (table, row_id, column, name) = crdt_target(params)?;
1208            let text = need_client(client)?
1209                .crdt_text(&table, &row_id, &column, &name)
1210                .map_err(client_err)?;
1211            Ok(json!({ "text": text }))
1212        }
1213        #[cfg(feature = "crdt-yjs")]
1214        "crdtInsertText" => {
1215            let (table, row_id, column, name) = crdt_target(params)?;
1216            let index = params
1217                .get("index")
1218                .and_then(Value::as_u64)
1219                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
1220                as u32;
1221            let value = params
1222                .get("value")
1223                .and_then(Value::as_str)
1224                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
1225            let id = need_client(client)?
1226                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
1227                .map_err(CommandError::from_client)?;
1228            Ok(json!({ "clientCommitId": id }))
1229        }
1230        #[cfg(feature = "crdt-yjs")]
1231        "crdtDeleteText" => {
1232            let (table, row_id, column, name) = crdt_target(params)?;
1233            let index = params
1234                .get("index")
1235                .and_then(Value::as_u64)
1236                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
1237                as u32;
1238            let len = params
1239                .get("len")
1240                .and_then(Value::as_u64)
1241                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
1242                as u32;
1243            let id = need_client(client)?
1244                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
1245                .map_err(CommandError::from_client)?;
1246            Ok(json!({ "clientCommitId": id }))
1247        }
1248        #[cfg(feature = "crdt-yjs")]
1249        "crdtApplyUpdate" => {
1250            let (table, row_id, column, _name) = crdt_target(params)?;
1251            let update = value_bytes(params.get("update")).map_err(client_err)?;
1252            let id = need_client(client)?
1253                .crdt_apply_update(&table, &row_id, &column, &update)
1254                .map_err(CommandError::from_client)?;
1255            Ok(json!({ "clientCommitId": id }))
1256        }
1257        #[cfg(not(feature = "crdt-yjs"))]
1258        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => {
1259            Err(CommandError::new(
1260                "client.crdt_unavailable",
1261                "native CRDT support requires the `crdt-yjs` feature (§5.10.5)",
1262            ))
1263        }
1264
1265        "uploadBlob" => {
1266            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1267            let media_type = params
1268                .get("mediaType")
1269                .and_then(Value::as_str)
1270                .map(str::to_owned);
1271            let name = params
1272                .get("name")
1273                .and_then(Value::as_str)
1274                .map(str::to_owned);
1275            let reference = need_client(client)?
1276                .upload_blob(&bytes, media_type, name)
1277                .map_err(client_err)?;
1278            Ok(json!({ "ref": reference }))
1279        }
1280        "fetchBlob" => {
1281            let blob = params
1282                .get("blob")
1283                .and_then(Value::as_str)
1284                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
1285                .to_owned();
1286            // fetch_blob_bytes returns (code, message) so the server's blob.*
1287            // code reaches the caller (§5.9.5 cross-scope probe). The command
1288            // boundary owns the JSON byte encoding used by native bindings.
1289            let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
1290            let mut value = serde_json::Map::new();
1291            value.insert("blobId".to_owned(), Value::from(blob.blob_id));
1292            value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
1293            value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
1294            if let Some(media_type) = blob.media_type {
1295                value.insert("mediaType".to_owned(), Value::from(media_type));
1296            }
1297            Ok(Value::Object(serde_json::Map::from_iter([(
1298                "blob".to_owned(),
1299                Value::Object(value),
1300            )])))
1301        }
1302        "conflicts" => {
1303            let conflicts = need_client(client)?.conflicts().to_vec();
1304            Ok(json!({ "conflicts": conflicts }))
1305        }
1306        "rejections" => {
1307            let rejections = need_client(client)?.rejections().to_vec();
1308            Ok(json!({ "rejections": rejections }))
1309        }
1310        "commitOutcome" => {
1311            let client_commit_id = params
1312                .get("clientCommitId")
1313                .and_then(Value::as_str)
1314                .ok_or_else(|| {
1315                    client_err(
1316                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
1317                    )
1318                })?;
1319            let outcome = need_client(client)?
1320                .commit_outcome(client_commit_id)
1321                .map_err(client_err)?;
1322            Ok(json!({ "outcome": outcome }))
1323        }
1324        "commitOutcomes" => {
1325            let query = serde_json::from_value::<CommitOutcomeQuery>(
1326                params.get("query").cloned().unwrap_or_else(|| json!({})),
1327            )
1328            .map_err(|error| {
1329                client_err(format!(
1330                    "sync.invalid_request: invalid commit outcome query: {error}"
1331                ))
1332            })?;
1333            let outcomes = need_client(client)?
1334                .commit_outcomes(query)
1335                .map_err(client_err)?;
1336            Ok(json!({ "outcomes": outcomes }))
1337        }
1338        "resolveCommitOutcome" => {
1339            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1340                params.get("input").cloned().ok_or_else(|| {
1341                    client_err(
1342                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1343                    )
1344                })?,
1345            )
1346            .map_err(|error| {
1347                client_err(format!(
1348                    "sync.invalid_request: invalid outcome resolution: {error}"
1349                ))
1350            })?;
1351            let outcome = need_client(client)?
1352                .resolve_commit_outcome(input)
1353                .map_err(client_err)?;
1354            Ok(json!({ "outcome": outcome }))
1355        }
1356        "pendingCommitIds" => {
1357            let ids = need_client(client)?.pending_commit_ids();
1358            Ok(json!({ "ids": ids }))
1359        }
1360        "pendingPayloads" => {
1361            let payloads = need_client(client)?.pending_payloads();
1362            Ok(json!({
1363                "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1364            }))
1365        }
1366        "subscriptionState" => {
1367            let id = params
1368                .get("id")
1369                .and_then(Value::as_str)
1370                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
1371            let state = need_client(client)?.subscription_state(id);
1372            Ok(json!({ "state": state }))
1373        }
1374        "recreateWithSchema" => {
1375            // §7.4.2 "app ships new code": swap to the new schema on the SAME
1376            // in-memory database (the Rust core has no persistent restart, so
1377            // recreation IS the boot). Fires the §7.4.1 marker check.
1378            let schema = params
1379                .get("schema")
1380                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1381            need_client(client)?
1382                .recreate_with_schema(schema)
1383                .map_err(client_err)?;
1384            Ok(json!({}))
1385        }
1386        "connectRealtime" => {
1387            need_client(client)?
1388                .connect_realtime(transport)
1389                .map_err(client_err)?;
1390            Ok(json!({}))
1391        }
1392        "disconnectRealtime" => {
1393            need_client(client)?.disconnect_realtime(transport);
1394            Ok(json!({}))
1395        }
1396        "setPresence" => {
1397            let scope_key = params
1398                .get("scopeKey")
1399                .and_then(Value::as_str)
1400                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1401                .to_owned();
1402            // §8.6.2: `doc` may be a JSON object or null (a leave).
1403            let doc = params.get("doc");
1404            let doc_ref = match doc {
1405                None | Some(Value::Null) => None,
1406                Some(v) => Some(v),
1407            };
1408            need_client(client)?
1409                .set_presence(transport, &scope_key, doc_ref)
1410                .map_err(client_err)?;
1411            Ok(json!({}))
1412        }
1413        "presence" => {
1414            let scope_key = params
1415                .get("scopeKey")
1416                .and_then(Value::as_str)
1417                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1418            let peers = need_client(client)?.presence(scope_key);
1419            Ok(json!({ "peers": peers }))
1420        }
1421
1422        // -- CodecDriver surface (Appendix A) — no client instance needed --
1423        "messageRoundtrip" => {
1424            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1425            match decode_message(&bytes) {
1426                Ok(message) => Ok(json!({
1427                    "ok": true,
1428                    "bytes": bytes_value(&encode_message(&message)),
1429                    "renderedJson": render_message(&message).to_string(),
1430                })),
1431                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1432            }
1433        }
1434        "segmentRoundtrip" => {
1435            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1436            match decode_rows_segment(&bytes) {
1437                Ok(segment) => Ok(json!({
1438                    "ok": true,
1439                    "bytes": bytes_value(&encode_rows_segment(&segment)),
1440                    "renderedJson": render_rows_segment(&segment).to_string(),
1441                })),
1442                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1443            }
1444        }
1445        "realtimeKnown" => {
1446            let text = params
1447                .get("text")
1448                .and_then(Value::as_str)
1449                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1450            let known = matches!(
1451                parse_control(text),
1452                Ok(ControlMessage::Hello { .. })
1453                    | Ok(ControlMessage::Wake { .. })
1454                    | Ok(ControlMessage::Heartbeat { .. })
1455                    | Ok(ControlMessage::Presence { .. })
1456            );
1457            Ok(json!({ "value": known }))
1458        }
1459
1460        other => Err(client_err(format!("unknown method {other:?}"))),
1461    };
1462    if client
1463        .as_ref()
1464        .is_some_and(|running| !running.transport_enabled())
1465    {
1466        if let Ok(value) = &mut result {
1467            if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
1468                effects.remove("sync");
1469            }
1470        }
1471    }
1472    result
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477    use serde_json::{json, Value};
1478    use syncular_client::{
1479        Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1480        SECURITY_PREFLIGHT_REQUIRED_CODE,
1481    };
1482
1483    use super::{
1484        client_err, dispatch, parse_encryption, parse_headers, parse_previous_version_context,
1485        CreateEffects,
1486    };
1487
1488    #[derive(Default)]
1489    struct NoNetwork {
1490        realtime_connects: usize,
1491        realtime_closes: usize,
1492    }
1493
1494    impl Transport for NoNetwork {
1495        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1496            Err(TransportError::new("sync.transport_failed", "offline"))
1497        }
1498
1499        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1500            Err(TransportError::new("sync.transport_failed", "offline"))
1501        }
1502
1503        fn download_segment(
1504            &mut self,
1505            _request: &SegmentRequest,
1506            _on_progress: &mut dyn FnMut(u64),
1507        ) -> Result<Vec<u8>, TransportError> {
1508            Err(TransportError::new("sync.transport_failed", "offline"))
1509        }
1510
1511        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1512            self.realtime_connects += 1;
1513            Ok(())
1514        }
1515
1516        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1517            Ok(())
1518        }
1519
1520        fn realtime_close(&mut self) -> Result<(), TransportError> {
1521            self.realtime_closes += 1;
1522            Ok(())
1523        }
1524    }
1525
1526    fn schema() -> Value {
1527        json!({
1528            "version": 1,
1529            "tables": [{
1530                "name": "todos",
1531                "columns": [
1532                    { "name": "id", "type": "string", "nullable": false },
1533                    { "name": "list_id", "type": "string", "nullable": false }
1534                ],
1535                "primaryKey": "id",
1536                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1537            }]
1538        })
1539    }
1540
1541    #[test]
1542    fn native_command_realtime_connection_is_idempotent() {
1543        let mut transport = NoNetwork::default();
1544        let mut client: Option<SyncClient> = None;
1545        let mut effects = CreateEffects::default();
1546        dispatch(
1547            &mut transport,
1548            &mut client,
1549            &mut effects,
1550            "create",
1551            &json!({ "schema": schema() }),
1552        )
1553        .expect("create client");
1554
1555        for _ in 0..2 {
1556            dispatch(
1557                &mut transport,
1558                &mut client,
1559                &mut effects,
1560                "connectRealtime",
1561                &json!({}),
1562            )
1563            .expect("idempotent connect command");
1564        }
1565        assert_eq!(transport.realtime_connects, 1);
1566
1567        dispatch(
1568            &mut transport,
1569            &mut client,
1570            &mut effects,
1571            "disconnectRealtime",
1572            &json!({}),
1573        )
1574        .expect("disconnect command");
1575        dispatch(
1576            &mut transport,
1577            &mut client,
1578            &mut effects,
1579            "disconnectRealtime",
1580            &json!({}),
1581        )
1582        .expect("idempotent disconnect command");
1583        assert_eq!(transport.realtime_closes, 1);
1584        dispatch(
1585            &mut transport,
1586            &mut client,
1587            &mut effects,
1588            "connectRealtime",
1589            &json!({}),
1590        )
1591        .expect("deliberate reconnect command");
1592        assert_eq!(transport.realtime_connects, 2);
1593    }
1594
1595    #[test]
1596    fn schema_downgrade_has_structured_code_at_create_and_recreate_boundaries() {
1597        let path = temp_db_path("schema-downgrade");
1598        let mut newer = schema();
1599        newer["version"] = json!(3);
1600        let mut older = schema();
1601        older["version"] = json!(2);
1602        let mut transport = NoNetwork::default();
1603        let mut client = None;
1604        let mut effects = CreateEffects::default();
1605        dispatch(
1606            &mut transport,
1607            &mut client,
1608            &mut effects,
1609            "create",
1610            &json!({ "schema": newer, "dbPath": path }),
1611        )
1612        .expect("v3 create");
1613        let error = dispatch(
1614            &mut transport,
1615            &mut client,
1616            &mut effects,
1617            "recreateWithSchema",
1618            &json!({ "schema": older }),
1619        )
1620        .expect_err("refuse recreation");
1621        assert_eq!(error.code, "client.schema_downgrade");
1622        assert_eq!(
1623            error.message,
1624            "client.schema_downgrade: persisted local schema is newer than the requested schema"
1625        );
1626        dispatch(
1627            &mut transport,
1628            &mut client,
1629            &mut effects,
1630            "shutdown",
1631            &json!({}),
1632        )
1633        .expect("shutdown");
1634        let error = dispatch(
1635            &mut transport,
1636            &mut client,
1637            &mut effects,
1638            "create",
1639            &json!({ "schema": older, "dbPath": path }),
1640        )
1641        .expect_err("refuse open");
1642        assert_eq!(error.code, "client.schema_downgrade");
1643        assert!(client.is_none());
1644        dispatch(
1645            &mut transport,
1646            &mut client,
1647            &mut effects,
1648            "create",
1649            &json!({ "schema": newer, "dbPath": path }),
1650        )
1651        .expect("compatible reopen");
1652        dispatch(
1653            &mut transport,
1654            &mut client,
1655            &mut effects,
1656            "shutdown",
1657            &json!({}),
1658        )
1659        .expect("shutdown");
1660        let _ = std::fs::remove_file(path);
1661    }
1662
1663    #[test]
1664    fn native_command_realtime_policy_rides_create_and_refuses_http() {
1665        let mut transport = NoNetwork::default();
1666        let mut client: Option<SyncClient> = None;
1667        let mut effects = CreateEffects::default();
1668        dispatch(
1669            &mut transport,
1670            &mut client,
1671            &mut effects,
1672            "create",
1673            &json!({ "schema": schema(), "realtimePolicy": "required" }),
1674        )
1675        .expect("create client with a required realtime policy");
1676        let result = dispatch(
1677            &mut transport,
1678            &mut client,
1679            &mut effects,
1680            "sync",
1681            &json!({}),
1682        )
1683        .expect("sync command");
1684        assert_eq!(result["ok"], json!(false));
1685        assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
1686        assert_eq!(result["realtimeState"], json!("disconnected"));
1687        assert_eq!(result["retryDelayMs"], json!(250));
1688        let snapshot = dispatch(
1689            &mut transport,
1690            &mut client,
1691            &mut effects,
1692            "diagnosticsSnapshot",
1693            &json!({}),
1694        )
1695        .expect("diagnostics command");
1696        assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
1697        assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
1698        assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
1699
1700        let mut second: Option<SyncClient> = None;
1701        let error = dispatch(
1702            &mut transport,
1703            &mut second,
1704            &mut effects,
1705            "create",
1706            &json!({ "schema": schema(), "realtimePolicy": "always" }),
1707        )
1708        .expect_err("an unknown policy is refused");
1709        assert_eq!(error.code, "sync.invalid_request");
1710    }
1711
1712    #[test]
1713    fn parses_portable_encryption_keyring_and_key_id_columns() {
1714        let key_hex = "2a".repeat(32);
1715        let config = parse_encryption(&json!({
1716            "keys": { "practice-key-v1": { "$bytes": key_hex } },
1717            "keyIdColumns": { "patients": "encryption_key_id" }
1718        }))
1719        .expect("portable keyring parses");
1720        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1721        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1722    }
1723
1724    #[test]
1725    fn rejects_non_string_key_id_columns() {
1726        let error = parse_encryption(&json!({
1727            "keys": {},
1728            "keyIdColumns": { "patients": 7 }
1729        }))
1730        .expect_err("invalid selector must fail");
1731        assert!(error.contains("must be a string"), "{error}");
1732    }
1733
1734    #[test]
1735    fn subscribe_many_registers_every_subscription() {
1736        let mut transport = NoNetwork::default();
1737        let mut client: Option<SyncClient> = None;
1738        let mut effects = CreateEffects::default();
1739        dispatch(
1740            &mut transport,
1741            &mut client,
1742            &mut effects,
1743            "create",
1744            &json!({ "schema": schema() }),
1745        )
1746        .expect("create client");
1747        dispatch(
1748            &mut transport,
1749            &mut client,
1750            &mut effects,
1751            "subscribeMany",
1752            &json!({ "subscriptions": [
1753                { "id": "a", "table": "todos", "scopes": { "list_id": ["list-1"] } },
1754                { "id": "b", "table": "todos", "scopes": { "list_id": ["list-2"] } }
1755            ] }),
1756        )
1757        .expect("register subscriptions");
1758        for id in ["a", "b"] {
1759            let state = dispatch(
1760                &mut transport,
1761                &mut client,
1762                &mut effects,
1763                "subscriptionState",
1764                &json!({ "id": id }),
1765            )
1766            .expect("subscription state");
1767            assert_eq!(state["state"]["table"], "todos");
1768        }
1769    }
1770
1771    #[test]
1772    fn native_command_hosts_reject_subscription_identity_rebinds() {
1773        let mut transport = NoNetwork::default();
1774        let mut client: Option<SyncClient> = None;
1775        let mut effects = CreateEffects::default();
1776        dispatch(
1777            &mut transport,
1778            &mut client,
1779            &mut effects,
1780            "create",
1781            &json!({ "schema": schema() }),
1782        )
1783        .expect("create client");
1784
1785        let original = json!({
1786            "id": "stable-subscription",
1787            "table": "todos",
1788            "scopes": { "list_id": ["list-2", "list-1"] },
1789            "params": "{\"view\":\"v1\"}"
1790        });
1791        dispatch(
1792            &mut transport,
1793            &mut client,
1794            &mut effects,
1795            "subscribe",
1796            &original,
1797        )
1798        .expect("register subscription");
1799        dispatch(
1800            &mut transport,
1801            &mut client,
1802            &mut effects,
1803            "subscribe",
1804            &json!({
1805                "id": "stable-subscription",
1806                "table": "todos",
1807                "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1808                "params": "{\"view\":\"v1\"}"
1809            }),
1810        )
1811        .expect("canonical re-declaration is idempotent");
1812
1813        let error = dispatch(
1814            &mut transport,
1815            &mut client,
1816            &mut effects,
1817            "subscribe",
1818            &json!({
1819                "id": "stable-subscription",
1820                "table": "todos",
1821                "scopes": { "list_id": ["list-2"] },
1822                "params": "{\"view\":\"v1\"}"
1823            }),
1824        )
1825        .expect_err("changed native query identity must fail");
1826        assert_eq!(error.code, "client.subscription_intent_mismatch");
1827
1828        let state = dispatch(
1829            &mut transport,
1830            &mut client,
1831            &mut effects,
1832            "subscriptionState",
1833            &json!({ "id": "stable-subscription" }),
1834        )
1835        .expect("subscription state");
1836        assert_eq!(state["state"]["cursor"], -1);
1837        assert_eq!(state["state"]["table"], "todos");
1838    }
1839
1840    #[test]
1841    fn security_preflight_is_fail_closed_until_exact_activation() {
1842        let mut transport = NoNetwork::default();
1843        let mut client: Option<SyncClient> = None;
1844        let mut effects = CreateEffects::default();
1845        dispatch(
1846            &mut transport,
1847            &mut client,
1848            &mut effects,
1849            "create",
1850            &json!({ "schema": schema(), "securityPreflight": true }),
1851        )
1852        .expect("preflight create");
1853
1854        let lifecycle = dispatch(
1855            &mut transport,
1856            &mut client,
1857            &mut effects,
1858            "securityLifecycle",
1859            &json!({}),
1860        )
1861        .expect("lifecycle");
1862        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1863
1864        let query_error = dispatch(
1865            &mut transport,
1866            &mut client,
1867            &mut effects,
1868            "query",
1869            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1870        )
1871        .expect_err("protected query must fail");
1872        assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1873        let diagnostics_error = dispatch(
1874            &mut transport,
1875            &mut client,
1876            &mut effects,
1877            "diagnosticsSnapshot",
1878            &json!({}),
1879        )
1880        .expect_err("diagnostics table/subscription evidence remains protected");
1881        assert_eq!(diagnostics_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1882
1883        dispatch(
1884            &mut transport,
1885            &mut client,
1886            &mut effects,
1887            "purgeLocalData",
1888            &json!({
1889                "input": {
1890                    "purgeId": "directive-1",
1891                    "targets": [{
1892                        "table": "todos",
1893                        "selectors": { "list_id": ["list-1"] }
1894                    }]
1895                }
1896            }),
1897        )
1898        .expect("authorized local purge remains available");
1899
1900        let repair_error = dispatch(
1901            &mut transport,
1902            &mut client,
1903            &mut effects,
1904            "rebootstrapLocalData",
1905            &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1906        )
1907        .expect_err("projection repair must remain protected during preflight");
1908        assert_eq!(repair_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1909
1910        dispatch(
1911            &mut transport,
1912            &mut client,
1913            &mut effects,
1914            "activateSecurity",
1915            &json!({}),
1916        )
1917        .expect("activation");
1918        let rows = dispatch(
1919            &mut transport,
1920            &mut client,
1921            &mut effects,
1922            "query",
1923            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1924        )
1925        .expect("active query");
1926        assert_eq!(rows, json!({ "rows": [] }));
1927
1928        dispatch(
1929            &mut transport,
1930            &mut client,
1931            &mut effects,
1932            "beginSecurityPreflight",
1933            &json!({}),
1934        )
1935        .expect("re-enter preflight");
1936        let blocked = dispatch(
1937            &mut transport,
1938            &mut client,
1939            &mut effects,
1940            "mutate",
1941            &json!({ "mutations": [] }),
1942        )
1943        .expect_err("mutation must be gated");
1944        assert_eq!(blocked.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1945    }
1946
1947    #[test]
1948    fn mutate_and_patch_forward_typed_storage_failures() {
1949        let path = temp_db_path("busy-authoring");
1950        let schema = json!({
1951            "version": 1,
1952            "tables": [{
1953                "name": "todos",
1954                "columns": [
1955                    { "name": "id", "type": "string", "nullable": false },
1956                    { "name": "list_id", "type": "string", "nullable": false },
1957                    { "name": "title", "type": "string", "nullable": false }
1958                ],
1959                "primaryKey": "id",
1960                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1961            }]
1962        });
1963        let mut transport = NoNetwork::default();
1964        let mut client: Option<SyncClient> = None;
1965        let mut effects = CreateEffects::default();
1966        dispatch(
1967            &mut transport,
1968            &mut client,
1969            &mut effects,
1970            "create",
1971            &json!({ "schema": schema, "dbPath": path }),
1972        )
1973        .expect("create file-backed client");
1974        let upsert = |id: &str| {
1975            json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
1976                "id": id, "list_id": "l1", "title": "first" } }] })
1977        };
1978        dispatch(
1979            &mut transport,
1980            &mut client,
1981            &mut effects,
1982            "mutate",
1983            &upsert("t1"),
1984        )
1985        .expect("seed row");
1986
1987        let locker = rusqlite::Connection::open(&path).expect("second connection");
1988        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
1989        let busy = dispatch(
1990            &mut transport,
1991            &mut client,
1992            &mut effects,
1993            "mutate",
1994            &upsert("t2"),
1995        )
1996        .expect_err("busy mutate");
1997        assert_eq!(busy.code, "client.storage_busy");
1998        assert!(busy.retryable);
1999        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
2000
2001        let busy_patch = dispatch(
2002            &mut transport,
2003            &mut client,
2004            &mut effects,
2005            "patch",
2006            &json!({ "table": "todos", "rowId": "t1", "partial": { "title": "second" } }),
2007        )
2008        .expect_err("busy patch");
2009        assert_eq!(busy_patch.code, "client.storage_busy");
2010        assert!(busy_patch.retryable);
2011        assert_eq!(busy_patch.details.as_ref().unwrap()["sqliteCode"], 5);
2012
2013        locker.execute_batch("ROLLBACK").unwrap();
2014        let retried = dispatch(
2015            &mut transport,
2016            &mut client,
2017            &mut effects,
2018            "mutate",
2019            &upsert("t2"),
2020        )
2021        .expect("retry after busy");
2022        assert!(retried.get("clientCommitId").is_some());
2023        let _ = std::fs::remove_file(&path);
2024    }
2025
2026    #[test]
2027    fn read_command_error_preserves_code_details_and_retryability() {
2028        let corrupt = super::read_command_error(syncular_client::QueryReadFailure {
2029            code: Some("sync.local_corrupt"),
2030            sqlite_code: None,
2031            sqlite_message: None,
2032            rollback_failure: None,
2033            message: "persisted local revision is invalid".to_owned(),
2034        });
2035        assert_eq!(corrupt.code, "sync.local_corrupt");
2036        assert!(!corrupt.retryable);
2037        assert!(corrupt.details.is_none());
2038
2039        let busy = super::read_command_error(syncular_client::QueryReadFailure {
2040            code: Some("client.storage_busy"),
2041            sqlite_code: Some(5),
2042            sqlite_message: Some("database is locked".to_owned()),
2043            rollback_failure: None,
2044            message: "local SQLite storage is busy".to_owned(),
2045        });
2046        assert_eq!(busy.code, "client.storage_busy");
2047        assert!(busy.retryable);
2048        assert_eq!(
2049            busy.details.expect("busy details")["sqliteCode"],
2050            serde_json::json!(5)
2051        );
2052    }
2053
2054    #[test]
2055    fn mutate_and_patch_report_caller_value_failures_as_invalid_request() {
2056        let path = temp_db_path("caller-values");
2057        let schema = json!({
2058            "version": 1,
2059            "tables": [{
2060                "name": "todos",
2061                "columns": [
2062                    { "name": "id", "type": "string", "nullable": false },
2063                    { "name": "list_id", "type": "string", "nullable": false },
2064                    { "name": "title", "type": "string", "nullable": false },
2065                    { "name": "priority", "type": "integer", "nullable": false }
2066                ],
2067                "primaryKey": "id",
2068                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2069            }]
2070        });
2071        let mut transport = NoNetwork::default();
2072        let mut client: Option<SyncClient> = None;
2073        let mut effects = CreateEffects::default();
2074        dispatch(
2075            &mut transport,
2076            &mut client,
2077            &mut effects,
2078            "create",
2079            &json!({ "schema": schema, "dbPath": path }),
2080        )
2081        .expect("create file-backed client");
2082        let seed = dispatch(
2083            &mut transport,
2084            &mut client,
2085            &mut effects,
2086            "mutate",
2087            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2088                "id": "t1", "list_id": "l1", "title": "first", "priority": 1 } }] }),
2089        )
2090        .expect("seed row");
2091        let seed_id = seed["clientCommitId"].clone();
2092
2093        // §6.1/§7.1: a caller value the authoring seam rejects is an invalid
2094        // request, not the internal-failure default.
2095        let unknown = dispatch(
2096            &mut transport,
2097            &mut client,
2098            &mut effects,
2099            "mutate",
2100            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2101                "id": "t2", "list_id": "l1", "title": "second", "priority": 1,
2102                "missing_column": 1 } }] }),
2103        )
2104        .expect_err("unknown column");
2105        assert_eq!(unknown.code, "sync.invalid_request");
2106        assert_eq!(unknown.message, "the authoring request is invalid");
2107        assert!(!unknown.retryable);
2108        assert!(unknown.details.as_ref().unwrap()["legacyCause"]
2109            .as_str()
2110            .unwrap()
2111            .contains("unknown column"));
2112
2113        let bad_patch = dispatch(
2114            &mut transport,
2115            &mut client,
2116            &mut effects,
2117            "patch",
2118            &json!({ "table": "todos", "rowId": "t1", "partial": { "priority": "high" } }),
2119        )
2120        .expect_err("bad patch value");
2121        assert_eq!(bad_patch.code, "sync.invalid_request");
2122        assert_eq!(bad_patch.message, "the authoring request is invalid");
2123        assert!(bad_patch.details.as_ref().unwrap()["legacyCause"]
2124            .as_str()
2125            .unwrap()
2126            .contains("priority"));
2127
2128        // Neither rejection enqueued a commit or touched the projection.
2129        let pending = dispatch(
2130            &mut transport,
2131            &mut client,
2132            &mut effects,
2133            "pendingCommitIds",
2134            &json!({}),
2135        )
2136        .expect("pending commit ids");
2137        assert_eq!(pending["ids"], json!([seed_id]));
2138        let rows = dispatch(
2139            &mut transport,
2140            &mut client,
2141            &mut effects,
2142            "query",
2143            &json!({ "sql": "SELECT id, priority FROM todos ORDER BY id" }),
2144        )
2145        .expect("visible rows");
2146        assert_eq!(rows["rows"], json!([{ "id": "t1", "priority": 1 }]));
2147
2148        // The next valid call authorizes normally.
2149        let accepted = dispatch(
2150            &mut transport,
2151            &mut client,
2152            &mut effects,
2153            "mutate",
2154            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2155                "id": "t2", "list_id": "l1", "title": "second", "priority": 2 } }] }),
2156        )
2157        .expect("retry after rejection");
2158        assert!(accepted.get("clientCommitId").is_some());
2159        let _ = std::fs::remove_file(&path);
2160    }
2161
2162    #[test]
2163    fn client_err_preserves_retryable_for_storage_busy() {
2164        let busy = client_err("client.storage_busy: local SQLite storage is busy".to_owned());
2165        assert_eq!(busy.code, "client.storage_busy");
2166        assert!(busy.retryable);
2167        // The legacy boundary keeps the full rendered message.
2168        assert_eq!(
2169            busy.message,
2170            "client.storage_busy: local SQLite storage is busy"
2171        );
2172        let full = client_err("client.storage_full: local SQLite storage is full".to_owned());
2173        assert_eq!(full.code, "client.storage_full");
2174        assert!(!full.retryable);
2175    }
2176
2177    #[test]
2178    fn sync_until_idle_rejects_an_invalid_max_rounds_budget() {
2179        for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2180            let mut transport = NoNetwork::default();
2181            let mut client: Option<SyncClient> = None;
2182            let mut effects = CreateEffects::default();
2183            dispatch(
2184                &mut transport,
2185                &mut client,
2186                &mut effects,
2187                "create",
2188                &json!({ "schema": schema() }),
2189            )
2190            .expect("create");
2191            let error = dispatch(
2192                &mut transport,
2193                &mut client,
2194                &mut effects,
2195                "syncUntilIdle",
2196                &json!({ "maxRounds": value }),
2197            )
2198            .expect_err("invalid budget");
2199            assert_eq!(error.code, "sync.invalid_request", "{value}");
2200        }
2201    }
2202
2203    #[test]
2204    fn create_rejects_invalid_push_limits() {
2205        for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2206            let mut transport = NoNetwork::default();
2207            let mut client: Option<SyncClient> = None;
2208            let mut effects = CreateEffects::default();
2209            let error = dispatch(
2210                &mut transport,
2211                &mut client,
2212                &mut effects,
2213                "create",
2214                &json!({
2215                    "schema": schema(),
2216                    "limits": { "maxPushRequestBytes": value }
2217                }),
2218            )
2219            .expect_err("invalid push limit");
2220            assert_eq!(error.code, "sync.invalid_request", "{value}");
2221            assert_eq!(
2222                error.details.as_ref().unwrap()["limit"],
2223                json!("maxPushRequestBytes")
2224            );
2225        }
2226    }
2227
2228    #[test]
2229    fn preflight_allows_only_the_authorized_previous_version_discard() {
2230        let mut transport = NoNetwork::default();
2231        let mut client: Option<SyncClient> = None;
2232        let mut effects = CreateEffects::default();
2233        dispatch(
2234            &mut transport,
2235            &mut client,
2236            &mut effects,
2237            "create",
2238            &json!({ "schema": schema(), "securityPreflight": true }),
2239        )
2240        .expect("preflight create");
2241
2242        // Reads and the audit observe protected local data: still gated.
2243        for method in ["previousVersionSnapshot", "previousVersionAudit"] {
2244            let error = dispatch(
2245                &mut transport,
2246                &mut client,
2247                &mut effects,
2248                method,
2249                &json!({}),
2250            )
2251            .expect_err("previous-version reads stay gated during preflight");
2252            assert_eq!(error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2253        }
2254        // RFC 0006 runs the discard consumer inside the quiesced window, so the
2255        // command router must permit it (feature off here: a clean no-op).
2256        let discarded = dispatch(
2257            &mut transport,
2258            &mut client,
2259            &mut effects,
2260            "previousVersionDiscard",
2261            &json!({}),
2262        )
2263        .expect("authorized cleanup is allowed during preflight");
2264        assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2265        assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
2266    }
2267
2268    #[test]
2269    fn preflight_refuses_a_replacement_create_without_the_flag() {
2270        let mut transport = NoNetwork::default();
2271        let mut client: Option<SyncClient> = None;
2272        let mut effects = CreateEffects::default();
2273        dispatch(
2274            &mut transport,
2275            &mut client,
2276            &mut effects,
2277            "create",
2278            &json!({ "schema": schema(), "securityPreflight": true }),
2279        )
2280        .expect("preflight create");
2281
2282        // The escape the gate exists to prevent: a plain re-create must fail.
2283        let escape = dispatch(
2284            &mut transport,
2285            &mut client,
2286            &mut effects,
2287            "create",
2288            &json!({ "schema": schema() }),
2289        )
2290        .expect_err("plain create must be refused during preflight");
2291        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2292        // The refused create left the preflighted client installed and gated.
2293        let query_error = dispatch(
2294            &mut transport,
2295            &mut client,
2296            &mut effects,
2297            "query",
2298            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2299        )
2300        .expect_err("protected query stays gated");
2301        assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2302
2303        // A preflighted replacement is permitted and stays gated.
2304        dispatch(
2305            &mut transport,
2306            &mut client,
2307            &mut effects,
2308            "create",
2309            &json!({ "schema": schema(), "securityPreflight": true }),
2310        )
2311        .expect("preflighted replacement create");
2312        let still_gated = dispatch(
2313            &mut transport,
2314            &mut client,
2315            &mut effects,
2316            "query",
2317            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2318        )
2319        .expect_err("replacement stays gated");
2320        assert_eq!(still_gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2321
2322        // A legitimate activation releases the gate; creates behave as today.
2323        dispatch(
2324            &mut transport,
2325            &mut client,
2326            &mut effects,
2327            "activateSecurity",
2328            &json!({}),
2329        )
2330        .expect("activation");
2331        dispatch(
2332            &mut transport,
2333            &mut client,
2334            &mut effects,
2335            "create",
2336            &json!({ "schema": schema() }),
2337        )
2338        .expect("plain create after activation");
2339        let rows = dispatch(
2340            &mut transport,
2341            &mut client,
2342            &mut effects,
2343            "query",
2344            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2345        )
2346        .expect("active query");
2347        assert_eq!(rows, json!({ "rows": [] }));
2348    }
2349
2350    /// A file-backed replica: the persisted quarantine marker only exists here,
2351    /// which is why the in-memory tests above could not catch this.
2352    fn temp_db_path(tag: &str) -> String {
2353        std::env::temp_dir()
2354            .join(format!(
2355                "syncular-command-{tag}-{}-{:?}.db",
2356                std::process::id(),
2357                std::time::SystemTime::now()
2358                    .duration_since(std::time::UNIX_EPOCH)
2359                    .expect("clock")
2360                    .as_nanos()
2361            ))
2362            .to_string_lossy()
2363            .into_owned()
2364    }
2365
2366    #[test]
2367    fn an_activated_replica_reopens_plainly_after_shutdown() {
2368        // Shutting an ACTIVATED client down is not a quarantine event. The
2369        // teardown barrier used to call `begin_security_preflight`, which now
2370        // persists the gate, so every cleanly closed replica was marked pending
2371        // and its next plain `create` was refused forever — the reopen path
2372        // restores the flag from the marker and the create guard rejects it.
2373        let path = temp_db_path("activated-reopen");
2374        let mut transport = NoNetwork::default();
2375        let mut client: Option<SyncClient> = None;
2376        let mut effects = CreateEffects::default();
2377
2378        dispatch(
2379            &mut transport,
2380            &mut client,
2381            &mut effects,
2382            "create",
2383            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2384        )
2385        .expect("preflight create");
2386        dispatch(
2387            &mut transport,
2388            &mut client,
2389            &mut effects,
2390            "activateSecurity",
2391            &json!({}),
2392        )
2393        .expect("activation clears the gate");
2394        dispatch(
2395            &mut transport,
2396            &mut client,
2397            &mut effects,
2398            "shutdown",
2399            &json!({}),
2400        )
2401        .expect("shutdown after activation");
2402
2403        // The replica is active on disk, so a plain re-create must be admitted.
2404        dispatch(
2405            &mut transport,
2406            &mut client,
2407            &mut effects,
2408            "create",
2409            &json!({ "schema": schema(), "dbPath": path }),
2410        )
2411        .expect("an activated replica must reopen plainly after shutdown");
2412        let state = dispatch(
2413            &mut transport,
2414            &mut client,
2415            &mut effects,
2416            "securityLifecycle",
2417            &json!({}),
2418        )
2419        .expect("lifecycle");
2420        assert_eq!(state, json!({ "state": "active" }));
2421
2422        let _ = std::fs::remove_file(&path);
2423    }
2424
2425    #[test]
2426    fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
2427        // The other half, and the reason the marker exists: a replica that
2428        // entered preflight and never activated must still refuse a plain
2429        // re-create after shutdown, even on a host that rebuilt its handle and
2430        // carries no in-memory flag.
2431        let path = temp_db_path("quarantined-reopen");
2432        let mut transport = NoNetwork::default();
2433        let mut client: Option<SyncClient> = None;
2434        let mut effects = CreateEffects::default();
2435
2436        dispatch(
2437            &mut transport,
2438            &mut client,
2439            &mut effects,
2440            "create",
2441            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2442        )
2443        .expect("preflight create");
2444        dispatch(
2445            &mut transport,
2446            &mut client,
2447            &mut effects,
2448            "shutdown",
2449            &json!({}),
2450        )
2451        .expect("shutdown during preflight");
2452
2453        // A fresh host handle: no in-memory pending flag survives here, so only
2454        // the persisted marker can hold the gate.
2455        let mut rebuilt = CreateEffects::default();
2456        let escape = dispatch(
2457            &mut transport,
2458            &mut client,
2459            &mut rebuilt,
2460            "create",
2461            &json!({ "schema": schema(), "dbPath": path }),
2462        )
2463        .expect_err("a quarantined replica must refuse a plain re-create");
2464        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2465
2466        let _ = std::fs::remove_file(&path);
2467    }
2468
2469    #[test]
2470    fn preflight_gate_survives_shutdown_before_replacement_creates() {
2471        let mut transport = NoNetwork::default();
2472        let mut client: Option<SyncClient> = None;
2473        let mut effects = CreateEffects::default();
2474        dispatch(
2475            &mut transport,
2476            &mut client,
2477            &mut effects,
2478            "create",
2479            &json!({ "schema": schema(), "securityPreflight": true }),
2480        )
2481        .expect("preflight create");
2482        dispatch(
2483            &mut transport,
2484            &mut client,
2485            &mut effects,
2486            "shutdown",
2487            &json!({}),
2488        )
2489        .expect("shutdown during preflight");
2490        assert!(client.is_none());
2491
2492        // The pending preflight rides `effects` across the empty client slot.
2493        let escape = dispatch(
2494            &mut transport,
2495            &mut client,
2496            &mut effects,
2497            "create",
2498            &json!({ "schema": schema() }),
2499        )
2500        .expect_err("shutdown + plain create must stay refused");
2501        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2502
2503        dispatch(
2504            &mut transport,
2505            &mut client,
2506            &mut effects,
2507            "create",
2508            &json!({ "schema": schema(), "securityPreflight": true }),
2509        )
2510        .expect("preflighted re-create");
2511        dispatch(
2512            &mut transport,
2513            &mut client,
2514            &mut effects,
2515            "activateSecurity",
2516            &json!({}),
2517        )
2518        .expect("activation");
2519
2520        // An ACTIVATED client shut down cleanly may be recreated plainly.
2521        dispatch(
2522            &mut transport,
2523            &mut client,
2524            &mut effects,
2525            "shutdown",
2526            &json!({}),
2527        )
2528        .expect("shutdown after activation");
2529        dispatch(
2530            &mut transport,
2531            &mut client,
2532            &mut effects,
2533            "create",
2534            &json!({ "schema": schema() }),
2535        )
2536        .expect("plain create after an activated shutdown");
2537    }
2538
2539    #[test]
2540    fn activate_security_validates_optional_headers_atomically() {
2541        let mut transport = NoNetwork::default();
2542        let mut client: Option<SyncClient> = None;
2543        let mut effects = CreateEffects::default();
2544        dispatch(
2545            &mut transport,
2546            &mut client,
2547            &mut effects,
2548            "create",
2549            &json!({ "schema": schema(), "securityPreflight": true }),
2550        )
2551        .expect("preflight create");
2552
2553        // Invalid header shapes fail loudly and keep the gate closed.
2554        let invalid = dispatch(
2555            &mut transport,
2556            &mut client,
2557            &mut effects,
2558            "activateSecurity",
2559            &json!({ "headers": { "authorization": 7 } }),
2560        )
2561        .expect_err("non-string header must fail");
2562        assert_eq!(invalid.code, "sync.invalid_request");
2563        let lifecycle = dispatch(
2564            &mut transport,
2565            &mut client,
2566            &mut effects,
2567            "securityLifecycle",
2568            &json!({}),
2569        )
2570        .expect("lifecycle");
2571        assert_eq!(lifecycle, json!({ "state": "preflight" }));
2572
2573        // A valid header set activates in one atomic step.
2574        dispatch(
2575            &mut transport,
2576            &mut client,
2577            &mut effects,
2578            "activateSecurity",
2579            &json!({ "headers": { "authorization": "Bearer fresh" } }),
2580        )
2581        .expect("activation with fresh headers");
2582        let lifecycle = dispatch(
2583            &mut transport,
2584            &mut client,
2585            &mut effects,
2586            "securityLifecycle",
2587            &json!({}),
2588        )
2589        .expect("lifecycle");
2590        assert_eq!(lifecycle, json!({ "state": "active" }));
2591    }
2592
2593    #[test]
2594    fn parse_headers_reads_the_full_replacement_set() {
2595        let parsed = parse_headers(&json!({
2596            "authorization": "Bearer fresh",
2597            "x-tenant": "t1"
2598        }))
2599        .expect("valid headers parse");
2600        assert_eq!(
2601            parsed,
2602            vec![
2603                ("authorization".to_owned(), "Bearer fresh".to_owned()),
2604                ("x-tenant".to_owned(), "t1".to_owned())
2605            ]
2606        );
2607        assert!(parse_headers(&json!(["authorization"])).is_err());
2608        assert!(parse_headers(&json!({ "authorization": null })).is_err());
2609    }
2610
2611    #[test]
2612    fn set_headers_validates_replacement_and_respects_preflight() {
2613        let mut transport = NoNetwork::default();
2614        let mut client: Option<SyncClient> = None;
2615        let mut effects = CreateEffects::default();
2616        dispatch(
2617            &mut transport,
2618            &mut client,
2619            &mut effects,
2620            "create",
2621            &json!({ "schema": schema() }),
2622        )
2623        .expect("create");
2624        dispatch(
2625            &mut transport,
2626            &mut client,
2627            &mut effects,
2628            "setHeaders",
2629            &json!({ "headers": { "authorization": "Bearer fresh" } }),
2630        )
2631        .expect("valid replacement");
2632        let invalid = dispatch(
2633            &mut transport,
2634            &mut client,
2635            &mut effects,
2636            "setHeaders",
2637            &json!({ "headers": { "authorization": 7 } }),
2638        )
2639        .expect_err("invalid replacement");
2640        assert_eq!(invalid.code, "sync.invalid_request");
2641
2642        dispatch(
2643            &mut transport,
2644            &mut client,
2645            &mut effects,
2646            "beginSecurityPreflight",
2647            &json!({}),
2648        )
2649        .expect("preflight");
2650        let gated = dispatch(
2651            &mut transport,
2652            &mut client,
2653            &mut effects,
2654            "setHeaders",
2655            &json!({ "headers": {} }),
2656        )
2657        .expect_err("direct rotation must respect preflight");
2658        assert_eq!(gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2659    }
2660
2661    fn schema_v2() -> Value {
2662        json!({
2663            "version": 2,
2664            "tables": [{
2665                "name": "todos",
2666                "columns": [
2667                    { "name": "id", "type": "string", "nullable": false },
2668                    { "name": "list_id", "type": "string", "nullable": false },
2669                    { "name": "note", "type": "string", "nullable": true }
2670                ],
2671                "primaryKey": "id",
2672                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2673            }]
2674        })
2675    }
2676
2677    fn previous_version_enabled() -> Value {
2678        json!({ "enabled": true })
2679    }
2680
2681    fn container_path(db_path: &str) -> String {
2682        format!("{db_path}.prev-context")
2683    }
2684
2685    #[test]
2686    fn previous_version_config_parser_matches_ts_keys_and_defaults() {
2687        assert_eq!(parse_previous_version_context(None).expect("absent"), None);
2688        let defaults = parse_previous_version_context(Some(&json!({})))
2689            .expect("empty")
2690            .expect("present");
2691        assert!(!defaults.enabled);
2692        assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
2693        assert_eq!(defaults.max_rows, 20_000);
2694        assert_eq!(defaults.max_tables, 32);
2695        assert_eq!(defaults.max_row_bytes, 1024 * 1024);
2696        assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
2697
2698        let configured = parse_previous_version_context(Some(&json!({
2699            "enabled": true,
2700            "maxBytes": 1024,
2701            "maxRows": 3,
2702            "maxTables": 2,
2703            "maxRowBytes": 5,
2704            "maxAgeMs": 7
2705        })))
2706        .expect("configured")
2707        .expect("present");
2708        assert!(configured.enabled);
2709        assert_eq!(configured.max_bytes, 1024);
2710        assert_eq!(configured.max_rows, 3);
2711        assert_eq!(configured.max_tables, 2);
2712        assert_eq!(configured.max_row_bytes, 5);
2713        assert_eq!(configured.max_age_ms, 7);
2714
2715        // Invalid values fail loudly instead of silently defaulting.
2716        for (value, expected) in [
2717            (json!([]), "must be an object"),
2718            (json!({ "enabled": "yes" }), "enabled must be a boolean"),
2719            (
2720                json!({ "maxBytes": 0 }),
2721                "maxBytes must be a positive safe integer",
2722            ),
2723            (
2724                json!({ "maxRows": -1 }),
2725                "maxRows must be a positive safe integer",
2726            ),
2727            (
2728                json!({ "maxTables": 1.5 }),
2729                "maxTables must be a positive safe integer",
2730            ),
2731            (
2732                json!({ "maxRowBytes": "1" }),
2733                "maxRowBytes must be a positive safe integer",
2734            ),
2735            (
2736                json!({ "maxAgeMs": 0 }),
2737                "maxAgeMs must be a positive safe integer",
2738            ),
2739        ] {
2740            let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
2741            assert_eq!(error.code, "sync.invalid_request", "{value}");
2742            assert!(
2743                error.message.contains(expected),
2744                "{value}: {}",
2745                error.message
2746            );
2747        }
2748    }
2749
2750    #[test]
2751    fn invalid_previous_version_config_fails_create_loudly() {
2752        let mut transport = NoNetwork::default();
2753        let mut client: Option<SyncClient> = None;
2754        let mut effects = CreateEffects::default();
2755        let error = dispatch(
2756            &mut transport,
2757            &mut client,
2758            &mut effects,
2759            "create",
2760            &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
2761        )
2762        .expect_err("invalid config must fail the create");
2763        assert_eq!(error.code, "sync.invalid_request");
2764        assert!(error.message.contains("maxRows"), "{}", error.message);
2765        assert!(client.is_none(), "no client may be installed");
2766    }
2767
2768    #[test]
2769    fn previous_version_snapshot_reports_not_configured_by_default() {
2770        let mut transport = NoNetwork::default();
2771        let mut client: Option<SyncClient> = None;
2772        let mut effects = CreateEffects::default();
2773        dispatch(
2774            &mut transport,
2775            &mut client,
2776            &mut effects,
2777            "create",
2778            &json!({ "schema": schema() }),
2779        )
2780        .expect("create");
2781        let snapshot = dispatch(
2782            &mut transport,
2783            &mut client,
2784            &mut effects,
2785            "previousVersionSnapshot",
2786            &json!({ "table": "todos" }),
2787        )
2788        .expect("snapshot");
2789        assert_eq!(snapshot["state"], json!("previousVersion"));
2790        assert_eq!(snapshot["available"], json!(false));
2791        assert_eq!(snapshot["reason"], json!("not-configured"));
2792        assert_eq!(snapshot["rows"], json!([]));
2793        assert_eq!(snapshot["truncated"], json!(false));
2794        assert_eq!(snapshot["currentVersion"], json!(1));
2795        let discarded = dispatch(
2796            &mut transport,
2797            &mut client,
2798            &mut effects,
2799            "previousVersionDiscard",
2800            &json!({}),
2801        )
2802        .expect("discard");
2803        assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2804        assert_eq!(
2805            dispatch(
2806                &mut transport,
2807                &mut client,
2808                &mut effects,
2809                "previousVersionAudit",
2810                &json!({}),
2811            )
2812            .expect("audit"),
2813            Value::Null
2814        );
2815    }
2816
2817    #[test]
2818    fn previous_version_commands_round_trip_through_the_dispatcher() {
2819        let path = temp_db_path("previous-version");
2820        let mut transport = NoNetwork::default();
2821        let mut client: Option<SyncClient> = None;
2822        let mut effects = CreateEffects::default();
2823
2824        // A v1 replica with one pending local row and the feature on.
2825        dispatch(
2826            &mut transport,
2827            &mut client,
2828            &mut effects,
2829            "create",
2830            &json!({
2831                "schema": schema(),
2832                "dbPath": path,
2833                "previousVersionContext": previous_version_enabled()
2834            }),
2835        )
2836        .expect("create v1");
2837        client
2838            .as_mut()
2839            .expect("client")
2840            .mutate(vec![Mutation::Upsert {
2841                table: "todos".to_owned(),
2842                values: serde_json::Map::from_iter([
2843                    ("id".to_owned(), json!("t1")),
2844                    ("list_id".to_owned(), json!("l1")),
2845                ]),
2846                base_version: None,
2847            }])
2848            .expect("seed a local row");
2849        dispatch(
2850            &mut transport,
2851            &mut client,
2852            &mut effects,
2853            "shutdown",
2854            &json!({}),
2855        )
2856        .expect("shutdown");
2857
2858        // The bump captures into the sibling file.
2859        dispatch(
2860            &mut transport,
2861            &mut client,
2862            &mut effects,
2863            "create",
2864            &json!({
2865                "schema": schema_v2(),
2866                "dbPath": path,
2867                "previousVersionContext": previous_version_enabled()
2868            }),
2869        )
2870        .expect("create v2");
2871        assert!(
2872            std::path::Path::new(&container_path(&path)).exists(),
2873            "the bump must leave a container file"
2874        );
2875        let status = dispatch(
2876            &mut transport,
2877            &mut client,
2878            &mut effects,
2879            "statusSnapshot",
2880            &json!({}),
2881        )
2882        .expect("status");
2883        assert_eq!(
2884            status["previousVersionContext"]["present"],
2885            json!(true),
2886            "{status}"
2887        );
2888        assert!(
2889            status["previousVersionContext"]["createdAtMs"].is_i64(),
2890            "{status}"
2891        );
2892
2893        let snapshot = dispatch(
2894            &mut transport,
2895            &mut client,
2896            &mut effects,
2897            "previousVersionSnapshot",
2898            &json!({ "table": "todos" }),
2899        )
2900        .expect("snapshot");
2901        assert_eq!(snapshot["state"], json!("previousVersion"));
2902        assert_eq!(snapshot["available"], json!(true));
2903        assert_eq!(snapshot["previousVersion"], json!(1));
2904        assert_eq!(snapshot["currentVersion"], json!(2));
2905        assert_eq!(snapshot["truncated"], json!(false));
2906        assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
2907        assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
2908
2909        // Unknown previous table and a bad limit are loud request errors.
2910        let unknown = dispatch(
2911            &mut transport,
2912            &mut client,
2913            &mut effects,
2914            "previousVersionSnapshot",
2915            &json!({ "table": "absent" }),
2916        )
2917        .expect_err("unknown previous table");
2918        assert_eq!(unknown.code, "sync.invalid_request");
2919        let bad_limit = dispatch(
2920            &mut transport,
2921            &mut client,
2922            &mut effects,
2923            "previousVersionSnapshot",
2924            &json!({ "table": "todos", "limit": 0 }),
2925        )
2926        .expect_err("bad limit");
2927        assert_eq!(bad_limit.code, "sync.invalid_request");
2928
2929        // D6: the audit names the pending commit's classification, with no
2930        // envelope and nothing dropped.
2931        let audit = dispatch(
2932            &mut transport,
2933            &mut client,
2934            &mut effects,
2935            "previousVersionAudit",
2936            &json!({}),
2937        )
2938        .expect("audit");
2939        assert_eq!(audit["v"], json!(1));
2940        assert_eq!(audit["fromVersion"], json!(1));
2941        assert_eq!(audit["toVersion"], json!(2));
2942        assert_eq!(audit["pending"], json!(1));
2943        assert_eq!(audit["encodable"], json!(1));
2944        assert_eq!(audit["incompatible"], json!([]));
2945        assert!(audit.get("operations").is_none());
2946
2947        // The executable downgrade step, idempotent.
2948        let discarded = dispatch(
2949            &mut transport,
2950            &mut client,
2951            &mut effects,
2952            "previousVersionDiscard",
2953            &json!({}),
2954        )
2955        .expect("discard");
2956        assert_eq!(discarded, json!({ "present": true, "discarded": true }));
2957        assert!(!std::path::Path::new(&container_path(&path)).exists());
2958        let again = dispatch(
2959            &mut transport,
2960            &mut client,
2961            &mut effects,
2962            "previousVersionDiscard",
2963            &json!({}),
2964        )
2965        .expect("idempotent discard");
2966        assert_eq!(again, json!({ "present": false, "discarded": false }));
2967
2968        // Storage read directly: both metadata records are gone.
2969        let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
2970        let remaining: i64 = inspect
2971            .query_row(
2972                "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
2973                rusqlite::params!["previousVersionContext", "previousVersionAudit"],
2974                |row| row.get(0),
2975            )
2976            .expect("count meta");
2977        assert_eq!(remaining, 0);
2978
2979        let _ = std::fs::remove_file(&path);
2980    }
2981
2982    #[test]
2983    fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
2984        let path = temp_db_path("previous-version-ttl");
2985        let mut transport = NoNetwork::default();
2986        let mut client: Option<SyncClient> = None;
2987        let mut effects = CreateEffects::default();
2988        dispatch(
2989            &mut transport,
2990            &mut client,
2991            &mut effects,
2992            "create",
2993            &json!({
2994                "schema": schema(),
2995                "dbPath": path,
2996                "previousVersionContext": previous_version_enabled()
2997            }),
2998        )
2999        .expect("create v1");
3000        dispatch(
3001            &mut transport,
3002            &mut client,
3003            &mut effects,
3004            "shutdown",
3005            &json!({}),
3006        )
3007        .expect("shutdown");
3008
3009        // The injected clock is two seconds past the capture, with a TTL far
3010        // larger than the boot gap but far smaller than the skew, so the boot
3011        // reconcile keeps the container and the read expires it.
3012        let now_ms = std::time::SystemTime::now()
3013            .duration_since(std::time::UNIX_EPOCH)
3014            .expect("clock")
3015            .as_millis() as i64
3016            + 2_000;
3017        dispatch(
3018            &mut transport,
3019            &mut client,
3020            &mut effects,
3021            "create",
3022            &json!({
3023                "schema": schema_v2(),
3024                "dbPath": path,
3025                "nowMs": now_ms,
3026                "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
3027            }),
3028        )
3029        .expect("create v2");
3030        assert!(std::path::Path::new(&container_path(&path)).exists());
3031        let snapshot = dispatch(
3032            &mut transport,
3033            &mut client,
3034            &mut effects,
3035            "previousVersionSnapshot",
3036            &json!({ "table": "todos" }),
3037        )
3038        .expect("snapshot");
3039        assert_eq!(snapshot["available"], json!(false));
3040        assert_eq!(snapshot["reason"], json!("expired"));
3041        assert_eq!(snapshot["rows"], json!([]));
3042        // The TTL discards, and the FILE is what must be gone.
3043        assert!(!std::path::Path::new(&container_path(&path)).exists());
3044
3045        let _ = std::fs::remove_file(&path);
3046    }
3047}