Skip to main content

syncular_command/
lib.rs

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