- Ao editar simultaneamente estruturas hierárquicas em software colaborativo, surgem conflitos de árvore como nós duplicados, ciclos e movimentação de subnós de ancestrais excluídos, e o Loro implementa isso com uma tree CRDT com movimentação
- A abordagem de Martin Kleppmann e outros unifica criação, exclusão e movimentação em
Move t p m c, e trata a exclusão como uma movimentação para o nó TRASH, preservando a movimentação simultânea de subnós
- A ordem global é criada com Lamport Timestamp e Peer ID, e quando uma operação remota entra no meio da ordem existente, usa-se undo-do-redo para evitar ciclos
- O Loro combina Fractional Index para ordenar nós irmãos e, quando índices se sobrepõem por inserções simultâneas na mesma posição, trata isso com PeerID, jitter e redefinição de índice
- Nos benchmarks, a Loro Movable Tree executou 10.000 movimentações aleatórias em 28 ms após criar 1.000 nós, mostrando desempenho suficiente para colaboração em tempo real e checkout de versões anteriores
Conflitos que surgem em árvores colaborativas
- Ao gerenciar relações hierárquicas em sistemas distribuídos e software colaborativo, modelar uma movimentação como combinação de exclusão e inserção facilita conflitos entre a expectativa do usuário e a forma de resolução
- Se o mesmo nó for movido simultaneamente para pais diferentes em várias réplicas, um nó pode ser excluído duas vezes e recriado sob dois pais, gerando nós duplicados com o mesmo conteúdo
- As operações básicas de uma árvore com movimentação são três: criação, exclusão e movimentação
- As situações que mais costumam causar problemas na sincronização são as seguintes
- o mesmo nó é excluído e movido ao mesmo tempo
- o mesmo nó é movido para pais diferentes
- movimentações de nós diferentes se combinam e geram um ciclo
- um subnó é movido enquanto um nó ancestral está sendo excluído
Formas de tratamento por tipo de conflito
- Quando há conflito entre exclusão e movimentação do mesmo nó, pode-se aplicar uma operação e ignorar a outra conforme o timestamp do sistema distribuído ou os requisitos da aplicação
- Quando o mesmo nó é movido para pais diferentes, as opções variam de acordo com a aplicação
- excluir o nó e depois criar cópias sob pais diferentes, tratando-as independentemente a partir daí
- permitir que um nó aponte para dois pais, mas isso quebra a estrutura de árvore e normalmente é difícil de aceitar
- ordenar todas as operações e aplicá-las em sequência para produzir o mesmo resultado em todos os peers
- Quando movimentações de nós diferentes criam um ciclo, a resolução de conflitos em árvores com movimentação se torna especialmente complexa
- Matthew Weidner lista opções como tratamento de erro, renderização de uma área “time-out”, rejeição baseada em servidor, ignorar operações que criem ciclo após ordenação topológica, ocultar certas edges na renderização e reverter ao pai anterior
- Também é fácil deixar passar a situação em que um subnó é movido durante a exclusão de um nó ancestral
- se todos os subnós do ancestral forem excluídos imediatamente, o usuário pode entender isso como perda de dados
A abordagem da Dropbox e da Figma
- A Dropbox inicialmente tratava a movimentação de arquivos em duas etapas: excluir da posição original e criar na nova posição
- se houvesse queda de energia ou falha do sistema entre a exclusão e a criação, existia risco de perda de dados
- hoje, quando várias pessoas tentam mover e salvar o mesmo arquivo ao mesmo tempo, ela detecta o conflito e normalmente salva uma versão do arquivo original, criando uma “conflicted copy” para a alteração de um dos usuários
- A Figma considera a estrutura de árvore a parte mais complexa do sistema colaborativo e atribui uma propriedade
parent a cada elemento
- um servidor central monitora as atualizações de vários usuários e rejeita operações que possam criar um ciclo
- por causa da latência de rede, um ciclo pode surgir temporariamente antes de o servidor rejeitar a operação
- nesse caso, a Figma oculta temporariamente os elementos incluídos no ciclo e preserva o estado até que o servidor rejeite oficialmente a operação
- a explicação relacionada pode ser vista no artigo sobre a tecnologia multiplayer da Figma
Duas abordagens de tree CRDT com movimentação
- Em vez de uma solução centralizada, é possível tratar estruturas de árvore colaborativas com CRDT
- Os algoritmos iniciais de árvore baseados em CRDT eram difíceis de implementar e tinham alto overhead de armazenamento, mas com otimizações e melhorias surgiram algoritmos de sincronização de árvores adequados para alguns ambientes de produção
- Há duas abordagens representativas baseadas em CRDT
Abordagem de Kleppmann: unificar todas as operações em Move
- A highly-available move operation for replicated trees unifica criação, exclusão e movimentação da árvore em uma única operação move
- A operação move é definida por quatro valores:
Move t p m c
t: um timestamp único e ordenável, como um Lamport timestamp
p: ID do nó pai
m: metadata associada ao nó
c: ID do nó filho
- Se
c não existir na árvore, a operação se torna uma criação, criando o filho c sob o pai p
- Se
c já existir, a operação se torna uma movimentação, transferindo-o do pai atual para o novo pai p
- A exclusão é tratada como uma movimentação para o nó
TRASH
- todos os subnós de
TRASH são considerados excluídos
- mas permanecem na memória para que uma edição simultânea possa mover esse nó para outro lugar
- isso serve para tratar situações em que a exclusão de um ancestral e a movimentação de um subnó acontecem ao mesmo tempo
Ordenação e unsafe operation
- Como a exclusão também é definida como operação move, “excluir e mover o mesmo nó” vira um conflito entre duas operações move
- Restam dois problemas centrais
- mover o mesmo nó para pais diferentes
- mover nós diferentes e gerar um ciclo
- Ao ordenar linearmente todas as operações com Lamport timestamp e Peer ID, a movimentação simultânea do mesmo nó também pode ser expressa como duas operações ordenadas
- Ao modelar a árvore apenas com operações move, as situações excepcionais da edição simultânea se reduzem à criação de ciclos
- Operações que criam ciclos são tratadas como unsafe operation
- o algoritmo ordena todas as operações move em ordem de timestamp
- antes de aplicar cada operação, ele detecta se haverá ciclo
- se a operação criar um ciclo, essa unsafe operation é ignorada para manter uma estrutura de árvore válida
Lamport Timestamp e aplicação de operações remotas
- Lamport Timestamp permite determinar a ordem causal dos eventos em sistemas distribuídos
- cada peer tem um contador que começa em 0
- quando ocorre um evento local, o contador é incrementado em 1 e esse valor é usado como timestamp
- quando o peer
A envia uma mensagem para B, ele anexa o timestamp
B compara o timestamp recebido com seu relógio lógico e o atualiza para o maior valor
- A ordenação global primeiro compara o Lamport Timestamp e, em caso de empate, usa o ID único do peer como tie-breaker
- Quando uma atualização remota entra no meio da sequência já ordenada de operações, é necessário usar undo-do-redo
- desfaz as operações mais recentes
- insere e aplica a nova operação
- reaplica as operações desfeitas
- Para desfazer rapidamente uma operação move, o old parent do nó de destino é armazenado em cache antes da aplicação de cada move
- Mesmo que o efeito de uma unsafe operation seja ignorado, o registro precisa ser mantido
- a segurança de uma operação é determinada dinamicamente
- se depois chegar uma atualização em que outro nó que causava o ciclo seja excluído primeiro, uma operação que antes era unsafe pode se tornar safe
- durante o processo de undo, é necessário marcar operações ineffective para encontrar o pai de destino da última operação que realmente teve efeito
Exemplo de undo-do-redo
- Se uma nova operação depender de uma operação que ainda não existe localmente, como a atualização da versão intermediária ainda está faltando, ela deve ser armazenada temporariamente em cache e aplicada após o recebimento da atualização ausente
- Se o novo
opId for maior do que todas as operações existentes, ele pode ser aplicado imediatamente
- se for safe, registra o pai atual do nó de destino como old parent e aplica o move
- se for unsafe, marca como ineffective e ignora seu efeito
- Se o novo
opId entrar no meio da ordem existente, as operações posteriores são desfeitas uma a uma, a nova operação é aplicada, e então as operações desfeitas são reaplicadas em ordem
- No fluxo de exemplo, o
Peer1 move C localmente para baixo de B e depois recebe a operação em que o Peer0 move B para baixo de C
- Na ordem do Lamport timestamp,
0:3 vem antes de 1:3, então primeiro faz undo de 1:3 para devolver C ao old parent A
- Depois, com
0:3, move B para baixo de C
- Em seguida, faz redo de
1:3 para tentar mover C para baixo de B, mas um ciclo é detectado e a operação não é aplicada
- O estado da árvore não muda e o processo de undo-do-redo é concluído
Abordagem de Evan Wallace: rastreamento de pais passados
- O texto CRDT: Mutable Tree Hierarchy de Evan Wallace faz com que cada nó rastreie todos os nós pais passados
- Cada pai registrado recebe um contador
- O valor de count do novo pai é 1 maior do que os counts de todos os pais passados daquele nó
- O pai com o maior count se torna o pai atual
- Na sincronização, os registros de pais também são sincronizados
- Se surgir um ciclo, um algoritmo heurístico reconecta o nó que causou o ciclo ao pai passado mais próximo que não forme ciclo e que esteja conectado à root
- Esse processo é repetido até que todos os nós cíclicos sejam reconectados à árvore, sincronizando a estrutura da árvore entre as réplicas
- Essa abordagem não exige o procedimento caro de undo-do-redo, mas, a cada move remoto recebido, precisa verificar se todos os nós estão conectados à root e reconectar os nós cíclicos, o que pode degradar o desempenho quando há muitos nós
- Foi criado separadamente um benchmark para comparação de desempenho
Implementação da Movable Tree no Loro
- O Loro implementa o algoritmo de A highly-available move operation for replicated trees, de Martin Kleppmann e outros
- Esse algoritmo oferece alto desempenho na maioria dos cenários reais
- O processo central de undo-do-redo é muito semelhante à forma como o Eg-walker (Event Graph Walker) do Loro aplica atualizações remotas
- Apenas a árvore com movimentação não resolve o problema da ordem entre nós irmãos
- Em outline notes ou no gerenciamento de layers de softwares de design gráfico, é necessário ordenar os nós filhos
- O usuário precisa ajustar a ordem dos nós e sincronizá-la com outros colaboradores ou dispositivos
- O Loro integra o algoritmo
Fractional Index para tornar ordenáveis os nós filhos da árvore com movimentação
Fractional Index e conflitos de inserção concorrente
- O
Fractional Index atribui a cada objeto um valor ordenável
- Quando uma nova inserção acontece entre dois objetos, o
Fractional Index do novo objeto fica entre os valores da esquerda e da direita
- Explicações relacionadas podem ser vistas no blog da Figma e no blog do Evan
- Em ambiente distribuído, se vários peers inserirem novos nós na mesma posição, nós com conteúdos diferentes podem receber o mesmo
Fractional Index
- O Loro mantém o mesmo
Fractional Index, e a ordem relativa entre índices iguais é determinada pelo PeerID, o ID único de cada peer
- Se houver o mesmo
Fractional Index dos dois lados, não é possível criar um novo Fractional Index entre eles
- O Loro lida com esse problema de duas formas
- adiciona uma certa quantidade de jitter ao
Fractional Index gerado para reduzir bastante a chance de surgirem índices iguais
- Por exemplo, se o valor entre 0 e 1 fosse originalmente 0.5, com jitter aleatório ele pode se tornar
0.52712, 0.58312, 0.52834 etc.
- Se for necessário inserir entre
0.7@A e 0.7@B, é possível reajustar atribuindo novos Fractional Index ao novo nó e ao nó 0.7@B na faixa entre 0.7 e 1
Tamanho da codificação e configuração de jitter
- O Loro usa a implementação de
Fractional Index baseada em Vec<u8> do drifting-in-space
- Essa implementação usa base 256
- Na configuração padrão, é preciso continuar inserindo 128 valores para frente ou para trás para que o tamanho em bytes do
Fractional Index aumente em 1
- O pior overhead de armazenamento acontece quando novos valores são inseridos alternadamente a cada vez
- Por exemplo, em
ab, insere-se c entre a e b, depois d entre c e b, e depois e entre c e d
- Nesse caso, uma nova operação pode exigir um byte adicional, mas essa situação é muito rara
- O Loro adiciona uma solução simples de jitter à implementação original
- acrescenta bytes aleatórios ao
Fractional Index de acordo com o comprimento do valor de jitter
- Em JavaScript, é possível ativar o jitter passando um número positivo para
doc.setFractionalIndexJitter(number)
- O tamanho da codificação aumenta um pouco, com
jitter bytes adicionados a cada Fractional Index
- A relação entre o jitter necessário para evitar colisões com 99% de probabilidade ao criar
Fractional Index na mesma posição e o número máximo n de edições simultâneas é a seguinte
| jitter |
Número máximo de edições simultâneas |
| 1 |
3 |
| 2 |
37 |
| 3 |
582 |
- Muitos
Fractional Index ordenados passam a compartilhar prefixos em comum
- Ao codificar, o Loro reduz o tamanho total usando otimização de prefixo, armazenando apenas a quantidade de bits de prefixo iguais ao valor anterior e os bytes restantes
Trabalhos relacionados e motivo da escolha
- Além do
Fractional Index, também existem movable list CRDTs capazes de ordenar nós sibling de uma árvore
- O texto Moving Elements in List CRDTs, de Martin Kleppmann, é usado no Movable List do Loro
- A solução com
Fractional Index é mais simples de implementar
- Ao modelar nós de árvore, se não for fornecida uma representação estável de posição para os nós filhos, a estrutura inteira da árvore se torna complexa demais
- O
Fractional Index tem um problema de interleaving
- Em casos como itens de layer do Figma ou bookmarks multinível, em que só é necessária ordem relativa e não uma semântica sequencial estrita, isso é aceitável
Resultados do benchmark
- O Loro fez benchmark da implementação de Movable Tree quanto a desempenho em condições extremas, como movimentação aleatória de nós, troca para versões passadas e estruturas de árvore muito profundas
- Os resultados ficaram em um nível capaz de suportar colaboração em tempo real e checkout fluido de versões passadas
- O ambiente de teste foi uma CPU M2 Max, e o código de benchmark está em tree.rs
| Operação |
Tempo |
Configuração |
| 10.000 movimentações aleatórias |
28ms |
Criar primeiro 1.000 nós |
| 1.000 trocas para versões diferentes |
153ms |
Criar primeiro 1.000 nós e depois mover 1.000 vezes |
| 1.000 trocas para versões diferentes em uma árvore de profundidade 300 |
701ms |
Cada novo nó é filho do nó anterior |
Exemplo de uso e demo
- O
LoroTree de loro-crdt oferece criação de nós, criação com posicionamento, movimentação, mover para a root, mover para antes ou depois de outro nó, consulta de índice dentro do pai, consulta de Fractional Index e acesso ao data map do nó
import { Loro, LoroTree, LoroTreeNode, LoroMap } from "loro-crdt";
let doc = new Loro();
let tree: LoroTree = doc.getTree("tree");
let root: LoroTreeNode = tree.createNode();
// By default, append to the end of the parent node's children list
let node = root.createNode();
// Specify the child's position
let node2 = root.createNode(0);
// Move `node2` to be the last child of `node`
node2.move(node);
// Move `node` to be the first child of `node2`
node.move(node2, 0);
// Move the node to become the root node
node.move();
// Move the node to be positioned after another node
node.moveAfter(node2);
// Move the node to be positioned before another node
node.moveBefore(node2);
// Retrieve the index of the node within its parent's children
let index = node.index();
// Get the `Fractional Index` of the node
let fractionalIndex = node.fractionalIndex();
// Access the associated data map container
let nodeData: LoroMap = node.data;
- Foi criada uma demo de aplicativo de Todo que simula a sincronização de dados entre vários peers com Loro
Movable Tree representa a relação de subtarefas
Map representa vários atributos da tarefa
Text representa o título da tarefa
- Além de criação, movimentação, modificação e exclusão, também foi implementada a troca de versões baseada em Loro
- É possível arrastar a scrollbar para alternar entre todas as versões passadas registradas
Resumo
- A implementação de uma árvore CRDT com movimentação é difícil por causa da combinação de movimentações concorrentes, exclusão, ciclos, exclusão de ancestrais e movimentação de descendentes
- O Loro implementa a movimentação na hierarquia da árvore com o algoritmo de operação de move de alta disponibilidade de Kleppmann e outros
- A movimentação e ordenação entre nós filhos são tratadas integrando a implementação de
Fractional Index de drifting-in-space
- Essa combinação pode atender às exigências de vários cenários de aplicações colaborativas
1 comentários
Opiniões no Hacker News
Estamos criando um novo editor multiplayer [1] para tarefas/anotações, com suporte tanto a texto quanto a operações de outliner.
Por fora, ele se comporta como um documento de texto plano, mas, por causa dos recursos de outliner, internamente vira uma grande árvore. Para sincronizar mudanças, usamos uma abordagem parecida com operações de movimentação de alta disponibilidade. A única operação que altera a árvore é
insmov; quando o cliente está online, ele sincroniza o conjunto de mudanças C com o servidor. Se houver mudanças remotas no servidor, ele devolve todas as mudanças R desde a última sincronização em uma ordem linear global, desfaz osinsmovdas mudanças locais C e reaplica R junto com as novas mudanças ainda não sincronizadas.Não usamos índices fracionários; a tupla
insmovinclui não só o pai P, mas também oguidA do irmão anterior. Como todas as operações de árvore acabam sendo aplicadas na ordem linear global definida pelo servidor, a ordenação é tratada pela própria operaçãoinsmov. Na maioria dos casos, não é preciso desfazer nada; a reprodução na ordem correta só é necessária quando há no servidor mudançasinsmovque eu não conheço ao mesmo tempo em que envio um novoinsmov. Isso pode acontecer ao reconectar ao Wi-Fi depois de um voo longo, mas é menos comum quando se está online recebendo pushes em tempo real via WebSocket, e não é necessário para operações que não sejaminsmov, como atualizações de texto.[1] https://thymer.com
Por exemplo, seria como usar a ordem do servidor em vez de timestamps de Lamport.
[1] https://inria.hal.science/inria-00555588/
Ao procurar Thymer no HN, encontrei um Show HN[1] de 2009, e parece que o Thymer ficou em beta fechado pelos últimos 15 anos.
0. https://news.ycombinator.com/item?id=40786425
Preciso muito ler este texto. Em um trabalho para um cliente freelance, lancei como open source a React Table Library [0], com foco em operações de árvore.
Eles lidam com uma estrutura de árvore de pastas/arquivos na escala de 100 mil nós, tratando movimentação e duplicação de pastas e arquivos, lazy loading no nível superior e em níveis aninhados etc. dentro da mesma estrutura de tabela. Depois de terminar o projeto, acho que entendi por que o Google Drive só permite visualização e edição no mesmo nível hierárquico. Para implementar isso em uma visualização aninhada com muitos nós, há restrições demais a considerar.
[0] https://react-table-library.com/
Gostaria de pedir um conselho. Não é um app multiplayer, mas no front-end temos árvores desnormalizadas grandes e interconectadas, usadas como perfis de usuário.
Pense em algo como um layout em tiles, em que o usuário adiciona/remove/redimensiona tiles, adiciona vários componentes a cada slot de tile, e esses componentes também têm seus próprios perfis. Pode haver vários layouts com diferentes arranjos de tiles, além da complexidade de tiles individuais referenciarem ou compartilharem outras partes do estado global.
É difícil atualizar isso com segurança usando REST comum. Precisamos garantir que, se o mesmo usuário abrir duas abas, atualizar na aba 1 e depois atualizar de novo na aba 2, o perfil inteiro não fique em um estado inválido. De modo geral, a ordem também importa. No cliente, se o servidor pular uma atualização que foi aplicada corretamente, tudo pode quebrar.
Como solução bem simples, usamos uma abordagem em que enviamos o mínimo de dados necessário para sobrescrever completamente uma parte específica do estado e colocamos isso no fim de uma fila. Em geral funciona, mas às vezes há desperdício, como enviar 50 KB quando a mudança real tem só alguns bytes.
Em geral, não temos os motivos clássicos para precisar de CRDT, mas mesmo para um único usuário parece que o gerenciamento de estado ficaria muito mais fácil. Primeiro, haveria sincronização entre as abas do navegador do usuário; mais importante, eu poderia simplesmente alterar o estado no front-end e confiar que o CRDT coordenaria isso adequadamente com o servidor. Eu não precisaria mais lidar com isso diretamente. Queria saber se isso faz sentido ou se, em um cenário sem necessidade de multiplayer nem local-first, o overhead de encaixar algo como Yjs não vale a pena.
Dito isso, se a edição de perfil é exclusiva de um único usuário, adotar CRDT parece exagero. Pelo que parece, o cenário de duas abas abertas é a maior fonte de bugs, então você poderia usar
BroadcastChannelpara notificar todas as outras abas sobre eventos de atualização.Manter estado compartilhado com chamadas REST que sobrescrevem partes do estado do servidor é de fato frágil, e só é adequado para algo como sobrescrever campos de registros de dados planos. Além disso, é sempre preciso pensar com cuidado na coordenação de estado entre servidor e cliente, e em caminhos fora do fluxo normal a sincronização pode se desalinhar facilmente.
Como você disse, criar um CRDT que explicite como as atualizações são mescladas reduziria bastante a carga cognitiva.
Em conteúdos de texto rico como Google Docs ou Zoho Writer, operações como mover um item de lista para baixo ou adicionar uma nova coluna, e operações em tabelas/listas, são essencialmente operações de manipulação de árvore.
Conflitos simultâneos nesses casos são notoriamente difíceis de fazer convergir sem tratamento especial por contexto [1]. Fico curioso se esta implementação oferece uma solução generalizada também para esses casos de uso.
Provavelmente daria para usar um CRDT de lista ou de string nos nós folha, isto é, nos blocos de texto, e combinar este CRDT de árvore nos nós estruturais, como listas e tabelas. Mas, nesse caso, seria preciso acrescentar a todas as operações um endereço bidimensional
(parent-id, index_offset_into_that_parent).[1] https://github.com/inkandswitch/peritext/issues/27
Um CRDT de texto, no fundo, é apenas um CRDT de lista que contém dados de caracteres. Portanto, elementos incorporados podem ser facilmente modelados como itens especiais de tamanho 1, isto é, nós filhos incorporados, como qualquer outro item da string. Com a abordagem correta, é possível misturar diferentes CRDTs dentro da árvore conforme necessário. Por exemplo, uma tabela dentro de um texto rico, e uma imagem em uma das células dela.
Ter de adicionar um campo
parent-crdt-ida todas as operações é uma pena, mas parece difícil de evitar. Felizmente, na maioria dos casos de uso reais, parece muito comum que operações consecutivas compartilhem o mesmo CRDT pai, então esses campos de ID provavelmente se comprimem bem com run-length encoding.Porém, como Seph comentou, operações consecutivas sob o mesmo pai podem ser comprimidas de forma eficiente, então o overhead amortizado desses IDs de pai normalmente não é grande.
Fico curioso se já existiu algum CRDT prático para aplicações com alta densidade de dados, como pixels de imagens ou modelos 3D.
Mesmo que a representação concreta do documento seja intensiva em dados, a forma de codificar as edições e operações individuais do usuário ainda pode ser pequena.
Imagine criar um editor de imagens como o Photoshop: uma imagem não comprimida de 102 milhões de pixels com profundidade de cor de 16 bits por canal, por exemplo uma foto da Fujifilm GFX100, fica em torno de 610 MB em TIFF. Representar cada pixel como um registrador separado de último escritor vence teria muito overhead, mas essa representação não corresponde de fato a preservar a intenção do usuário. As edições que o usuário faria seriam coisas como "aumentar o contraste da imagem em 15%" ou "pintar a spline [(0,0), (1500, 1500)] com o pincel Q e a cor #000". Se cada pixel for sincronizado com timestamps de Lamport, a alteração de contraste do usuário 1 será aplicada a todos os pixels exceto os pintados pelo usuário 2, e os pixels sobrepintados podem ficar estranhos.
Em vez disso, é melhor representar a intenção do usuário como uma lista de operações de edição. Isso é muito menor do que toda a grade de pixels de 102 MB. Estruturas de dados CRDT são um possível mecanismo técnico para sincronizar essa intenção do usuário, mas a estrutura deve ser escolhida de acordo com a semântica da intenção do usuário, não com o layout concreto dos dados de saída.
Ainda assim, podem surgir operações de edição que incluam grandes volumes de dados, como
"add new layer namedbgbelow layerfgwith pixelsdata:(10mb of pixels)at (1500, 1500)". Mas o overhead de sincronização desse comando de edição é muito baixo, e seu tamanho é O(1), não O(pixels) proporcional ao número de pixels dentro do comando de edição.Modelos 3D são outro problema, e não vi ferramentas colaborativas de modelagem 3D no mercado. Também não procurei ativamente.
Nunca cheguei a implementá-lo, e não tenho certeza se seria realmente prático. Ainda assim, ao menos permitiria preservar todo o histórico do documento.
Ele representa os dados com Yjs, mas, em vez de armazenar pixels brutos, armazena todo o histórico de transformações.
https://digest.browsertech.com/archive/browsertech-digest-ho...
Fico curioso se o texto foi revisado com GPT. No primeiro parágrafo, há um forte tom de ChatGPT.
This article introduces the implementation difficulties and challenges of Movable Tree CRDTs when collaboration, and how Loro implements it and sorts child nodes.