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