1use 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 ClientLimits, CommandEffects, CommitOutcomeQuery, LocalDataPurgeInput, Mutation,
24 ResolveCommitOutcomeInput, SyncClient, Transport, WindowBase, WindowCoverage,
25};
26
27pub fn bytes_to_hex(bytes: &[u8]) -> String {
30 syncular_client::values::bytes_to_hex(bytes)
31}
32
33pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
34 syncular_client::values::hex_to_bytes(hex)
35}
36
37pub fn bytes_value(bytes: &[u8]) -> Value {
38 json!({ "$bytes": bytes_to_hex(bytes) })
39}
40
41pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
42 let hex = value
43 .and_then(|v| v.get("$bytes"))
44 .and_then(Value::as_str)
45 .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
46 hex_to_bytes(hex)
47}
48
49pub type CommandError = (String, String);
51
52#[derive(Debug, Default, Clone)]
56pub struct CreateEffects {
57 pub signed_urls: bool,
60}
61
62fn client_err(message: String) -> CommandError {
63 let code = message
64 .split_once(':')
65 .map(|(candidate, _)| candidate)
66 .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
67 .unwrap_or("client.failed");
68 (code.to_owned(), message)
69}
70
71fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
72 client
73 .as_mut()
74 .ok_or_else(|| client_err("no client instance created".to_owned()))
75}
76
77pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
78 let mut limits = ClientLimits::default();
79 let Some(object) = value.and_then(Value::as_object) else {
80 return limits;
81 };
82 limits.limit_commits = object
83 .get("limitCommits")
84 .and_then(Value::as_i64)
85 .map(|v| v as i32);
86 limits.limit_snapshot_rows = object
87 .get("limitSnapshotRows")
88 .and_then(Value::as_i64)
89 .map(|v| v as i32);
90 limits.max_snapshot_pages = object
91 .get("maxSnapshotPages")
92 .and_then(Value::as_i64)
93 .map(|v| v as i32);
94 limits.accept = object
95 .get("accept")
96 .and_then(Value::as_u64)
97 .map(|v| v as u8);
98 limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
99 limits.outcome_retention_max_entries = object
100 .get("outcomeRetentionMaxEntries")
101 .and_then(Value::as_u64)
102 .map(|value| value as usize);
103 limits
104}
105
106pub fn parse_encryption(
110 value: &Value,
111) -> Result<syncular_client::values::EncryptionConfig, String> {
112 let mut config = syncular_client::values::EncryptionConfig::default();
113 let Some(keys) = value.get("keys").and_then(Value::as_object) else {
114 return Ok(config);
115 };
116 for (key_id, key_val) in keys {
117 let bytes =
118 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
119 if bytes.len() != 32 {
120 return Err(format!(
121 "encryption key {key_id:?} must be 32 bytes, got {}",
122 bytes.len()
123 ));
124 }
125 config.keys.insert(key_id.clone(), bytes);
126 }
127 if let Some(columns) = value.get("keyIdColumns") {
128 let columns = columns
129 .as_object()
130 .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
131 for (table, column) in columns {
132 let column = column.as_str().ok_or_else(|| {
133 format!("encryption keyIdColumns entry for {table:?} must be a string")
134 })?;
135 if column.is_empty() {
136 return Err(format!(
137 "encryption keyIdColumns entry for {table:?} must not be empty"
138 ));
139 }
140 config
141 .key_id_columns
142 .insert(table.clone(), column.to_owned());
143 }
144 }
145 Ok(config)
146}
147
148pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
149 let list = value
150 .and_then(Value::as_array)
151 .ok_or_else(|| "mutations must be a list".to_owned())?;
152 let mut out = Vec::with_capacity(list.len());
153 for entry in list {
154 let op = entry
155 .get("op")
156 .and_then(Value::as_str)
157 .ok_or_else(|| "mutation missing op".to_owned())?;
158 let table = entry
159 .get("table")
160 .and_then(Value::as_str)
161 .ok_or_else(|| "mutation missing table".to_owned())?
162 .to_owned();
163 let base_version = entry.get("baseVersion").and_then(Value::as_i64);
164 match op {
165 "upsert" => {
166 let mut values = entry
167 .get("values")
168 .and_then(Value::as_object)
169 .cloned()
170 .ok_or_else(|| "upsert missing values".to_owned())?;
171 decode_bigint_members(&mut values)?;
172 out.push(Mutation::Upsert {
173 table,
174 values,
175 base_version,
176 });
177 }
178 "delete" => {
179 let row_id = entry
180 .get("rowId")
181 .and_then(Value::as_str)
182 .ok_or_else(|| "delete missing rowId".to_owned())?
183 .to_owned();
184 out.push(Mutation::Delete {
185 table,
186 row_id,
187 base_version,
188 });
189 }
190 other => return Err(format!("unknown mutation op {other:?}")),
191 }
192 }
193 Ok(out)
194}
195
196fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
197 for value in values.values_mut() {
198 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
199 continue;
200 };
201 let integer = decimal
202 .parse::<i64>()
203 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
204 *value = Value::from(integer);
205 }
206 Ok(())
207}
208
209fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
210 match value {
211 Some(v) => syncular_client::values::json_to_scope_map(v),
212 None => Ok(Vec::new()),
213 }
214}
215
216fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
219 let object = value
220 .and_then(Value::as_object)
221 .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
222 let table = object
223 .get("table")
224 .and_then(Value::as_str)
225 .ok_or_else(|| "window base missing table".to_owned())?
226 .to_owned();
227 let variable = object
228 .get("variable")
229 .and_then(Value::as_str)
230 .ok_or_else(|| "window base missing variable".to_owned())?
231 .to_owned();
232 let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
233 let params = object
234 .get("params")
235 .and_then(Value::as_str)
236 .map(str::to_owned);
237 Ok(WindowBase {
238 table,
239 variable,
240 fixed_scopes,
241 params,
242 })
243}
244
245#[cfg(feature = "crdt-yjs")]
249fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
250 let table = params
251 .get("table")
252 .and_then(Value::as_str)
253 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
254 .to_owned();
255 let row_id = params
256 .get("rowId")
257 .and_then(Value::as_str)
258 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
259 .to_owned();
260 let column = params
261 .get("column")
262 .and_then(Value::as_str)
263 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
264 .to_owned();
265 let name = params
266 .get("name")
267 .and_then(Value::as_str)
268 .unwrap_or("text")
269 .to_owned();
270 Ok((table, row_id, column, name))
271}
272
273pub fn dispatch<T: Transport>(
283 transport: &mut T,
284 client: &mut Option<SyncClient>,
285 effects: &mut CreateEffects,
286 method: &str,
287 params: &Value,
288) -> Result<Value, CommandError> {
289 match method {
290 "create" => {
291 let client_id = params
292 .get("clientId")
293 .and_then(Value::as_str)
294 .map(str::to_owned);
295 let schema = params
296 .get("schema")
297 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
298 let limits = parse_limits(params.get("limits"));
299 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
303 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
304 .map_err(client_err)?,
305 None => {
306 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
307 }
308 };
309 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
311 instance.set_now_ms(now_ms);
312 }
313 effects.signed_urls = params
316 .get("signedUrls")
317 .and_then(Value::as_bool)
318 .unwrap_or(false);
319 if let Some(enc) = params.get("encryption") {
323 let config = parse_encryption(enc).map_err(client_err)?;
324 instance.set_encryption(config);
325 }
326 *client = Some(instance);
327 Ok(json!({}))
328 }
329 "subscribe" => {
330 let id = params
331 .get("id")
332 .and_then(Value::as_str)
333 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
334 .to_owned();
335 let table = params
336 .get("table")
337 .and_then(Value::as_str)
338 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
339 .to_owned();
340 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
341 let sub_params = params
342 .get("params")
343 .and_then(Value::as_str)
344 .map(str::to_owned);
345 need_client(client)?
346 .subscribe(id, table, scopes, sub_params)
347 .map_err(client_err)?;
348 Ok(json!({}))
349 }
350 "unsubscribe" => {
351 let id = params
352 .get("id")
353 .and_then(Value::as_str)
354 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
355 need_client(client)?.unsubscribe(id);
356 Ok(json!({}))
357 }
358 "setWindow" => {
359 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
360 let units: Vec<String> = params
361 .get("units")
362 .and_then(Value::as_array)
363 .map(|arr| {
364 arr.iter()
365 .filter_map(|v| v.as_str().map(str::to_owned))
366 .collect()
367 })
368 .unwrap_or_default();
369 let command_effects = need_client(client)?
370 .set_window(&base, &units)
371 .map_err(client_err)?;
372 Ok(json!({ "effects": command_effects }))
373 }
374 "windowState" => {
375 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
376 let state = need_client(client)?.window_state(&base);
377 Ok(json!({ "units": state.units, "pending": state.pending }))
378 }
379 "mutate" => {
380 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
381 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
382 Ok(json!({
383 "clientCommitId": id,
384 "effects": CommandEffects::interactive()
385 }))
386 }
387 "patch" => {
388 let table = params
389 .get("table")
390 .and_then(Value::as_str)
391 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
392 let row_id = params
393 .get("rowId")
394 .and_then(Value::as_str)
395 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
396 let mut partial = params
397 .get("partial")
398 .and_then(Value::as_object)
399 .cloned()
400 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
401 decode_bigint_members(&mut partial).map_err(client_err)?;
402 let base_version = params.get("baseVersion").and_then(Value::as_i64);
403 let id = need_client(client)?
404 .patch(table, row_id, partial, base_version)
405 .map_err(client_err)?;
406 Ok(json!({
407 "clientCommitId": id,
408 "effects": CommandEffects::interactive()
409 }))
410 }
411 "purgeLocalData" => {
412 let input: LocalDataPurgeInput =
413 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
414 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
415 })?)
416 .map_err(|error| {
417 client_err(format!(
418 "sync.invalid_request: invalid purgeLocalData input: {error}"
419 ))
420 })?;
421 let result = need_client(client)?
422 .purge_local_data(&input)
423 .map_err(client_err)?;
424 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
425 }
426 "sync" => {
427 let outcome = need_client(client)?.sync(transport);
428 Ok(outcome.to_json())
429 }
430 "syncUntilIdle" => {
431 let max_rounds = params
432 .get("maxRounds")
433 .and_then(Value::as_u64)
434 .map(|v| v as u32);
435 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
436 Ok(outcome.to_json())
437 }
438 "readRows" => {
439 let table = params
440 .get("table")
441 .and_then(Value::as_str)
442 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
443 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
444 Ok(json!({ "rows": rows }))
445 }
446 "query" => {
447 let sql = params
451 .get("sql")
452 .and_then(Value::as_str)
453 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
454 let bind = match params.get("params") {
455 Some(Value::Array(list)) => list.clone(),
456 None | Some(Value::Null) => Vec::new(),
457 Some(_) => return Err(client_err("query params must be a list".to_owned())),
458 };
459 let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
460 Ok(json!({ "rows": rows }))
461 }
462 "querySnapshot" => {
463 let sql = params
464 .get("sql")
465 .and_then(Value::as_str)
466 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
467 let bind = match params.get("params") {
468 Some(Value::Array(list)) => list.clone(),
469 None | Some(Value::Null) => Vec::new(),
470 Some(_) => {
471 return Err(client_err("querySnapshot params must be a list".to_owned()))
472 }
473 };
474 let mut coverage = Vec::new();
475 for entry in params
476 .get("coverage")
477 .and_then(Value::as_array)
478 .into_iter()
479 .flatten()
480 {
481 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
482 let units = entry
483 .get("units")
484 .and_then(Value::as_array)
485 .map(|values| {
486 values
487 .iter()
488 .filter_map(|value| value.as_str().map(str::to_owned))
489 .collect()
490 })
491 .unwrap_or_default();
492 coverage.push(WindowCoverage { base, units });
493 }
494 let snapshot = need_client(client)?
495 .query_snapshot(sql, &bind, &coverage)
496 .map_err(client_err)?;
497 Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
498 }
499 "localRevision" => Ok(json!({
500 "revision": need_client(client)?.local_revision().to_string()
501 })),
502 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
503 .expect("status serializes")),
504 "drainChangeBatches" => Ok(json!({
509 "batches": need_client(client)?.drain_change_batches()
510 })),
511 "drainSyncIntents" => Ok(json!({
512 "intents": need_client(client)?.drain_sync_intents()
513 })),
514 #[cfg(feature = "crdt-yjs")]
520 "crdtText" => {
521 let (table, row_id, column, name) = crdt_target(params)?;
522 let text = need_client(client)?
523 .crdt_text(&table, &row_id, &column, &name)
524 .map_err(client_err)?;
525 Ok(json!({ "text": text }))
526 }
527 #[cfg(feature = "crdt-yjs")]
528 "crdtInsertText" => {
529 let (table, row_id, column, name) = crdt_target(params)?;
530 let index = params
531 .get("index")
532 .and_then(Value::as_u64)
533 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
534 as u32;
535 let value = params
536 .get("value")
537 .and_then(Value::as_str)
538 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
539 let id = need_client(client)?
540 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
541 .map_err(client_err)?;
542 Ok(json!({ "clientCommitId": id }))
543 }
544 #[cfg(feature = "crdt-yjs")]
545 "crdtDeleteText" => {
546 let (table, row_id, column, name) = crdt_target(params)?;
547 let index = params
548 .get("index")
549 .and_then(Value::as_u64)
550 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
551 as u32;
552 let len = params
553 .get("len")
554 .and_then(Value::as_u64)
555 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
556 as u32;
557 let id = need_client(client)?
558 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
559 .map_err(client_err)?;
560 Ok(json!({ "clientCommitId": id }))
561 }
562 #[cfg(feature = "crdt-yjs")]
563 "crdtApplyUpdate" => {
564 let (table, row_id, column, _name) = crdt_target(params)?;
565 let update = value_bytes(params.get("update")).map_err(client_err)?;
566 let id = need_client(client)?
567 .crdt_apply_update(&table, &row_id, &column, &update)
568 .map_err(client_err)?;
569 Ok(json!({ "clientCommitId": id }))
570 }
571 #[cfg(not(feature = "crdt-yjs"))]
572 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
573 "client.crdt_unavailable".to_owned(),
574 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
575 )),
576
577 "uploadBlob" => {
578 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
579 let media_type = params
580 .get("mediaType")
581 .and_then(Value::as_str)
582 .map(str::to_owned);
583 let name = params
584 .get("name")
585 .and_then(Value::as_str)
586 .map(str::to_owned);
587 let reference = need_client(client)?
588 .upload_blob(&bytes, media_type, name)
589 .map_err(client_err)?;
590 Ok(json!({ "ref": reference }))
591 }
592 "fetchBlob" => {
593 let blob = params
594 .get("blob")
595 .and_then(Value::as_str)
596 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
597 .to_owned();
598 let value = need_client(client)?.fetch_blob(transport, &blob)?;
601 Ok(json!({ "blob": value }))
602 }
603 "conflicts" => {
604 let conflicts = need_client(client)?.conflicts().to_vec();
605 Ok(json!({ "conflicts": conflicts }))
606 }
607 "rejections" => {
608 let rejections = need_client(client)?.rejections().to_vec();
609 Ok(json!({ "rejections": rejections }))
610 }
611 "commitOutcome" => {
612 let client_commit_id = params
613 .get("clientCommitId")
614 .and_then(Value::as_str)
615 .ok_or_else(|| {
616 client_err(
617 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
618 )
619 })?;
620 let outcome = need_client(client)?
621 .commit_outcome(client_commit_id)
622 .map_err(client_err)?;
623 Ok(json!({ "outcome": outcome }))
624 }
625 "commitOutcomes" => {
626 let query = serde_json::from_value::<CommitOutcomeQuery>(
627 params.get("query").cloned().unwrap_or_else(|| json!({})),
628 )
629 .map_err(|error| {
630 client_err(format!(
631 "sync.invalid_request: invalid commit outcome query: {error}"
632 ))
633 })?;
634 let outcomes = need_client(client)?
635 .commit_outcomes(query)
636 .map_err(client_err)?;
637 Ok(json!({ "outcomes": outcomes }))
638 }
639 "resolveCommitOutcome" => {
640 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
641 params.get("input").cloned().ok_or_else(|| {
642 client_err(
643 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
644 )
645 })?,
646 )
647 .map_err(|error| {
648 client_err(format!(
649 "sync.invalid_request: invalid outcome resolution: {error}"
650 ))
651 })?;
652 let outcome = need_client(client)?
653 .resolve_commit_outcome(input)
654 .map_err(client_err)?;
655 Ok(json!({ "outcome": outcome }))
656 }
657 "pendingCommitIds" => {
658 let ids = need_client(client)?.pending_commit_ids();
659 Ok(json!({ "ids": ids }))
660 }
661 "subscriptionState" => {
662 let id = params
663 .get("id")
664 .and_then(Value::as_str)
665 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
666 let state = need_client(client)?.subscription_state(id);
667 Ok(json!({ "state": state }))
668 }
669 "schemaFloor" => {
670 let floor = need_client(client)?.schema_floor().cloned();
671 Ok(json!({ "floor": floor }))
672 }
673 "leaseState" => {
674 let lease = need_client(client)?.lease_state().cloned();
675 Ok(json!({ "lease": lease }))
676 }
677 "upgrading" => {
678 let value = need_client(client)?.upgrading();
680 Ok(json!({ "value": value }))
681 }
682 "recreateWithSchema" => {
683 let schema = params
687 .get("schema")
688 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
689 need_client(client)?
690 .recreate_with_schema(schema)
691 .map_err(client_err)?;
692 Ok(json!({}))
693 }
694 "connectRealtime" => {
695 need_client(client)?
696 .connect_realtime(transport)
697 .map_err(client_err)?;
698 Ok(json!({}))
699 }
700 "disconnectRealtime" => {
701 need_client(client)?.disconnect_realtime(transport);
702 Ok(json!({}))
703 }
704 "syncNeeded" => {
705 let value = need_client(client)?.sync_needed();
706 Ok(json!({ "value": value }))
707 }
708 "setPresence" => {
709 let scope_key = params
710 .get("scopeKey")
711 .and_then(Value::as_str)
712 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
713 .to_owned();
714 let doc = params.get("doc");
716 let doc_ref = match doc {
717 None | Some(Value::Null) => None,
718 Some(v) => Some(v),
719 };
720 need_client(client)?
721 .set_presence(transport, &scope_key, doc_ref)
722 .map_err(client_err)?;
723 Ok(json!({}))
724 }
725 "presence" => {
726 let scope_key = params
727 .get("scopeKey")
728 .and_then(Value::as_str)
729 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
730 let peers = need_client(client)?.presence(scope_key);
731 Ok(json!({ "peers": peers }))
732 }
733
734 "messageRoundtrip" => {
736 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
737 match decode_message(&bytes) {
738 Ok(message) => Ok(json!({
739 "ok": true,
740 "bytes": bytes_value(&encode_message(&message)),
741 "renderedJson": render_message(&message).to_string(),
742 })),
743 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
744 }
745 }
746 "segmentRoundtrip" => {
747 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
748 match decode_rows_segment(&bytes) {
749 Ok(segment) => Ok(json!({
750 "ok": true,
751 "bytes": bytes_value(&encode_rows_segment(&segment)),
752 "renderedJson": render_rows_segment(&segment).to_string(),
753 })),
754 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
755 }
756 }
757 "realtimeKnown" => {
758 let text = params
759 .get("text")
760 .and_then(Value::as_str)
761 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
762 let known = matches!(
763 parse_control(text),
764 Ok(ControlMessage::Hello { .. })
765 | Ok(ControlMessage::Wake { .. })
766 | Ok(ControlMessage::Heartbeat { .. })
767 | Ok(ControlMessage::Presence { .. })
768 );
769 Ok(json!({ "value": known }))
770 }
771
772 other => Err(client_err(format!("unknown method {other:?}"))),
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use serde_json::json;
779
780 use super::parse_encryption;
781
782 #[test]
783 fn parses_portable_encryption_keyring_and_key_id_columns() {
784 let key_hex = "2a".repeat(32);
785 let config = parse_encryption(&json!({
786 "keys": { "practice-key-v1": { "$bytes": key_hex } },
787 "keyIdColumns": { "patients": "encryption_key_id" }
788 }))
789 .expect("portable keyring parses");
790 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
791 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
792 }
793
794 #[test]
795 fn rejects_non_string_key_id_columns() {
796 let error = parse_encryption(&json!({
797 "keys": {},
798 "keyIdColumns": { "patients": 7 }
799 }))
800 .expect_err("invalid selector must fail");
801 assert!(error.contains("must be a string"), "{error}");
802 }
803}