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