Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
161 lines
5.1 KiB
Rust
161 lines
5.1 KiB
Rust
//! A space's Yjs document: one `Y.Text` per model file in the `files` map.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use archify_sync_protocol::FILES_MAP;
|
|
use archify_sync_store::StoredDoc;
|
|
use yrs::updates::decoder::Decode;
|
|
use yrs::{
|
|
Doc, GetString, Map, OffsetKind, Options, Out, ReadTxn, StateVector, Text, TextPrelim,
|
|
Transact, Update,
|
|
};
|
|
|
|
/// A document that counts text offsets in UTF-16, as Yjs in the extension does.
|
|
pub fn new_doc() -> Doc {
|
|
Doc::with_options(Options {
|
|
offset_kind: OffsetKind::Utf16,
|
|
..Options::default()
|
|
})
|
|
}
|
|
|
|
pub fn load(stored: &StoredDoc) -> anyhow::Result<Doc> {
|
|
let doc = new_doc();
|
|
{
|
|
let mut txn = doc.transact_mut();
|
|
if let Some(state) = &stored.state {
|
|
txn.apply_update(Update::decode_v1(state)?)?;
|
|
}
|
|
for update in &stored.updates {
|
|
txn.apply_update(Update::decode_v1(update)?)?;
|
|
}
|
|
}
|
|
Ok(doc)
|
|
}
|
|
|
|
pub fn from_state(state: &[u8]) -> anyhow::Result<Doc> {
|
|
let doc = new_doc();
|
|
doc.transact_mut().apply_update(Update::decode_v1(state)?)?;
|
|
Ok(doc)
|
|
}
|
|
|
|
pub fn encode_state(doc: &Doc) -> Vec<u8> {
|
|
doc.transact()
|
|
.encode_state_as_update_v1(&StateVector::default())
|
|
}
|
|
|
|
/// Path → text of every model file.
|
|
pub fn files(doc: &Doc) -> BTreeMap<String, String> {
|
|
let map = doc.get_or_insert_map(FILES_MAP);
|
|
let txn = doc.transact();
|
|
map.iter(&txn)
|
|
.filter_map(|(path, value)| match value {
|
|
Out::YText(text) => Some((path.to_owned(), text.get_string(&txn))),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Makes `doc` hold the files of `target` as ordinary edits, so every copy
|
|
/// takes them like any other change. Returns the update, empty when nothing changed.
|
|
pub fn replace_files(doc: &Doc, target: &BTreeMap<String, String>) -> Vec<u8> {
|
|
let map = doc.get_or_insert_map(FILES_MAP);
|
|
let mut txn = doc.transact_mut();
|
|
let current: Vec<String> = map.keys(&txn).map(str::to_owned).collect();
|
|
for path in current.iter().filter(|p| !target.contains_key(*p)) {
|
|
map.remove(&mut txn, path);
|
|
}
|
|
for (path, text) in target {
|
|
match map.get(&txn, path) {
|
|
Some(Out::YText(existing)) => {
|
|
let old = existing.get_string(&txn);
|
|
if let Some((at, remove, insert)) = minimal_change(&old, text) {
|
|
if remove > 0 {
|
|
existing.remove_range(&mut txn, at, remove);
|
|
}
|
|
if !insert.is_empty() {
|
|
existing.insert(&mut txn, at, insert);
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
map.insert(&mut txn, path.as_str(), TextPrelim::new(text.as_str()));
|
|
}
|
|
}
|
|
}
|
|
txn.encode_update_v1()
|
|
}
|
|
|
|
/// The one edit that turns `old` into `new`: UTF-16 offset, UTF-16 length to
|
|
/// remove, text to insert. Keeps the common start and end, as the extension's
|
|
/// `minimalChange` does, and never splits a character.
|
|
pub fn minimal_change<'a>(old: &str, new: &'a str) -> Option<(u32, u32, &'a str)> {
|
|
if old == new {
|
|
return None;
|
|
}
|
|
let prefix: usize = old
|
|
.chars()
|
|
.zip(new.chars())
|
|
.take_while(|(a, b)| a == b)
|
|
.map(|(a, _)| a.len_utf8())
|
|
.sum();
|
|
let (old_rest, new_rest) = (&old[prefix..], &new[prefix..]);
|
|
let suffix: usize = old_rest
|
|
.chars()
|
|
.rev()
|
|
.zip(new_rest.chars().rev())
|
|
.take_while(|(a, b)| a == b)
|
|
.map(|(a, _)| a.len_utf8())
|
|
.sum();
|
|
let removed = &old_rest[..old_rest.len() - suffix];
|
|
let inserted = &new_rest[..new_rest.len() - suffix];
|
|
let utf16 = |s: &str| s.encode_utf16().count() as u32;
|
|
Some((utf16(&old[..prefix]), utf16(removed), inserted))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn texts(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
|
pairs
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_change_keeps_common_ends() {
|
|
assert_eq!(
|
|
minimal_change("label: API", "label: Gateway API"),
|
|
Some((7, 0, "Gateway "))
|
|
);
|
|
assert_eq!(minimal_change("abc", "abc"), None);
|
|
assert_eq!(minimal_change("a😀b", "a😀cb"), Some((3, 0, "c")));
|
|
assert_eq!(minimal_change("x😀y", "x😃y"), Some((1, 2, "😃")));
|
|
}
|
|
|
|
#[test]
|
|
fn replace_files_reaches_target_and_syncs() {
|
|
let doc = new_doc();
|
|
replace_files(
|
|
&doc,
|
|
&texts(&[("main.archify", "model: a\n"), ("old.archify", "x")]),
|
|
);
|
|
let other = from_state(&encode_state(&doc)).unwrap();
|
|
|
|
let target = texts(&[("main.archify", "model: b\n"), ("new.archify", "y")]);
|
|
let update = replace_files(&doc, &target);
|
|
assert_eq!(files(&doc), target);
|
|
|
|
other
|
|
.transact_mut()
|
|
.apply_update(Update::decode_v1(&update).unwrap())
|
|
.unwrap();
|
|
assert_eq!(files(&other), target);
|
|
assert!(
|
|
replace_files(&doc, &target).len() <= 2,
|
|
"no change gives an empty update"
|
|
);
|
|
}
|
|
}
|