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