feat(tree): server-authoritative realtime tree updates
The sidebar page tree only updated on other clients when a change was made via the UI tree, in an open tab, within a ~50ms client relay window — API/MCP/ AI/import changes never propagated. Move the source of truth to the server. Server: - Enrich PageEvent with thin TreeNodeSnapshot(s) so the WS listener never reads the DB (avoids the in-transaction visibility race). insertPage fills the create snapshot from its returning() row; removePage ships only the deleted subtree ROOT (client treeModel.remove drops descendants); restorePage carries spaceId. - New PAGE_MOVED event from movePage with old/new parent + position + snapshot (generic PAGE_UPDATED stays for content/rename). - WsService.emitTreeEvent mirrors emitCommentEvent (per-space restriction gate: spaceHasRestrictions -> hasRestrictedAncestor -> broadcastToAuthorizedUsers); author NOT excluded so non-UI creators see their own page (receiver is idempotent). - WsTreeService.broadcastPageCreated/Deleted/Moved + broadcastRefetchRoot; new PageWsListener (create/delete/move/restore) registered in WsModule. Client: - Remove the client relay (emit + setTimeout(50)) from create/move/delete; keep optimistic local updates. Make the optimistic create insert id-idempotent (find-then-skip) so the now-fast server addTreeNode broadcast can't race it into a duplicate row. addTreeNode inserts by fractional position among loaded siblings (consistent order across clients). Restore uses refetchRootTreeNodeEvent (robust for subtree re-attach). Rename/icon updateOne and cross-space move realtime are deferred (commented as follow-ups). Implements docs/backlog/realtime-tree-server-authoritative.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,35 @@ export const treeModel = {
|
||||
return touched ? out : tree;
|
||||
},
|
||||
|
||||
// Position-aware insert for server-authoritative broadcasts. The server does
|
||||
// not know each receiver's local index (clients have different loaded sets and
|
||||
// the root list is paginated), so it sends the node's fractional `position`.
|
||||
// We insert among the already-loaded siblings ordered by `position` so the
|
||||
// order is consistent across clients regardless of which nodes they loaded.
|
||||
// Falls back to appending when `position` is missing.
|
||||
insertByPosition<T extends { position?: string }>(
|
||||
tree: TreeNode<T>[],
|
||||
parentId: string | null,
|
||||
node: TreeNode<T>,
|
||||
): TreeNode<T>[] {
|
||||
const index = (siblings: TreeNode<T>[]): number => {
|
||||
const pos = node.position;
|
||||
if (pos == null) return siblings.length;
|
||||
// First sibling whose position sorts after the new node's position.
|
||||
const at = siblings.findIndex(
|
||||
(s) => s.position != null && s.position > pos,
|
||||
);
|
||||
return at === -1 ? siblings.length : at;
|
||||
};
|
||||
|
||||
if (parentId === null) {
|
||||
return treeModel.insert(tree, null, node, index(tree));
|
||||
}
|
||||
const parent = treeModel.find(tree, parentId);
|
||||
const kids = (parent?.children as TreeNode<T>[] | undefined) ?? [];
|
||||
return treeModel.insert(tree, parentId, node, index(kids));
|
||||
},
|
||||
|
||||
remove<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] {
|
||||
let touched = false;
|
||||
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] => {
|
||||
|
||||
Reference in New Issue
Block a user