Skip to main content

syncular_command/
lib.rs

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