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::{
23    ClientDiagnosticsRequest, ClientLimits, CommandEffects, CommitOutcomeQuery,
24    LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, ResolveCommitOutcomeInput,
25    SyncClient, Transport, WindowBase, WindowCoverage,
26};
27
28// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
29
30pub fn bytes_to_hex(bytes: &[u8]) -> String {
31    syncular_client::values::bytes_to_hex(bytes)
32}
33
34pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
35    syncular_client::values::hex_to_bytes(hex)
36}
37
38pub fn bytes_value(bytes: &[u8]) -> Value {
39    json!({ "$bytes": bytes_to_hex(bytes) })
40}
41
42pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
43    let hex = value
44        .and_then(|v| v.get("$bytes"))
45        .and_then(Value::as_str)
46        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
47    hex_to_bytes(hex)
48}
49
50/// The `(code, message)` pair the driver protocol carries in an `error`.
51pub type CommandError = (String, String);
52
53/// Parsed side effects of a `create` command that the host must apply to its
54/// own transport/clock (the router stays transport-agnostic). The client is
55/// already installed into the `Option<SyncClient>` slot by `dispatch`.
56#[derive(Debug, Default, Clone)]
57pub struct CreateEffects {
58    /// §5.4 capability the harness/host announced for its endpoints — the
59    /// host sets its transport's `supports_url_fetch` accordingly.
60    pub signed_urls: bool,
61    /// True while a security preflight is pending: a preflighted client was
62    /// installed (or entered preflight, or was shut down mid-preflight) and
63    /// `activateSecurity` has yet to complete. `dispatch` maintains this so a
64    /// replacement `create` without the `securityPreflight` flag is refused
65    /// across `shutdown`, where the client slot is empty, on a host that reuses
66    /// one `CreateEffects` across creates (the Tauri plugin, the conformance
67    /// shim, the bench harness).
68    ///
69    /// A host that allocates a fresh `CreateEffects` per create — the React
70    /// Native native module rebuilds its FFI handle on every `create` — starts
71    /// each create with this flag clear. For those hosts the persisted marker
72    /// in the client core carries the gate: a file-backed replica reopens in
73    /// preflight, and the `create` path refuses a plain re-create against it.
74    /// This in-memory flag covers the same-handle case where no file persists
75    /// the marker.
76    pub security_preflight_pending: bool,
77}
78
79fn client_err(message: String) -> CommandError {
80    let code = message
81        .split_once(':')
82        .map(|(candidate, _)| candidate)
83        .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
84        .unwrap_or("client.failed");
85    (code.to_owned(), message)
86}
87
88fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
89    client
90        .as_mut()
91        .ok_or_else(|| client_err("no client instance created".to_owned()))
92}
93
94pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
95    let mut limits = ClientLimits::default();
96    let Some(object) = value.and_then(Value::as_object) else {
97        return limits;
98    };
99    limits.limit_commits = object
100        .get("limitCommits")
101        .and_then(Value::as_i64)
102        .map(|v| v as i32);
103    limits.limit_snapshot_rows = object
104        .get("limitSnapshotRows")
105        .and_then(Value::as_i64)
106        .map(|v| v as i32);
107    limits.max_snapshot_pages = object
108        .get("maxSnapshotPages")
109        .and_then(Value::as_i64)
110        .map(|v| v as i32);
111    limits.accept = object
112        .get("accept")
113        .and_then(Value::as_u64)
114        .map(|v| v as u8);
115    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
116    limits.outcome_retention_max_entries = object
117        .get("outcomeRetentionMaxEntries")
118        .and_then(Value::as_u64)
119        .map(|value| value as usize);
120    limits
121}
122
123/// §5.11: parse the `encryption` config into the client's portable keyring.
124/// Shape: `{ keys: { "<keyId>": {"$bytes": "<hex>"} },
125/// keyIdColumns: { "<table>": "<column>" } }`. Keys are 32 bytes.
126pub fn parse_encryption(
127    value: &Value,
128) -> Result<syncular_client::values::EncryptionConfig, String> {
129    let mut config = syncular_client::values::EncryptionConfig::default();
130    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
131        return Ok(config);
132    };
133    for (key_id, key_val) in keys {
134        let bytes =
135            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
136        if bytes.len() != 32 {
137            return Err(format!(
138                "encryption key {key_id:?} must be 32 bytes, got {}",
139                bytes.len()
140            ));
141        }
142        config.keys.insert(key_id.clone(), bytes);
143    }
144    if let Some(columns) = value.get("keyIdColumns") {
145        let columns = columns
146            .as_object()
147            .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
148        for (table, column) in columns {
149            let column = column.as_str().ok_or_else(|| {
150                format!("encryption keyIdColumns entry for {table:?} must be a string")
151            })?;
152            if column.is_empty() {
153                return Err(format!(
154                    "encryption keyIdColumns entry for {table:?} must not be empty"
155                ));
156            }
157            config
158                .key_id_columns
159                .insert(table.clone(), column.to_owned());
160        }
161    }
162    Ok(config)
163}
164
165/// Parse an `activateSecurity` (or rotation) `headers` param: an object of
166/// string values, replacing the transport's FULL header set.
167/// The router validates the shape at the shared chokepoint; each host applies
168/// the parsed set to its own transport (the router stays transport-agnostic).
169pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
170    let object = value.as_object().ok_or_else(|| {
171        "sync.invalid_request: headers must be an object of string values".to_owned()
172    })?;
173    let mut headers = Vec::with_capacity(object.len());
174    for (name, value) in object {
175        let value = value
176            .as_str()
177            .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
178        headers.push((name.clone(), value.to_owned()));
179    }
180    Ok(headers)
181}
182
183pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
184    let list = value
185        .and_then(Value::as_array)
186        .ok_or_else(|| "mutations must be a list".to_owned())?;
187    let mut out = Vec::with_capacity(list.len());
188    for entry in list {
189        let op = entry
190            .get("op")
191            .and_then(Value::as_str)
192            .ok_or_else(|| "mutation missing op".to_owned())?;
193        let table = entry
194            .get("table")
195            .and_then(Value::as_str)
196            .ok_or_else(|| "mutation missing table".to_owned())?
197            .to_owned();
198        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
199        match op {
200            "upsert" => {
201                let mut values = entry
202                    .get("values")
203                    .and_then(Value::as_object)
204                    .cloned()
205                    .ok_or_else(|| "upsert missing values".to_owned())?;
206                decode_bigint_members(&mut values)?;
207                out.push(Mutation::Upsert {
208                    table,
209                    values,
210                    base_version,
211                });
212            }
213            "delete" => {
214                let row_id = entry
215                    .get("rowId")
216                    .and_then(Value::as_str)
217                    .ok_or_else(|| "delete missing rowId".to_owned())?
218                    .to_owned();
219                out.push(Mutation::Delete {
220                    table,
221                    row_id,
222                    base_version,
223                });
224            }
225            other => return Err(format!("unknown mutation op {other:?}")),
226        }
227    }
228    Ok(out)
229}
230
231fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
232    for value in values.values_mut() {
233        let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
234            continue;
235        };
236        let integer = decimal
237            .parse::<i64>()
238            .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
239        *value = Value::from(integer);
240    }
241    Ok(())
242}
243
244fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
245    match value {
246        Some(v) => syncular_client::values::json_to_scope_map(v),
247        None => Ok(Vec::new()),
248    }
249}
250
251/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
252/// params? }` from a command's `base` param.
253fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
254    let object = value
255        .and_then(Value::as_object)
256        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
257    let table = object
258        .get("table")
259        .and_then(Value::as_str)
260        .ok_or_else(|| "window base missing table".to_owned())?
261        .to_owned();
262    let variable = object
263        .get("variable")
264        .and_then(Value::as_str)
265        .ok_or_else(|| "window base missing variable".to_owned())?
266        .to_owned();
267    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
268    let params = object
269        .get("params")
270        .and_then(Value::as_str)
271        .map(str::to_owned);
272    Ok(WindowBase {
273        table,
274        variable,
275        fixed_scopes,
276        params,
277    })
278}
279
280/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
281/// command. `name` selects the shared type inside the doc (default `"text"`,
282/// matching the TS `YjsColumn.text()` default).
283#[cfg(feature = "crdt-yjs")]
284fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
285    let table = params
286        .get("table")
287        .and_then(Value::as_str)
288        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
289        .to_owned();
290    let row_id = params
291        .get("rowId")
292        .and_then(Value::as_str)
293        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
294        .to_owned();
295    let column = params
296        .get("column")
297        .and_then(Value::as_str)
298        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
299        .to_owned();
300    let name = params
301        .get("name")
302        .and_then(Value::as_str)
303        .unwrap_or("text")
304        .to_owned();
305    Ok((table, row_id, column, name))
306}
307
308/// Dispatch one command against the client instance over `transport`.
309///
310/// The `create` command installs a fresh `SyncClient` into `client` and
311/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
312/// other command mutates the existing instance. Errors come back as the
313/// driver-protocol `(code, message)` pair.
314///
315/// Generic over `T: Transport` so the shim (host-inverted transport) and the
316/// FFI core (native HTTP+WS transport) share this exact router.
317pub fn dispatch<T: Transport>(
318    transport: &mut T,
319    client: &mut Option<SyncClient>,
320    effects: &mut CreateEffects,
321    method: &str,
322    params: &Value,
323) -> Result<Value, CommandError> {
324    if method == "create" {
325        // Fail-closed against a compromised webview re-issuing `create` (or
326        // `shutdown` + `create`) WITHOUT the securityPreflight flag to exit
327        // the gate: while a preflight is pending — on the live client, or
328        // carried in `effects` across a `shutdown` — a replacement create must
329        // itself request securityPreflight; the gate opens only through
330        // activateSecurity.
331        let requests_preflight = params
332            .get("securityPreflight")
333            .and_then(Value::as_bool)
334            .unwrap_or(false);
335        let preflight_engaged = client.as_ref().map_or(
336            effects.security_preflight_pending,
337            SyncClient::security_preflight,
338        );
339        if preflight_engaged && !requests_preflight {
340            return Err((
341                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
342                "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
343            ));
344        }
345    } else {
346        let allowed_during_preflight = matches!(
347            method,
348            "securityLifecycle"
349                | "beginSecurityPreflight"
350                | "activateSecurity"
351                | "purgeLocalData"
352                | "localRevision"
353                | "statusSnapshot"
354                | "shutdown"
355        );
356        if client
357            .as_ref()
358            .is_some_and(|running| running.security_preflight())
359            && !allowed_during_preflight
360        {
361            return Err((
362                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
363                "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
364            ));
365        }
366    }
367    match method {
368        "create" => {
369            let client_id = params
370                .get("clientId")
371                .and_then(Value::as_str)
372                .map(str::to_owned);
373            let schema = params
374                .get("schema")
375                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
376            let limits = parse_limits(params.get("limits"));
377            // §native: a `dbPath` installs a file-backed rusqlite connection so
378            // native hosts (Tauri plugin, FFI file variant) persist across
379            // restarts; absent it, the default in-memory core (the shim's mode).
380            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
381                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
382                    .map_err(client_err)?,
383                None => {
384                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
385                }
386            };
387            // Harness clock pin (§5.4 expiry runs on the virtual clock).
388            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
389                instance.set_now_ms(now_ms);
390            }
391            // §5.4 capability of the host endpoint set (accept bit 3) — the
392            // host applies it to its own transport.
393            effects.signed_urls = params
394                .get("signedUrls")
395                .and_then(Value::as_bool)
396                .unwrap_or(false);
397            // §5.11: install client-side encryption keys. Shape:
398            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} },
399            //                 keyIdColumns: { "<table>": "<column>" } } }.
400            let security_preflight = params
401                .get("securityPreflight")
402                .and_then(Value::as_bool)
403                .unwrap_or(false);
404            // A file-backed replica reopens in preflight when its persisted
405            // quarantine marker is set (client core restores it). Refuse a plain
406            // re-create so a rebuilt host handle — the React Native native module
407            // tears its FFI handle down on every create — cannot downgrade a
408            // quarantined replica to active. A create that itself requests
409            // securityPreflight, or an activated replica whose marker cleared,
410            // proceeds.
411            if instance.security_preflight() && !security_preflight {
412                return Err((
413                    syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
414                    "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
415                ));
416            }
417            if security_preflight && params.get("encryption").is_some() {
418                return Err(client_err(
419                    "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
420                        .to_owned(),
421                ));
422            }
423            if let Some(enc) = params.get("encryption") {
424                let config = parse_encryption(enc).map_err(client_err)?;
425                instance.set_encryption(config);
426            }
427            if security_preflight {
428                instance.begin_security_preflight();
429            }
430            effects.security_preflight_pending = security_preflight;
431            *client = Some(instance);
432            Ok(json!({}))
433        }
434        "securityLifecycle" => Ok(json!({
435            "state": need_client(client)?.security_lifecycle()
436        })),
437        "beginSecurityPreflight" => {
438            let running = need_client(client)?;
439            running.disconnect_realtime(transport);
440            running.begin_security_preflight();
441            effects.security_preflight_pending = true;
442            Ok(json!({}))
443        }
444        "activateSecurity" => {
445            let encryption = match params.get("encryption") {
446                Some(value) => parse_encryption(value).map_err(client_err)?,
447                None => syncular_client::values::EncryptionConfig::default(),
448            };
449            // Optional fresh transport headers ride the activation atomically,
450            // so a preflight that outlives the boot token starts its first
451            // sync round with valid credentials. Validated here at the shared
452            // chokepoint (invalid input keeps the gate closed); the host
453            // applies the parsed set to its own transport.
454            if let Some(headers) = params.get("headers") {
455                parse_headers(headers).map_err(client_err)?;
456            }
457            need_client(client)?
458                .activate_security(encryption)
459                .map_err(client_err)?;
460            effects.security_preflight_pending = false;
461            Ok(json!({}))
462        }
463        "shutdown" => {
464            if let Some(running) = client.as_mut() {
465                // Capture the gate state BEFORE the shutdown barrier flips the
466                // client into preflight: an unactivated preflight stays
467                // pending across the shutdown, while an activated client may
468                // be recreated plainly afterwards.
469                effects.security_preflight_pending = running.security_preflight();
470                running.disconnect_realtime(transport);
471                // Teardown barrier only: an activated client that shuts down
472                // cleanly must not leave a durable quarantine mark, or its next
473                // plain create would be refused forever.
474                running.seal_security_on_teardown();
475            }
476            *client = None;
477            Ok(json!({}))
478        }
479        "subscribe" => {
480            let id = params
481                .get("id")
482                .and_then(Value::as_str)
483                .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
484                .to_owned();
485            let table = params
486                .get("table")
487                .and_then(Value::as_str)
488                .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
489                .to_owned();
490            let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
491            let sub_params = params
492                .get("params")
493                .and_then(Value::as_str)
494                .map(str::to_owned);
495            need_client(client)?
496                .subscribe(id, table, scopes, sub_params)
497                .map_err(client_err)?;
498            Ok(json!({}))
499        }
500        "unsubscribe" => {
501            let id = params
502                .get("id")
503                .and_then(Value::as_str)
504                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
505            need_client(client)?.unsubscribe(id);
506            Ok(json!({}))
507        }
508        "setWindow" => {
509            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
510            let units: Vec<String> = params
511                .get("units")
512                .and_then(Value::as_array)
513                .map(|arr| {
514                    arr.iter()
515                        .filter_map(|v| v.as_str().map(str::to_owned))
516                        .collect()
517                })
518                .unwrap_or_default();
519            let command_effects = need_client(client)?
520                .set_window(&base, &units)
521                .map_err(client_err)?;
522            Ok(json!({ "effects": command_effects }))
523        }
524        "windowState" => {
525            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
526            let state = need_client(client)?.window_state(&base);
527            Ok(json!({ "units": state.units, "pending": state.pending }))
528        }
529        "mutate" => {
530            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
531            let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
532            Ok(json!({
533                "clientCommitId": id,
534                "effects": CommandEffects::interactive()
535            }))
536        }
537        "patch" => {
538            let table = params
539                .get("table")
540                .and_then(Value::as_str)
541                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
542            let row_id = params
543                .get("rowId")
544                .and_then(Value::as_str)
545                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
546            let mut partial = params
547                .get("partial")
548                .and_then(Value::as_object)
549                .cloned()
550                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
551            decode_bigint_members(&mut partial).map_err(client_err)?;
552            let base_version = params.get("baseVersion").and_then(Value::as_i64);
553            let id = need_client(client)?
554                .patch(table, row_id, partial, base_version)
555                .map_err(client_err)?;
556            Ok(json!({
557                "clientCommitId": id,
558                "effects": CommandEffects::interactive()
559            }))
560        }
561        "purgeLocalData" => {
562            let input: LocalDataPurgeInput =
563                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
564                    client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
565                })?)
566                .map_err(|error| {
567                    client_err(format!(
568                        "sync.invalid_request: invalid purgeLocalData input: {error}"
569                    ))
570                })?;
571            let result = need_client(client)?
572                .purge_local_data(&input)
573                .map_err(client_err)?;
574            serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
575        }
576        "rebootstrapLocalData" => {
577            let input: LocalDataRebootstrapInput =
578                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
579                    client_err(
580                        "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
581                    )
582                })?)
583                .map_err(|error| {
584                    client_err(format!(
585                        "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
586                    ))
587                })?;
588            let result = need_client(client)?
589                .rebootstrap_local_data(&input)
590                .map_err(client_err)?;
591            Ok(json!({
592                "alreadyApplied": result.already_applied,
593                "retainedCommits": result.retained_commits,
594                "resetSubscriptions": result.reset_subscriptions,
595                "effects": if result.already_applied {
596                    CommandEffects::none()
597                } else {
598                    CommandEffects::interactive()
599                }
600            }))
601        }
602        "sync" => {
603            let outcome = need_client(client)?.sync(transport);
604            Ok(outcome.to_json())
605        }
606        "syncUntilIdle" => {
607            let max_rounds = params
608                .get("maxRounds")
609                .and_then(Value::as_u64)
610                .map(|v| v as u32);
611            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
612            Ok(outcome.to_json())
613        }
614        "readRows" => {
615            let table = params
616                .get("table")
617                .and_then(Value::as_str)
618                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
619            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
620            Ok(json!({ "rows": rows }))
621        }
622        "query" => {
623            // The React `useSyncQuery` live-query fast path: arbitrary read-only
624            // SQL over the local visible tables/views. Params ride as the driver
625            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
626            let sql = params
627                .get("sql")
628                .and_then(Value::as_str)
629                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
630            let bind = match params.get("params") {
631                Some(Value::Array(list)) => list.clone(),
632                None | Some(Value::Null) => Vec::new(),
633                Some(_) => return Err(client_err("query params must be a list".to_owned())),
634            };
635            let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
636            Ok(json!({ "rows": rows }))
637        }
638        "querySnapshot" => {
639            let sql = params
640                .get("sql")
641                .and_then(Value::as_str)
642                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
643            let bind = match params.get("params") {
644                Some(Value::Array(list)) => list.clone(),
645                None | Some(Value::Null) => Vec::new(),
646                Some(_) => {
647                    return Err(client_err("querySnapshot params must be a list".to_owned()))
648                }
649            };
650            let mut coverage = Vec::new();
651            for entry in params
652                .get("coverage")
653                .and_then(Value::as_array)
654                .into_iter()
655                .flatten()
656            {
657                let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
658                let units = entry
659                    .get("units")
660                    .and_then(Value::as_array)
661                    .map(|values| {
662                        values
663                            .iter()
664                            .filter_map(|value| value.as_str().map(str::to_owned))
665                            .collect()
666                    })
667                    .unwrap_or_default();
668                coverage.push(WindowCoverage { base, units });
669            }
670            let snapshot = need_client(client)?
671                .query_snapshot(sql, &bind, &coverage)
672                .map_err(client_err)?;
673            Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
674        }
675        "localRevision" => Ok(json!({
676            "revision": need_client(client)?.local_revision().to_string()
677        })),
678        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
679            .expect("status serializes")),
680        "diagnosticsSnapshot" => {
681            let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
682                .map_err(|error| {
683                    client_err(format!(
684                        "sync.invalid_request: invalid diagnostics request: {error}"
685                    ))
686                })?;
687            let snapshot = need_client(client)?
688                .diagnostics_snapshot(&request)
689                .map_err(client_err)?;
690            Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
691        }
692        // Conformance/debug drains. Production hosts normally drain these
693        // immediately after every command, but exposing the exact core output
694        // here lets both client implementations consume one observation
695        // vector catalog without bridge inference.
696        "drainChangeBatches" => Ok(json!({
697            "batches": need_client(client)?.drain_change_batches()
698        })),
699        "drainSyncIntents" => Ok(json!({
700            "intents": need_client(client)?.drain_sync_intents()
701        })),
702        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
703        // Thin forwards to the client core's yrs helpers. The command surface
704        // stays present-but-unavailable in a lean build: without the feature
705        // these fail loudly (`client.crdt_unavailable`) rather than being an
706        // unknown method, so a wrapper's typed method gives a clear error.
707        #[cfg(feature = "crdt-yjs")]
708        "crdtText" => {
709            let (table, row_id, column, name) = crdt_target(params)?;
710            let text = need_client(client)?
711                .crdt_text(&table, &row_id, &column, &name)
712                .map_err(client_err)?;
713            Ok(json!({ "text": text }))
714        }
715        #[cfg(feature = "crdt-yjs")]
716        "crdtInsertText" => {
717            let (table, row_id, column, name) = crdt_target(params)?;
718            let index = params
719                .get("index")
720                .and_then(Value::as_u64)
721                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
722                as u32;
723            let value = params
724                .get("value")
725                .and_then(Value::as_str)
726                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
727            let id = need_client(client)?
728                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
729                .map_err(client_err)?;
730            Ok(json!({ "clientCommitId": id }))
731        }
732        #[cfg(feature = "crdt-yjs")]
733        "crdtDeleteText" => {
734            let (table, row_id, column, name) = crdt_target(params)?;
735            let index = params
736                .get("index")
737                .and_then(Value::as_u64)
738                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
739                as u32;
740            let len = params
741                .get("len")
742                .and_then(Value::as_u64)
743                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
744                as u32;
745            let id = need_client(client)?
746                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
747                .map_err(client_err)?;
748            Ok(json!({ "clientCommitId": id }))
749        }
750        #[cfg(feature = "crdt-yjs")]
751        "crdtApplyUpdate" => {
752            let (table, row_id, column, _name) = crdt_target(params)?;
753            let update = value_bytes(params.get("update")).map_err(client_err)?;
754            let id = need_client(client)?
755                .crdt_apply_update(&table, &row_id, &column, &update)
756                .map_err(client_err)?;
757            Ok(json!({ "clientCommitId": id }))
758        }
759        #[cfg(not(feature = "crdt-yjs"))]
760        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
761            "client.crdt_unavailable".to_owned(),
762            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
763        )),
764
765        "uploadBlob" => {
766            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
767            let media_type = params
768                .get("mediaType")
769                .and_then(Value::as_str)
770                .map(str::to_owned);
771            let name = params
772                .get("name")
773                .and_then(Value::as_str)
774                .map(str::to_owned);
775            let reference = need_client(client)?
776                .upload_blob(&bytes, media_type, name)
777                .map_err(client_err)?;
778            Ok(json!({ "ref": reference }))
779        }
780        "fetchBlob" => {
781            let blob = params
782                .get("blob")
783                .and_then(Value::as_str)
784                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
785                .to_owned();
786            // fetch_blob returns (code, message) so the server's blob.* code
787            // reaches the caller (§5.9.5 cross-scope probe).
788            let value = need_client(client)?.fetch_blob(transport, &blob)?;
789            Ok(json!({ "blob": value }))
790        }
791        "conflicts" => {
792            let conflicts = need_client(client)?.conflicts().to_vec();
793            Ok(json!({ "conflicts": conflicts }))
794        }
795        "rejections" => {
796            let rejections = need_client(client)?.rejections().to_vec();
797            Ok(json!({ "rejections": rejections }))
798        }
799        "commitOutcome" => {
800            let client_commit_id = params
801                .get("clientCommitId")
802                .and_then(Value::as_str)
803                .ok_or_else(|| {
804                    client_err(
805                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
806                    )
807                })?;
808            let outcome = need_client(client)?
809                .commit_outcome(client_commit_id)
810                .map_err(client_err)?;
811            Ok(json!({ "outcome": outcome }))
812        }
813        "commitOutcomes" => {
814            let query = serde_json::from_value::<CommitOutcomeQuery>(
815                params.get("query").cloned().unwrap_or_else(|| json!({})),
816            )
817            .map_err(|error| {
818                client_err(format!(
819                    "sync.invalid_request: invalid commit outcome query: {error}"
820                ))
821            })?;
822            let outcomes = need_client(client)?
823                .commit_outcomes(query)
824                .map_err(client_err)?;
825            Ok(json!({ "outcomes": outcomes }))
826        }
827        "resolveCommitOutcome" => {
828            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
829                params.get("input").cloned().ok_or_else(|| {
830                    client_err(
831                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
832                    )
833                })?,
834            )
835            .map_err(|error| {
836                client_err(format!(
837                    "sync.invalid_request: invalid outcome resolution: {error}"
838                ))
839            })?;
840            let outcome = need_client(client)?
841                .resolve_commit_outcome(input)
842                .map_err(client_err)?;
843            Ok(json!({ "outcome": outcome }))
844        }
845        "pendingCommitIds" => {
846            let ids = need_client(client)?.pending_commit_ids();
847            Ok(json!({ "ids": ids }))
848        }
849        "subscriptionState" => {
850            let id = params
851                .get("id")
852                .and_then(Value::as_str)
853                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
854            let state = need_client(client)?.subscription_state(id);
855            Ok(json!({ "state": state }))
856        }
857        "schemaFloor" => {
858            let floor = need_client(client)?.schema_floor().cloned();
859            Ok(json!({ "floor": floor }))
860        }
861        "leaseState" => {
862            let lease = need_client(client)?.lease_state().cloned();
863            Ok(json!({ "lease": lease }))
864        }
865        "upgrading" => {
866            // §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
867            let value = need_client(client)?.upgrading();
868            Ok(json!({ "value": value }))
869        }
870        "recreateWithSchema" => {
871            // §7.4.2 "app ships new code": swap to the new schema on the SAME
872            // in-memory database (the Rust core has no persistent restart, so
873            // recreation IS the boot). Fires the §7.4.1 marker check.
874            let schema = params
875                .get("schema")
876                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
877            need_client(client)?
878                .recreate_with_schema(schema)
879                .map_err(client_err)?;
880            Ok(json!({}))
881        }
882        "connectRealtime" => {
883            need_client(client)?
884                .connect_realtime(transport)
885                .map_err(client_err)?;
886            Ok(json!({}))
887        }
888        "disconnectRealtime" => {
889            need_client(client)?.disconnect_realtime(transport);
890            Ok(json!({}))
891        }
892        "syncNeeded" => {
893            let value = need_client(client)?.sync_needed();
894            Ok(json!({ "value": value }))
895        }
896        "setPresence" => {
897            let scope_key = params
898                .get("scopeKey")
899                .and_then(Value::as_str)
900                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
901                .to_owned();
902            // §8.6.2: `doc` may be a JSON object or null (a leave).
903            let doc = params.get("doc");
904            let doc_ref = match doc {
905                None | Some(Value::Null) => None,
906                Some(v) => Some(v),
907            };
908            need_client(client)?
909                .set_presence(transport, &scope_key, doc_ref)
910                .map_err(client_err)?;
911            Ok(json!({}))
912        }
913        "presence" => {
914            let scope_key = params
915                .get("scopeKey")
916                .and_then(Value::as_str)
917                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
918            let peers = need_client(client)?.presence(scope_key);
919            Ok(json!({ "peers": peers }))
920        }
921
922        // -- CodecDriver surface (Appendix A) — no client instance needed --
923        "messageRoundtrip" => {
924            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
925            match decode_message(&bytes) {
926                Ok(message) => Ok(json!({
927                    "ok": true,
928                    "bytes": bytes_value(&encode_message(&message)),
929                    "renderedJson": render_message(&message).to_string(),
930                })),
931                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
932            }
933        }
934        "segmentRoundtrip" => {
935            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
936            match decode_rows_segment(&bytes) {
937                Ok(segment) => Ok(json!({
938                    "ok": true,
939                    "bytes": bytes_value(&encode_rows_segment(&segment)),
940                    "renderedJson": render_rows_segment(&segment).to_string(),
941                })),
942                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
943            }
944        }
945        "realtimeKnown" => {
946            let text = params
947                .get("text")
948                .and_then(Value::as_str)
949                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
950            let known = matches!(
951                parse_control(text),
952                Ok(ControlMessage::Hello { .. })
953                    | Ok(ControlMessage::Wake { .. })
954                    | Ok(ControlMessage::Heartbeat { .. })
955                    | Ok(ControlMessage::Presence { .. })
956            );
957            Ok(json!({ "value": known }))
958        }
959
960        other => Err(client_err(format!("unknown method {other:?}"))),
961    }
962}
963
964#[cfg(test)]
965mod tests {
966    use serde_json::{json, Value};
967    use syncular_client::{
968        SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
969    };
970
971    use super::{dispatch, parse_encryption, parse_headers, CreateEffects};
972
973    #[derive(Default)]
974    struct NoNetwork {
975        realtime_connects: usize,
976        realtime_closes: usize,
977    }
978
979    impl Transport for NoNetwork {
980        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
981            Err(TransportError::new("sync.transport_failed", "offline"))
982        }
983
984        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
985            Err(TransportError::new("sync.transport_failed", "offline"))
986        }
987
988        fn download_segment(
989            &mut self,
990            _request: &SegmentRequest,
991        ) -> Result<Vec<u8>, TransportError> {
992            Err(TransportError::new("sync.transport_failed", "offline"))
993        }
994
995        fn realtime_connect(&mut self) -> Result<(), TransportError> {
996            self.realtime_connects += 1;
997            Ok(())
998        }
999
1000        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1001            Ok(())
1002        }
1003
1004        fn realtime_close(&mut self) -> Result<(), TransportError> {
1005            self.realtime_closes += 1;
1006            Ok(())
1007        }
1008    }
1009
1010    fn schema() -> Value {
1011        json!({
1012            "version": 1,
1013            "tables": [{
1014                "name": "todos",
1015                "columns": [
1016                    { "name": "id", "type": "string", "nullable": false },
1017                    { "name": "list_id", "type": "string", "nullable": false }
1018                ],
1019                "primaryKey": "id",
1020                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1021            }]
1022        })
1023    }
1024
1025    #[test]
1026    fn native_command_realtime_connection_is_idempotent() {
1027        let mut transport = NoNetwork::default();
1028        let mut client: Option<SyncClient> = None;
1029        let mut effects = CreateEffects::default();
1030        dispatch(
1031            &mut transport,
1032            &mut client,
1033            &mut effects,
1034            "create",
1035            &json!({ "schema": schema() }),
1036        )
1037        .expect("create client");
1038
1039        for _ in 0..2 {
1040            dispatch(
1041                &mut transport,
1042                &mut client,
1043                &mut effects,
1044                "connectRealtime",
1045                &json!({}),
1046            )
1047            .expect("idempotent connect command");
1048        }
1049        assert_eq!(transport.realtime_connects, 1);
1050
1051        dispatch(
1052            &mut transport,
1053            &mut client,
1054            &mut effects,
1055            "disconnectRealtime",
1056            &json!({}),
1057        )
1058        .expect("disconnect command");
1059        dispatch(
1060            &mut transport,
1061            &mut client,
1062            &mut effects,
1063            "disconnectRealtime",
1064            &json!({}),
1065        )
1066        .expect("idempotent disconnect command");
1067        assert_eq!(transport.realtime_closes, 1);
1068        dispatch(
1069            &mut transport,
1070            &mut client,
1071            &mut effects,
1072            "connectRealtime",
1073            &json!({}),
1074        )
1075        .expect("deliberate reconnect command");
1076        assert_eq!(transport.realtime_connects, 2);
1077    }
1078
1079    #[test]
1080    fn parses_portable_encryption_keyring_and_key_id_columns() {
1081        let key_hex = "2a".repeat(32);
1082        let config = parse_encryption(&json!({
1083            "keys": { "practice-key-v1": { "$bytes": key_hex } },
1084            "keyIdColumns": { "patients": "encryption_key_id" }
1085        }))
1086        .expect("portable keyring parses");
1087        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1088        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1089    }
1090
1091    #[test]
1092    fn rejects_non_string_key_id_columns() {
1093        let error = parse_encryption(&json!({
1094            "keys": {},
1095            "keyIdColumns": { "patients": 7 }
1096        }))
1097        .expect_err("invalid selector must fail");
1098        assert!(error.contains("must be a string"), "{error}");
1099    }
1100
1101    #[test]
1102    fn native_command_hosts_reject_subscription_identity_rebinds() {
1103        let mut transport = NoNetwork::default();
1104        let mut client: Option<SyncClient> = None;
1105        let mut effects = CreateEffects::default();
1106        dispatch(
1107            &mut transport,
1108            &mut client,
1109            &mut effects,
1110            "create",
1111            &json!({ "schema": schema() }),
1112        )
1113        .expect("create client");
1114
1115        let original = json!({
1116            "id": "stable-subscription",
1117            "table": "todos",
1118            "scopes": { "list_id": ["list-2", "list-1"] },
1119            "params": "{\"view\":\"v1\"}"
1120        });
1121        dispatch(
1122            &mut transport,
1123            &mut client,
1124            &mut effects,
1125            "subscribe",
1126            &original,
1127        )
1128        .expect("register subscription");
1129        dispatch(
1130            &mut transport,
1131            &mut client,
1132            &mut effects,
1133            "subscribe",
1134            &json!({
1135                "id": "stable-subscription",
1136                "table": "todos",
1137                "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1138                "params": "{\"view\":\"v1\"}"
1139            }),
1140        )
1141        .expect("canonical re-declaration is idempotent");
1142
1143        let error = dispatch(
1144            &mut transport,
1145            &mut client,
1146            &mut effects,
1147            "subscribe",
1148            &json!({
1149                "id": "stable-subscription",
1150                "table": "todos",
1151                "scopes": { "list_id": ["list-2"] },
1152                "params": "{\"view\":\"v1\"}"
1153            }),
1154        )
1155        .expect_err("changed native query identity must fail");
1156        assert_eq!(error.0, "client.subscription_intent_mismatch");
1157
1158        let state = dispatch(
1159            &mut transport,
1160            &mut client,
1161            &mut effects,
1162            "subscriptionState",
1163            &json!({ "id": "stable-subscription" }),
1164        )
1165        .expect("subscription state");
1166        assert_eq!(state["state"]["cursor"], -1);
1167        assert_eq!(state["state"]["table"], "todos");
1168    }
1169
1170    #[test]
1171    fn security_preflight_is_fail_closed_until_exact_activation() {
1172        let mut transport = NoNetwork::default();
1173        let mut client: Option<SyncClient> = None;
1174        let mut effects = CreateEffects::default();
1175        dispatch(
1176            &mut transport,
1177            &mut client,
1178            &mut effects,
1179            "create",
1180            &json!({ "schema": schema(), "securityPreflight": true }),
1181        )
1182        .expect("preflight create");
1183
1184        let lifecycle = dispatch(
1185            &mut transport,
1186            &mut client,
1187            &mut effects,
1188            "securityLifecycle",
1189            &json!({}),
1190        )
1191        .expect("lifecycle");
1192        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1193
1194        let query_error = dispatch(
1195            &mut transport,
1196            &mut client,
1197            &mut effects,
1198            "query",
1199            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1200        )
1201        .expect_err("protected query must fail");
1202        assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1203        let diagnostics_error = dispatch(
1204            &mut transport,
1205            &mut client,
1206            &mut effects,
1207            "diagnosticsSnapshot",
1208            &json!({}),
1209        )
1210        .expect_err("diagnostics table/subscription evidence remains protected");
1211        assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1212
1213        dispatch(
1214            &mut transport,
1215            &mut client,
1216            &mut effects,
1217            "purgeLocalData",
1218            &json!({
1219                "input": {
1220                    "purgeId": "directive-1",
1221                    "targets": [{
1222                        "table": "todos",
1223                        "selectors": { "list_id": ["list-1"] }
1224                    }]
1225                }
1226            }),
1227        )
1228        .expect("authorized local purge remains available");
1229
1230        let repair_error = dispatch(
1231            &mut transport,
1232            &mut client,
1233            &mut effects,
1234            "rebootstrapLocalData",
1235            &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1236        )
1237        .expect_err("projection repair must remain protected during preflight");
1238        assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1239
1240        dispatch(
1241            &mut transport,
1242            &mut client,
1243            &mut effects,
1244            "activateSecurity",
1245            &json!({}),
1246        )
1247        .expect("activation");
1248        let rows = dispatch(
1249            &mut transport,
1250            &mut client,
1251            &mut effects,
1252            "query",
1253            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1254        )
1255        .expect("active query");
1256        assert_eq!(rows, json!({ "rows": [] }));
1257
1258        dispatch(
1259            &mut transport,
1260            &mut client,
1261            &mut effects,
1262            "beginSecurityPreflight",
1263            &json!({}),
1264        )
1265        .expect("re-enter preflight");
1266        let blocked = dispatch(
1267            &mut transport,
1268            &mut client,
1269            &mut effects,
1270            "mutate",
1271            &json!({ "mutations": [] }),
1272        )
1273        .expect_err("mutation must be gated");
1274        assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1275    }
1276
1277    #[test]
1278    fn preflight_refuses_a_replacement_create_without_the_flag() {
1279        let mut transport = NoNetwork::default();
1280        let mut client: Option<SyncClient> = None;
1281        let mut effects = CreateEffects::default();
1282        dispatch(
1283            &mut transport,
1284            &mut client,
1285            &mut effects,
1286            "create",
1287            &json!({ "schema": schema(), "securityPreflight": true }),
1288        )
1289        .expect("preflight create");
1290
1291        // The escape the gate exists to prevent: a plain re-create must fail.
1292        let escape = dispatch(
1293            &mut transport,
1294            &mut client,
1295            &mut effects,
1296            "create",
1297            &json!({ "schema": schema() }),
1298        )
1299        .expect_err("plain create must be refused during preflight");
1300        assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1301        // The refused create left the preflighted client installed and gated.
1302        let query_error = dispatch(
1303            &mut transport,
1304            &mut client,
1305            &mut effects,
1306            "query",
1307            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1308        )
1309        .expect_err("protected query stays gated");
1310        assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1311
1312        // A preflighted replacement is permitted and stays gated.
1313        dispatch(
1314            &mut transport,
1315            &mut client,
1316            &mut effects,
1317            "create",
1318            &json!({ "schema": schema(), "securityPreflight": true }),
1319        )
1320        .expect("preflighted replacement create");
1321        let still_gated = dispatch(
1322            &mut transport,
1323            &mut client,
1324            &mut effects,
1325            "query",
1326            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1327        )
1328        .expect_err("replacement stays gated");
1329        assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1330
1331        // A legitimate activation releases the gate; creates behave as today.
1332        dispatch(
1333            &mut transport,
1334            &mut client,
1335            &mut effects,
1336            "activateSecurity",
1337            &json!({}),
1338        )
1339        .expect("activation");
1340        dispatch(
1341            &mut transport,
1342            &mut client,
1343            &mut effects,
1344            "create",
1345            &json!({ "schema": schema() }),
1346        )
1347        .expect("plain create after activation");
1348        let rows = dispatch(
1349            &mut transport,
1350            &mut client,
1351            &mut effects,
1352            "query",
1353            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1354        )
1355        .expect("active query");
1356        assert_eq!(rows, json!({ "rows": [] }));
1357    }
1358
1359    /// A file-backed replica: the persisted quarantine marker only exists here,
1360    /// which is why the in-memory tests above could not catch this.
1361    fn temp_db_path(tag: &str) -> String {
1362        std::env::temp_dir()
1363            .join(format!(
1364                "syncular-command-{tag}-{}-{:?}.db",
1365                std::process::id(),
1366                std::time::SystemTime::now()
1367                    .duration_since(std::time::UNIX_EPOCH)
1368                    .expect("clock")
1369                    .as_nanos()
1370            ))
1371            .to_string_lossy()
1372            .into_owned()
1373    }
1374
1375    #[test]
1376    fn an_activated_replica_reopens_plainly_after_shutdown() {
1377        // Shutting an ACTIVATED client down is not a quarantine event. The
1378        // teardown barrier used to call `begin_security_preflight`, which now
1379        // persists the gate, so every cleanly closed replica was marked pending
1380        // and its next plain `create` was refused forever — the reopen path
1381        // restores the flag from the marker and the create guard rejects it.
1382        let path = temp_db_path("activated-reopen");
1383        let mut transport = NoNetwork::default();
1384        let mut client: Option<SyncClient> = None;
1385        let mut effects = CreateEffects::default();
1386
1387        dispatch(
1388            &mut transport,
1389            &mut client,
1390            &mut effects,
1391            "create",
1392            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1393        )
1394        .expect("preflight create");
1395        dispatch(
1396            &mut transport,
1397            &mut client,
1398            &mut effects,
1399            "activateSecurity",
1400            &json!({}),
1401        )
1402        .expect("activation clears the gate");
1403        dispatch(
1404            &mut transport,
1405            &mut client,
1406            &mut effects,
1407            "shutdown",
1408            &json!({}),
1409        )
1410        .expect("shutdown after activation");
1411
1412        // The replica is active on disk, so a plain re-create must be admitted.
1413        dispatch(
1414            &mut transport,
1415            &mut client,
1416            &mut effects,
1417            "create",
1418            &json!({ "schema": schema(), "dbPath": path }),
1419        )
1420        .expect("an activated replica must reopen plainly after shutdown");
1421        let state = dispatch(
1422            &mut transport,
1423            &mut client,
1424            &mut effects,
1425            "securityLifecycle",
1426            &json!({}),
1427        )
1428        .expect("lifecycle");
1429        assert_eq!(state, json!({ "state": "active" }));
1430
1431        let _ = std::fs::remove_file(&path);
1432    }
1433
1434    #[test]
1435    fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1436        // The other half, and the reason the marker exists: a replica that
1437        // entered preflight and never activated must still refuse a plain
1438        // re-create after shutdown, even on a host that rebuilt its handle and
1439        // carries no in-memory flag.
1440        let path = temp_db_path("quarantined-reopen");
1441        let mut transport = NoNetwork::default();
1442        let mut client: Option<SyncClient> = None;
1443        let mut effects = CreateEffects::default();
1444
1445        dispatch(
1446            &mut transport,
1447            &mut client,
1448            &mut effects,
1449            "create",
1450            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1451        )
1452        .expect("preflight create");
1453        dispatch(
1454            &mut transport,
1455            &mut client,
1456            &mut effects,
1457            "shutdown",
1458            &json!({}),
1459        )
1460        .expect("shutdown during preflight");
1461
1462        // A fresh host handle: no in-memory pending flag survives here, so only
1463        // the persisted marker can hold the gate.
1464        let mut rebuilt = CreateEffects::default();
1465        let escape = dispatch(
1466            &mut transport,
1467            &mut client,
1468            &mut rebuilt,
1469            "create",
1470            &json!({ "schema": schema(), "dbPath": path }),
1471        )
1472        .expect_err("a quarantined replica must refuse a plain re-create");
1473        assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1474
1475        let _ = std::fs::remove_file(&path);
1476    }
1477
1478    #[test]
1479    fn preflight_gate_survives_shutdown_before_replacement_creates() {
1480        let mut transport = NoNetwork::default();
1481        let mut client: Option<SyncClient> = None;
1482        let mut effects = CreateEffects::default();
1483        dispatch(
1484            &mut transport,
1485            &mut client,
1486            &mut effects,
1487            "create",
1488            &json!({ "schema": schema(), "securityPreflight": true }),
1489        )
1490        .expect("preflight create");
1491        dispatch(
1492            &mut transport,
1493            &mut client,
1494            &mut effects,
1495            "shutdown",
1496            &json!({}),
1497        )
1498        .expect("shutdown during preflight");
1499        assert!(client.is_none());
1500
1501        // The pending preflight rides `effects` across the empty client slot.
1502        let escape = dispatch(
1503            &mut transport,
1504            &mut client,
1505            &mut effects,
1506            "create",
1507            &json!({ "schema": schema() }),
1508        )
1509        .expect_err("shutdown + plain create must stay refused");
1510        assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1511
1512        dispatch(
1513            &mut transport,
1514            &mut client,
1515            &mut effects,
1516            "create",
1517            &json!({ "schema": schema(), "securityPreflight": true }),
1518        )
1519        .expect("preflighted re-create");
1520        dispatch(
1521            &mut transport,
1522            &mut client,
1523            &mut effects,
1524            "activateSecurity",
1525            &json!({}),
1526        )
1527        .expect("activation");
1528
1529        // An ACTIVATED client shut down cleanly may be recreated plainly.
1530        dispatch(
1531            &mut transport,
1532            &mut client,
1533            &mut effects,
1534            "shutdown",
1535            &json!({}),
1536        )
1537        .expect("shutdown after activation");
1538        dispatch(
1539            &mut transport,
1540            &mut client,
1541            &mut effects,
1542            "create",
1543            &json!({ "schema": schema() }),
1544        )
1545        .expect("plain create after an activated shutdown");
1546    }
1547
1548    #[test]
1549    fn activate_security_validates_optional_headers_atomically() {
1550        let mut transport = NoNetwork::default();
1551        let mut client: Option<SyncClient> = None;
1552        let mut effects = CreateEffects::default();
1553        dispatch(
1554            &mut transport,
1555            &mut client,
1556            &mut effects,
1557            "create",
1558            &json!({ "schema": schema(), "securityPreflight": true }),
1559        )
1560        .expect("preflight create");
1561
1562        // Invalid header shapes fail loudly and keep the gate closed.
1563        let invalid = dispatch(
1564            &mut transport,
1565            &mut client,
1566            &mut effects,
1567            "activateSecurity",
1568            &json!({ "headers": { "authorization": 7 } }),
1569        )
1570        .expect_err("non-string header must fail");
1571        assert_eq!(invalid.0, "sync.invalid_request");
1572        let lifecycle = dispatch(
1573            &mut transport,
1574            &mut client,
1575            &mut effects,
1576            "securityLifecycle",
1577            &json!({}),
1578        )
1579        .expect("lifecycle");
1580        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1581
1582        // A valid header set activates in one atomic step.
1583        dispatch(
1584            &mut transport,
1585            &mut client,
1586            &mut effects,
1587            "activateSecurity",
1588            &json!({ "headers": { "authorization": "Bearer fresh" } }),
1589        )
1590        .expect("activation with fresh headers");
1591        let lifecycle = dispatch(
1592            &mut transport,
1593            &mut client,
1594            &mut effects,
1595            "securityLifecycle",
1596            &json!({}),
1597        )
1598        .expect("lifecycle");
1599        assert_eq!(lifecycle, json!({ "state": "active" }));
1600    }
1601
1602    #[test]
1603    fn parse_headers_reads_the_full_replacement_set() {
1604        let parsed = parse_headers(&json!({
1605            "authorization": "Bearer fresh",
1606            "x-tenant": "t1"
1607        }))
1608        .expect("valid headers parse");
1609        assert_eq!(
1610            parsed,
1611            vec![
1612                ("authorization".to_owned(), "Bearer fresh".to_owned()),
1613                ("x-tenant".to_owned(), "t1".to_owned())
1614            ]
1615        );
1616        assert!(parse_headers(&json!(["authorization"])).is_err());
1617        assert!(parse_headers(&json!({ "authorization": null })).is_err());
1618    }
1619}