Skip to main content

syncular_command/
lib.rs

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