Archify Sync server, Dockerfile and Helm chart
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.
This commit is contained in:
commit
99778d7489
67 files changed
+8362
No files matched your search
@@ -0,0 +1,160 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user