crdt crate

Orual a4ea50c1 54300c95

+1997 -1532
+27
Cargo.lock
··· 12127 12127 "weaver-common", 12128 12128 "weaver-editor-browser", 12129 12129 "weaver-editor-core", 12130 + "weaver-editor-crdt", 12130 12131 "weaver-embed-worker", 12131 12132 "weaver-renderer", 12132 12133 "web-sys", ··· 12235 12236 "weaver-common", 12236 12237 "weaver-renderer", 12237 12238 "web-time", 12239 + ] 12240 + 12241 + [[package]] 12242 + name = "weaver-editor-crdt" 12243 + version = "0.1.0" 12244 + dependencies = [ 12245 + "base64 0.22.1", 12246 + "console_error_panic_hook", 12247 + "futures-util", 12248 + "gloo-worker", 12249 + "jacquard", 12250 + "loro", 12251 + "n0-future 0.1.3", 12252 + "serde", 12253 + "smol_str", 12254 + "thiserror 2.0.17", 12255 + "tokio", 12256 + "tracing", 12257 + "tracing-subscriber", 12258 + "tracing-wasm", 12259 + "wasm-bindgen", 12260 + "wasm-bindgen-futures", 12261 + "weaver-api", 12262 + "weaver-common", 12263 + "weaver-editor-core", 12264 + "weaver-renderer", 12238 12265 ] 12239 12266 12240 12267 [[package]]
+1
crates/weaver-app/Cargo.toml
··· 50 50 weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } 51 51 weaver-editor-core = { path = "../weaver-editor-core" } 52 52 weaver-editor-browser = { path = "../weaver-editor-browser" } 53 + weaver-editor-crdt = { path = "../weaver-editor-crdt" } 53 54 jacquard = { workspace = true}#, features = ["streaming"] } 54 55 jacquard-lexicon = { workspace = true } 55 56 jacquard-identity = { workspace = true }
+7 -439
crates/weaver-app/public/editor_worker.js
··· 17 17 ? { register: () => {}, unregister: () => {} } 18 18 : new FinalizationRegistry(state => state.dtor(state.a, state.b)); 19 19 20 - function debugString(val) { 21 - // primitive types 22 - const type = typeof val; 23 - if (type == 'number' || type == 'boolean' || val == null) { 24 - return `${val}`; 25 - } 26 - if (type == 'string') { 27 - return `"${val}"`; 28 - } 29 - if (type == 'symbol') { 30 - const description = val.description; 31 - if (description == null) { 32 - return 'Symbol'; 33 - } else { 34 - return `Symbol(${description})`; 35 - } 36 - } 37 - if (type == 'function') { 38 - const name = val.name; 39 - if (typeof name == 'string' && name.length > 0) { 40 - return `Function(${name})`; 41 - } else { 42 - return 'Function'; 43 - } 44 - } 45 - // objects 46 - if (Array.isArray(val)) { 47 - const length = val.length; 48 - let debug = '['; 49 - if (length > 0) { 50 - debug += debugString(val[0]); 51 - } 52 - for(let i = 1; i < length; i++) { 53 - debug += ', ' + debugString(val[i]); 54 - } 55 - debug += ']'; 56 - return debug; 57 - } 58 - // Test for built-in 59 - const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); 60 - let className; 61 - if (builtInMatches && builtInMatches.length > 1) { 62 - className = builtInMatches[1]; 63 - } else { 64 - // Failed to match the standard '[object ClassName]' 65 - return toString.call(val); 66 - } 67 - if (className == 'Object') { 68 - // we're a user defined class or Object 69 - // JSON.stringify avoids problems with cycles, and is generally much 70 - // easier than looping through ownProperties of `val`. 71 - try { 72 - return 'Object(' + JSON.stringify(val) + ')'; 73 - } catch (_) { 74 - return 'Object'; 75 - } 76 - } 77 - // errors 78 - if (val instanceof Error) { 79 - return `${val.name}: ${val.message}\n${val.stack}`; 80 - } 81 - // TODO we could test for more things here, like `Set`s and `Map`s. 82 - return className; 83 - } 84 - 85 20 function getArrayU8FromWasm0(ptr, len) { 86 21 ptr = ptr >>> 0; 87 22 return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); ··· 232 167 233 168 let WASM_VECTOR_LEN = 0; 234 169 235 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 236 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 237 - } 238 - 239 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { 240 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2); 241 - } 242 - 243 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1) { 244 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1); 170 + function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 171 + wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 245 172 } 246 173 247 174 function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2) { 248 175 wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2); 249 176 } 250 177 251 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1) { 252 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1); 253 - } 254 - 255 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2) { 256 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); 257 - } 258 - 259 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______2_(arg0, arg1) { 260 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______2_(arg0, arg1); 261 - } 262 - 263 178 function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue__wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2, arg3) { 264 179 wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue__wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2, arg3); 265 180 } 266 - 267 - const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"]; 268 181 269 182 const __wbindgen_enum_ReadableStreamType = ["bytes"]; 270 - 271 - const __wbindgen_enum_RequestCache = ["default", "no-store", "reload", "no-cache", "force-cache", "only-if-cached"]; 272 - 273 - const __wbindgen_enum_RequestCredentials = ["omit", "same-origin", "include"]; 274 - 275 - const __wbindgen_enum_RequestMode = ["same-origin", "no-cors", "cors", "navigate"]; 276 183 277 184 const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') 278 185 ? { register: () => {}, unregister: () => {} } ··· 454 361 function __wbg_get_imports() { 455 362 const imports = {}; 456 363 imports.wbg = {}; 457 - imports.wbg.__wbg___wbindgen_boolean_get_dea25b33882b895b = function(arg0) { 458 - const v = arg0; 459 - const ret = typeof(v) === 'boolean' ? v : undefined; 460 - return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; 461 - }; 462 - imports.wbg.__wbg___wbindgen_debug_string_adfb662ae34724b6 = function(arg0, arg1) { 463 - const ret = debugString(arg1); 464 - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 465 - const len1 = WASM_VECTOR_LEN; 466 - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 467 - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 468 - }; 469 364 imports.wbg.__wbg___wbindgen_is_function_8d400b8b1af978cd = function(arg0) { 470 365 const ret = typeof(arg0) === 'function'; 471 366 return ret; ··· 483 378 const ret = arg0 === undefined; 484 379 return ret; 485 380 }; 486 - imports.wbg.__wbg___wbindgen_string_get_a2a31e16edf96e42 = function(arg0, arg1) { 487 - const obj = arg1; 488 - const ret = typeof(obj) === 'string' ? obj : undefined; 489 - var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 490 - var len1 = WASM_VECTOR_LEN; 491 - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 492 - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 493 - }; 494 381 imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) { 495 382 throw new Error(getStringFromWasm0(arg0, arg1)); 496 383 }; 497 384 imports.wbg.__wbg__wbg_cb_unref_87dfb5aaa0cbcea7 = function(arg0) { 498 385 arg0._wbg_cb_unref(); 499 386 }; 500 - imports.wbg.__wbg_abort_07646c894ebbf2bd = function(arg0) { 501 - arg0.abort(); 502 - }; 503 - imports.wbg.__wbg_abort_399ecbcfd6ef3c8e = function(arg0, arg1) { 504 - arg0.abort(arg1); 505 - }; 506 - imports.wbg.__wbg_addEventListener_e792423147a80626 = function() { return handleError(function (arg0, arg1, arg2, arg3) { 507 - arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); 508 - }, arguments) }; 509 - imports.wbg.__wbg_append_c5cbdf46455cc776 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { 510 - arg0.append(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); 511 - }, arguments) }; 512 - imports.wbg.__wbg_arrayBuffer_c04af4fce566092d = function() { return handleError(function (arg0) { 513 - const ret = arg0.arrayBuffer(); 514 - return ret; 515 - }, arguments) }; 516 - imports.wbg.__wbg_body_947b901c33f7fe32 = function(arg0) { 517 - const ret = arg0.body; 518 - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 519 - }; 520 387 imports.wbg.__wbg_buffer_6cb2fecb1f253d71 = function(arg0) { 521 388 const ret = arg0.buffer; 522 389 return ret; ··· 541 408 const ret = arg0.call(arg1); 542 409 return ret; 543 410 }, arguments) }; 544 - imports.wbg.__wbg_cancel_a65cf45dca50ba4c = function(arg0) { 545 - const ret = arg0.cancel(); 546 - return ret; 547 - }; 548 - imports.wbg.__wbg_catch_b9db41d97d42bd02 = function(arg0, arg1) { 549 - const ret = arg0.catch(arg1); 550 - return ret; 551 - }; 552 - imports.wbg.__wbg_clearTimeout_b716ecb44bea14ed = function(arg0) { 553 - const ret = clearTimeout(arg0); 554 - return ret; 555 - }; 556 - imports.wbg.__wbg_clearTimeout_f7a6c75a3d228439 = function() { return handleError(function (arg0, arg1) { 557 - arg0.clearTimeout(arg1); 558 - }, arguments) }; 559 411 imports.wbg.__wbg_close_0af5661bf3d335f2 = function() { return handleError(function (arg0) { 560 412 arg0.close(); 561 413 }, arguments) }; 562 414 imports.wbg.__wbg_close_0b472ca2d13f54f7 = function(arg0) { 563 415 arg0.close(); 564 416 }; 565 - imports.wbg.__wbg_close_1db3952de1b5b1cf = function() { return handleError(function (arg0) { 566 - arg0.close(); 567 - }, arguments) }; 568 417 imports.wbg.__wbg_close_3ec111e7b23d94d8 = function() { return handleError(function (arg0) { 569 418 arg0.close(); 570 419 }, arguments) }; 571 - imports.wbg.__wbg_code_85a811fe6ca962be = function(arg0) { 572 - const ret = arg0.code; 573 - return ret; 574 - }; 575 - imports.wbg.__wbg_code_c2a85f2863ec11b3 = function(arg0) { 576 - const ret = arg0.code; 577 - return ret; 578 - }; 579 420 imports.wbg.__wbg_crypto_574e78ad8b13b65f = function(arg0) { 580 421 const ret = arg0.crypto; 581 422 return ret; 582 423 }; 583 424 imports.wbg.__wbg_data_8bf4ae669a78a688 = function(arg0) { 584 425 const ret = arg0.data; 585 - return ret; 586 - }; 587 - imports.wbg.__wbg_done_62ea16af4ce34b24 = function(arg0) { 588 - const ret = arg0.done; 589 426 return ret; 590 427 }; 591 428 imports.wbg.__wbg_enqueue_a7e6b1ee87963aad = function() { return handleError(function (arg0, arg1) { ··· 602 439 wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 603 440 } 604 441 }; 605 - imports.wbg.__wbg_fetch_7fb7602a1bf647ec = function(arg0) { 606 - const ret = fetch(arg0); 607 - return ret; 608 - }; 609 - imports.wbg.__wbg_fetch_90447c28cc0b095e = function(arg0, arg1) { 610 - const ret = arg0.fetch(arg1); 611 - return ret; 612 - }; 613 - imports.wbg.__wbg_getRandomValues_1c61fac11405ffdc = function() { return handleError(function (arg0, arg1) { 614 - globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); 615 - }, arguments) }; 616 442 imports.wbg.__wbg_getRandomValues_b8f5dbd5f3995a9e = function() { return handleError(function (arg0, arg1) { 617 443 arg0.getRandomValues(arg1); 618 444 }, arguments) }; 619 - imports.wbg.__wbg_getReader_48e00749fe3f6089 = function() { return handleError(function (arg0) { 620 - const ret = arg0.getReader(); 621 - return ret; 622 - }, arguments) }; 623 - imports.wbg.__wbg_get_af9dab7e9603ea93 = function() { return handleError(function (arg0, arg1) { 624 - const ret = Reflect.get(arg0, arg1); 625 - return ret; 626 - }, arguments) }; 627 - imports.wbg.__wbg_get_done_f98a6e62c4e18fb9 = function(arg0) { 628 - const ret = arg0.done; 629 - return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; 630 - }; 631 - imports.wbg.__wbg_get_value_63e39884ef11812e = function(arg0) { 632 - const ret = arg0.value; 633 - return ret; 634 - }; 635 - imports.wbg.__wbg_has_0e670569d65d3a45 = function() { return handleError(function (arg0, arg1) { 636 - const ret = Reflect.has(arg0, arg1); 637 - return ret; 638 - }, arguments) }; 639 - imports.wbg.__wbg_headers_654c30e1bcccc552 = function(arg0) { 640 - const ret = arg0.headers; 641 - return ret; 642 - }; 643 - imports.wbg.__wbg_instanceof_ArrayBuffer_f3320d2419cd0355 = function(arg0) { 644 - let result; 645 - try { 646 - result = arg0 instanceof ArrayBuffer; 647 - } catch (_) { 648 - result = false; 649 - } 650 - const ret = result; 651 - return ret; 652 - }; 653 - imports.wbg.__wbg_instanceof_Blob_e9c51ce33a4b6181 = function(arg0) { 654 - let result; 655 - try { 656 - result = arg0 instanceof Blob; 657 - } catch (_) { 658 - result = false; 659 - } 660 - const ret = result; 661 - return ret; 662 - }; 663 - imports.wbg.__wbg_instanceof_Response_cd74d1c2ac92cb0b = function(arg0) { 664 - let result; 665 - try { 666 - result = arg0 instanceof Response; 667 - } catch (_) { 668 - result = false; 669 - } 670 - const ret = result; 671 - return ret; 672 - }; 673 445 imports.wbg.__wbg_instanceof_Window_b5cf7783caa68180 = function(arg0) { 674 446 let result; 675 447 try { ··· 678 450 result = false; 679 451 } 680 452 const ret = result; 681 - return ret; 682 - }; 683 - imports.wbg.__wbg_iterator_27b7c8b35ab3e86b = function() { 684 - const ret = Symbol.iterator; 685 453 return ret; 686 454 }; 687 455 imports.wbg.__wbg_length_22ac23eaec9d8053 = function(arg0) { ··· 729 497 wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); 730 498 } 731 499 }, arguments) }; 732 - imports.wbg.__wbg_message_a4e9a39ee8f92b17 = function(arg0, arg1) { 733 - const ret = arg1.message; 734 - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 735 - const len1 = WASM_VECTOR_LEN; 736 - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 737 - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 738 - }; 739 500 imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) { 740 501 const ret = arg0.msCrypto; 741 502 return ret; 742 503 }; 743 - imports.wbg.__wbg_new_1ba21ce319a06297 = function() { 744 - const ret = new Object(); 745 - return ret; 746 - }; 747 - imports.wbg.__wbg_new_25f239778d6112b9 = function() { 748 - const ret = new Array(); 749 - return ret; 750 - }; 751 - imports.wbg.__wbg_new_3c79b3bb1b32b7d3 = function() { return handleError(function () { 752 - const ret = new Headers(); 753 - return ret; 754 - }, arguments) }; 755 - imports.wbg.__wbg_new_6421f6084cc5bc5a = function(arg0) { 756 - const ret = new Uint8Array(arg0); 757 - return ret; 758 - }; 759 - imports.wbg.__wbg_new_7c30d1f874652e62 = function() { return handleError(function (arg0, arg1) { 760 - const ret = new WebSocket(getStringFromWasm0(arg0, arg1)); 761 - return ret; 762 - }, arguments) }; 763 - imports.wbg.__wbg_new_881a222c65f168fc = function() { return handleError(function () { 764 - const ret = new AbortController(); 765 - return ret; 766 - }, arguments) }; 767 504 imports.wbg.__wbg_new_8a6f238a6ece86ea = function() { 768 505 const ret = new Error(); 769 506 return ret; ··· 806 543 const ret = new Uint8Array(arg0 >>> 0); 807 544 return ret; 808 545 }; 809 - imports.wbg.__wbg_new_with_str_and_init_c5748f76f5108934 = function() { return handleError(function (arg0, arg1, arg2) { 810 - const ret = new Request(getStringFromWasm0(arg0, arg1), arg2); 811 - return ret; 812 - }, arguments) }; 813 - imports.wbg.__wbg_new_with_str_sequence_073466a4a5387941 = function() { return handleError(function (arg0, arg1, arg2) { 814 - const ret = new WebSocket(getStringFromWasm0(arg0, arg1), arg2); 815 - return ret; 816 - }, arguments) }; 817 - imports.wbg.__wbg_next_138a17bbf04e926c = function(arg0) { 818 - const ret = arg0.next; 819 - return ret; 820 - }; 821 - imports.wbg.__wbg_next_3cfe5c0fe2a4cc53 = function() { return handleError(function (arg0) { 822 - const ret = arg0.next(); 823 - return ret; 824 - }, arguments) }; 825 546 imports.wbg.__wbg_node_905d3e251edff8a2 = function(arg0) { 826 547 const ret = arg0.node; 827 548 return ret; 828 549 }; 829 - imports.wbg.__wbg_now_2c95c9de01293173 = function(arg0) { 830 - const ret = arg0.now(); 831 - return ret; 832 - }; 833 - imports.wbg.__wbg_now_69d776cd24f5215b = function() { 834 - const ret = Date.now(); 835 - return ret; 836 - }; 837 550 imports.wbg.__wbg_now_8cf15d6e317793e1 = function(arg0) { 838 551 const ret = arg0.now(); 839 552 return ret; ··· 842 555 const ret = Date.now(); 843 556 return ret; 844 557 }; 845 - imports.wbg.__wbg_performance_7a3ffd0b17f663ad = function(arg0) { 846 - const ret = arg0.performance; 847 - return ret; 848 - }; 849 558 imports.wbg.__wbg_performance_c77a440eff2efd9b = function(arg0) { 850 559 const ret = arg0.performance; 851 560 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); ··· 860 569 imports.wbg.__wbg_prototypesetcall_dfe9b766cdc1f1fd = function(arg0, arg1, arg2) { 861 570 Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); 862 571 }; 863 - imports.wbg.__wbg_push_7d9be8f38fc13975 = function(arg0, arg1) { 864 - const ret = arg0.push(arg1); 865 - return ret; 866 - }; 867 572 imports.wbg.__wbg_queueMicrotask_9b549dfce8865860 = function(arg0) { 868 573 const ret = arg0.queueMicrotask; 869 574 return ret; ··· 874 579 imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) { 875 580 arg0.randomFillSync(arg1); 876 581 }, arguments) }; 877 - imports.wbg.__wbg_read_39c4b35efcd03c25 = function(arg0) { 878 - const ret = arg0.read(); 879 - return ret; 880 - }; 881 - imports.wbg.__wbg_readyState_9d0976dcad561aa9 = function(arg0) { 882 - const ret = arg0.readyState; 883 - return ret; 884 - }; 885 - imports.wbg.__wbg_reason_d4eb9e40592438c2 = function(arg0, arg1) { 886 - const ret = arg1.reason; 887 - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 888 - const len1 = WASM_VECTOR_LEN; 889 - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 890 - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 891 - }; 892 - imports.wbg.__wbg_releaseLock_a5912f590b185180 = function(arg0) { 893 - arg0.releaseLock(); 894 - }; 895 - imports.wbg.__wbg_removeEventListener_54bf92f4a849bd7d = function() { return handleError(function (arg0, arg1, arg2, arg3) { 896 - arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); 897 - }, arguments) }; 898 582 imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () { 899 583 const ret = module.require; 900 584 return ret; ··· 906 590 imports.wbg.__wbg_respond_9f7fc54636c4a3af = function() { return handleError(function (arg0, arg1) { 907 591 arg0.respond(arg1 >>> 0); 908 592 }, arguments) }; 909 - imports.wbg.__wbg_send_7cc36bb628044281 = function() { return handleError(function (arg0, arg1, arg2) { 910 - arg0.send(getStringFromWasm0(arg1, arg2)); 911 - }, arguments) }; 912 - imports.wbg.__wbg_send_ea59e150ab5ebe08 = function() { return handleError(function (arg0, arg1, arg2) { 913 - arg0.send(getArrayU8FromWasm0(arg1, arg2)); 914 - }, arguments) }; 915 - imports.wbg.__wbg_setTimeout_4302406184dcc5be = function(arg0, arg1) { 916 - const ret = setTimeout(arg0, arg1); 917 - return ret; 918 - }; 919 - imports.wbg.__wbg_setTimeout_ceaa8eadc563d26e = function() { return handleError(function (arg0, arg1, arg2) { 920 - const ret = arg0.setTimeout(arg1, arg2); 921 - return ret; 922 - }, arguments) }; 923 593 imports.wbg.__wbg_set_169e13b608078b7b = function(arg0, arg1, arg2) { 924 594 arg0.set(getArrayU8FromWasm0(arg1, arg2)); 925 595 }; 926 - imports.wbg.__wbg_set_binaryType_73e8c75df97825f8 = function(arg0, arg1) { 927 - arg0.binaryType = __wbindgen_enum_BinaryType[arg1]; 928 - }; 929 - imports.wbg.__wbg_set_body_8e743242d6076a4f = function(arg0, arg1) { 930 - arg0.body = arg1; 931 - }; 932 - imports.wbg.__wbg_set_cache_0e437c7c8e838b9b = function(arg0, arg1) { 933 - arg0.cache = __wbindgen_enum_RequestCache[arg1]; 934 - }; 935 - imports.wbg.__wbg_set_credentials_55ae7c3c106fd5be = function(arg0, arg1) { 936 - arg0.credentials = __wbindgen_enum_RequestCredentials[arg1]; 937 - }; 938 - imports.wbg.__wbg_set_handle_event_14baa3949ef6909d = function(arg0, arg1) { 939 - arg0.handleEvent = arg1; 940 - }; 941 - imports.wbg.__wbg_set_headers_5671cf088e114d2b = function(arg0, arg1) { 942 - arg0.headers = arg1; 943 - }; 944 - imports.wbg.__wbg_set_method_76c69e41b3570627 = function(arg0, arg1, arg2) { 945 - arg0.method = getStringFromWasm0(arg1, arg2); 946 - }; 947 - imports.wbg.__wbg_set_mode_611016a6818fc690 = function(arg0, arg1) { 948 - arg0.mode = __wbindgen_enum_RequestMode[arg1]; 949 - }; 950 - imports.wbg.__wbg_set_onclose_032729b3d7ed7a9e = function(arg0, arg1) { 951 - arg0.onclose = arg1; 952 - }; 953 - imports.wbg.__wbg_set_onerror_7819daa6af176ddb = function(arg0, arg1) { 954 - arg0.onerror = arg1; 955 - }; 956 596 imports.wbg.__wbg_set_onmessage_5fe29d0fb54cb575 = function(arg0, arg1) { 957 597 arg0.onmessage = arg1; 958 - }; 959 - imports.wbg.__wbg_set_onmessage_71321d0bed69856c = function(arg0, arg1) { 960 - arg0.onmessage = arg1; 961 - }; 962 - imports.wbg.__wbg_set_onopen_6d4abedb27ba5656 = function(arg0, arg1) { 963 - arg0.onopen = arg1; 964 - }; 965 - imports.wbg.__wbg_set_signal_e89be862d0091009 = function(arg0, arg1) { 966 - arg0.signal = arg1; 967 - }; 968 - imports.wbg.__wbg_signal_3c14fbdc89694b39 = function(arg0) { 969 - const ret = arg0.signal; 970 - return ret; 971 598 }; 972 599 imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) { 973 600 const ret = arg1.stack; ··· 992 619 const ret = typeof window === 'undefined' ? null : window; 993 620 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 994 621 }; 995 - imports.wbg.__wbg_status_9bfc680efca4bdfd = function(arg0) { 996 - const ret = arg0.status; 997 - return ret; 998 - }; 999 - imports.wbg.__wbg_stringify_655a6390e1f5eb6b = function() { return handleError(function (arg0) { 1000 - const ret = JSON.stringify(arg0); 1001 - return ret; 1002 - }, arguments) }; 1003 622 imports.wbg.__wbg_subarray_845f2f5bce7d061a = function(arg0, arg1, arg2) { 1004 623 const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); 1005 624 return ret; 1006 625 }; 1007 - imports.wbg.__wbg_then_429f7caf1026411d = function(arg0, arg1, arg2) { 1008 - const ret = arg0.then(arg1, arg2); 1009 - return ret; 1010 - }; 1011 626 imports.wbg.__wbg_then_4f95312d68691235 = function(arg0, arg1) { 1012 627 const ret = arg0.then(arg1); 1013 628 return ret; 1014 629 }; 1015 - imports.wbg.__wbg_url_b6d11838a4f95198 = function(arg0, arg1) { 1016 - const ret = arg1.url; 1017 - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 1018 - const len1 = WASM_VECTOR_LEN; 1019 - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 1020 - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 1021 - }; 1022 - imports.wbg.__wbg_url_df28eef824b04410 = function(arg0, arg1) { 1023 - const ret = arg1.url; 1024 - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 1025 - const len1 = WASM_VECTOR_LEN; 1026 - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 1027 - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 1028 - }; 1029 - imports.wbg.__wbg_value_57b7b035e117f7ee = function(arg0) { 1030 - const ret = arg0.value; 1031 - return ret; 1032 - }; 1033 630 imports.wbg.__wbg_versions_c01dfd4722a88165 = function(arg0) { 1034 631 const ret = arg0.versions; 1035 632 return ret; ··· 1038 635 const ret = arg0.view; 1039 636 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 1040 637 }; 1041 - imports.wbg.__wbg_wasClean_4154a2d59fdb4dd7 = function(arg0) { 1042 - const ret = arg0.wasClean; 1043 - return ret; 1044 - }; 1045 - imports.wbg.__wbindgen_cast_1a1a598ea8f7755c = function(arg0, arg1) { 1046 - // Cast intrinsic for `Closure(Closure { dtor_idx: 206, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 207, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 1047 - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____); 1048 - return ret; 1049 - }; 1050 638 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 1051 639 // Cast intrinsic for `Ref(String) -> Externref`. 1052 640 const ret = getStringFromWasm0(arg0, arg1); 1053 641 return ret; 1054 642 }; 1055 - imports.wbg.__wbindgen_cast_26e84dab8c070084 = function(arg0, arg1) { 1056 - // Cast intrinsic for `Closure(Closure { dtor_idx: 3468, function: Function { arguments: [], shim_idx: 3469, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 1057 - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______); 1058 - return ret; 1059 - }; 1060 - imports.wbg.__wbindgen_cast_6ab2139490da380b = function(arg0, arg1) { 1061 - // Cast intrinsic for `Closure(Closure { dtor_idx: 5276, function: Function { arguments: [Externref], shim_idx: 5277, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 643 + imports.wbg.__wbindgen_cast_5695985a3f63a81e = function(arg0, arg1) { 644 + // Cast intrinsic for `Closure(Closure { dtor_idx: 956, function: Function { arguments: [Externref], shim_idx: 957, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1062 645 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_ff1081b5ed996cf4___JsValue____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____); 1063 646 return ret; 1064 647 }; 1065 - imports.wbg.__wbindgen_cast_6f3627d5a130f4e7 = function(arg0, arg1) { 1066 - // Cast intrinsic for `Closure(Closure { dtor_idx: 3966, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 3967, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1067 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent______1_); 1068 - return ret; 1069 - }; 1070 - imports.wbg.__wbindgen_cast_b4adb87c8017eb14 = function(arg0, arg1) { 1071 - // Cast intrinsic for `Closure(Closure { dtor_idx: 5096, function: Function { arguments: [], shim_idx: 5097, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1072 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______2_); 1073 - return ret; 1074 - }; 1075 - imports.wbg.__wbindgen_cast_bebec89f3b1bff9e = function(arg0, arg1) { 1076 - // Cast intrinsic for `Closure(Closure { dtor_idx: 4983, function: Function { arguments: [], shim_idx: 4984, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1077 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_); 648 + imports.wbg.__wbindgen_cast_8e598183f7f23db7 = function(arg0, arg1) { 649 + // Cast intrinsic for `Closure(Closure { dtor_idx: 128, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 129, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 650 + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____); 1078 651 return ret; 1079 652 }; 1080 653 imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 1081 654 // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 1082 655 const ret = getArrayU8FromWasm0(arg0, arg1); 1083 - return ret; 1084 - }; 1085 - imports.wbg.__wbindgen_cast_ed928a0ca040cecc = function(arg0, arg1) { 1086 - // Cast intrinsic for `Closure(Closure { dtor_idx: 3415, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 3416, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1087 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent_____); 1088 656 return ret; 1089 657 }; 1090 658 imports.wbg.__wbindgen_init_externref_table = function() {
+21 -21
crates/weaver-app/public/embed_worker.js
··· 232 232 233 233 let WASM_VECTOR_LEN = 0; 234 234 235 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1) { 236 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1); 237 - } 238 - 239 - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 240 - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 241 - } 242 - 243 235 function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1) { 244 236 wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1); 245 237 } 246 238 247 239 function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2) { 248 240 wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2); 241 + } 242 + 243 + function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 244 + wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 245 + } 246 + 247 + function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1) { 248 + wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1); 249 249 } 250 250 251 251 function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue__wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2, arg3) { ··· 796 796 const ret = arg0.view; 797 797 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 798 798 }; 799 - imports.wbg.__wbindgen_cast_02bff2294fb8559b = function(arg0, arg1) { 800 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1669, function: Function { arguments: [], shim_idx: 1670, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 801 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_); 802 - return ret; 803 - }; 804 - imports.wbg.__wbindgen_cast_04f3c1b8909cc857 = function(arg0, arg1) { 805 - // Cast intrinsic for `Closure(Closure { dtor_idx: 636, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 637, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 806 - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____); 799 + imports.wbg.__wbindgen_cast_15fea7dce706ecd8 = function(arg0, arg1) { 800 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1458, function: Function { arguments: [], shim_idx: 1459, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 801 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______); 807 802 return ret; 808 803 }; 809 804 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { ··· 811 806 const ret = getStringFromWasm0(arg0, arg1); 812 807 return ret; 813 808 }; 814 - imports.wbg.__wbindgen_cast_d4c1ebc0303a638a = function(arg0, arg1) { 815 - // Cast intrinsic for `Closure(Closure { dtor_idx: 2491, function: Function { arguments: [Externref], shim_idx: 2492, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 809 + imports.wbg.__wbindgen_cast_27edd9ec229cd76d = function(arg0, arg1) { 810 + // Cast intrinsic for `Closure(Closure { dtor_idx: 64, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 65, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 811 + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____); 812 + return ret; 813 + }; 814 + imports.wbg.__wbindgen_cast_5ebf193ba0482380 = function(arg0, arg1) { 815 + // Cast intrinsic for `Closure(Closure { dtor_idx: 2543, function: Function { arguments: [Externref], shim_idx: 2544, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 816 816 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_ff1081b5ed996cf4___JsValue____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____); 817 817 return ret; 818 818 }; 819 - imports.wbg.__wbindgen_cast_d70ef6992e943fc2 = function(arg0, arg1) { 820 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1406, function: Function { arguments: [], shim_idx: 1407, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 821 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______); 819 + imports.wbg.__wbindgen_cast_77c2bc8daf69132e = function(arg0, arg1) { 820 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1721, function: Function { arguments: [], shim_idx: 1722, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 821 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_); 822 822 return ret; 823 823 }; 824 824 imports.wbg.__wbindgen_init_externref_table = function() {
+1 -3
crates/weaver-app/src/components/editor/collab.rs
··· 83 83 pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { 84 84 #[cfg(target_arch = "wasm32")] 85 85 { 86 - use super::worker::{WorkerInput, WorkerOutput}; 87 86 use crate::collab_context::CollabDebugState; 88 87 use crate::fetch::Fetcher; 89 88 use futures_util::stream::SplitSink; ··· 92 91 use gloo_worker::reactor::ReactorBridge; 93 92 use jacquard::IntoStatic; 94 93 use weaver_common::WeaverExt; 95 - 96 - use super::worker::EditorReactor; 94 + use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; 97 95 98 96 let fetcher = use_context::<Fetcher>(); 99 97
+3 -4
crates/weaver-app/src/components/editor/component.rs
··· 501 501 // Background fetch for AT embeds via worker 502 502 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 503 503 { 504 - use super::worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; 504 + use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; 505 505 use dioxus::prelude::Writable; 506 506 use gloo_worker::Spawnable; 507 507 ··· 731 731 // Worker-based autosave (offloads export + encode to worker thread) 732 732 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 733 733 { 734 - use super::worker::{EditorReactor, WorkerInput, WorkerOutput}; 734 + use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; 735 735 use gloo_storage::Storage; 736 736 use gloo_worker::Spawnable; 737 737 use gloo_worker::reactor::ReactorBridge; ··· 964 964 tracing::debug!(input_type = %input_type_str, "beforeinput"); 965 965 966 966 let plat = platform::platform(); 967 - let input_type = InputType::from_str(&input_type_str); 967 + let input_type = weaver_editor_browser::parse_browser_input_type(&input_type_str); 968 968 let is_composing = evt.is_composing(); 969 969 970 970 // Get target range from the event if available ··· 1444 1444 let _ = crate::components::editor::cursor::restore_cursor_position( 1445 1445 offset, 1446 1446 &map, 1447 - editor_id, 1448 1447 None, 1449 1448 ); 1450 1449 return;
+43
crates/weaver-app/src/components/editor/document.rs
··· 1170 1170 false 1171 1171 } 1172 1172 } 1173 + 1174 + impl weaver_editor_crdt::CrdtDocument for EditorDocument { 1175 + fn export_snapshot(&self) -> Vec<u8> { 1176 + self.export_snapshot() 1177 + } 1178 + 1179 + fn export_updates_since_sync(&self) -> Option<Vec<u8>> { 1180 + self.export_updates_since_sync() 1181 + } 1182 + 1183 + fn import(&mut self, data: &[u8]) -> Result<(), weaver_editor_crdt::CrdtError> { 1184 + self.import_updates(data) 1185 + .map_err(|e| weaver_editor_crdt::CrdtError::Import(e.to_string())) 1186 + } 1187 + 1188 + fn version(&self) -> VersionVector { 1189 + self.version_vector() 1190 + } 1191 + 1192 + fn edit_root(&self) -> Option<StrongRef<'static>> { 1193 + self.edit_root() 1194 + } 1195 + 1196 + fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) { 1197 + self.set_edit_root(root); 1198 + } 1199 + 1200 + fn last_diff(&self) -> Option<StrongRef<'static>> { 1201 + self.last_diff() 1202 + } 1203 + 1204 + fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) { 1205 + self.set_last_diff(diff); 1206 + } 1207 + 1208 + fn mark_synced(&mut self) { 1209 + self.mark_synced(); 1210 + } 1211 + 1212 + fn has_unsynced_changes(&self) -> bool { 1213 + self.has_unsynced_changes() 1214 + } 1215 + }
+4 -5
crates/weaver-app/src/components/editor/dom_sync.rs
··· 213 213 let is_cursor_para = 214 214 new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end; 215 215 216 - if let Some(existing_elem) = old_elements.remove(para_id) { 216 + if let Some(existing_elem) = old_elements.remove(para_id.as_str()) { 217 217 // Element exists - check if it needs updating 218 218 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default(); 219 219 let needs_update = force || old_hash != new_hash; ··· 227 227 228 228 if !at_correct_position { 229 229 tracing::warn!( 230 - para_id, 230 + para_id = %para_id, 231 231 is_cursor_para, 232 232 "update_paragraph_dom: element not at correct position, moving" 233 233 ); ··· 286 286 287 287 if should_skip_cursor_update { 288 288 tracing::trace!( 289 - para_id, 289 + para_id = %para_id, 290 290 "update_paragraph_dom: skipping cursor para innerHTML (syntax unchanged, DOM verified)" 291 291 ); 292 292 // Update hash - browser native editing has the correct content ··· 318 318 { 319 319 let elapsed_ms = end_time - start_time; 320 320 tracing::debug!( 321 - para_id, 321 + para_id = %para_id, 322 322 is_cursor_para, 323 323 elapsed_ms, 324 324 html_len = new_para.html.len(), ··· 335 335 if let Err(e) = restore_cursor_position( 336 336 cursor_offset, 337 337 &new_para.offset_map, 338 - editor_id, 339 338 None, 340 339 ) { 341 340 tracing::warn!("Synchronous cursor restore failed: {:?}", e);
+3 -4
crates/weaver-app/src/components/editor/mod.rs
··· 24 24 mod sync; 25 25 mod toolbar; 26 26 mod visibility; 27 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 28 - mod worker; 29 27 mod writer; 30 28 31 29 #[cfg(test)] ··· 96 94 #[allow(unused_imports)] 97 95 pub use log_buffer::LogCaptureLayer; 98 96 99 - // Worker - EditorReactor stays local, EmbedWorker comes from weaver-embed-worker 97 + // Worker types from weaver-editor-crdt 100 98 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 101 - pub use worker::{EditorReactor, WorkerInput, WorkerOutput}; 99 + pub use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; 100 + // Embed worker from weaver-embed-worker 102 101 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 103 102 pub use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; 104 103
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 13 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello 🎉 world</p>\n" 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello 🎉 world</p>" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 26 11 - html: "<ul>\n<li data-node-id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\" spellcheck=\"false\">- </span>Item 1\n</li>\n<li data-node-id=\"p-0-n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"11\" spellcheck=\"false\">- </span>Item 2\n</li>\n<li data-node-id=\"p-0-n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"18\" data-char-end=\"20\" spellcheck=\"false\">- </span>Item 3</li>\n</ul>\n" 11 + html: "<ul><li data-node-id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\" spellcheck=\"false\">- </span>Item 1\n</li><li data-node-id=\"p-0-n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"11\" spellcheck=\"false\">- </span>Item 2\n</li><li data-node-id=\"p-0-n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"18\" data-char-end=\"20\" spellcheck=\"false\">- </span>Item 3</li></ul>" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+73 -1003
crates/weaver-app/src/components/editor/sync.rs
··· 1 1 //! PDS synchronization for editor edit state. 2 2 //! 3 - //! This module handles syncing the editor's Loro CRDT document to AT Protocol 4 - //! edit records (`sh.weaver.edit.root` and `sh.weaver.edit.diff`). 5 - //! 6 - //! ## Edit State Structure 7 - //! 8 - //! - `sh.weaver.edit.root`: The starting point for an edit session, containing 9 - //! a full Loro snapshot and a reference to the entry being edited. 10 - //! - `sh.weaver.edit.diff`: Incremental updates since the root (or previous diff), 11 - //! containing only the Loro delta bytes. 12 - //! 13 - //! ## Sync Flow 14 - //! 15 - //! 1. **First sync**: Create a root record with a full snapshot 16 - //! 2. **Subsequent syncs**: Create diff records with deltas since last sync 17 - //! 3. **Loading**: Find root via constellation backlinks, fetch all diffs, apply in order 3 + //! This module provides app-specific sync functionality built on top of 4 + //! `weaver_editor_crdt::sync`. It adds: 5 + //! - Fetcher-based API (wrapping the generic client) 6 + //! - Embed prefetching and blob caching 7 + //! - localStorage integration for document loading 8 + //! - Dioxus UI components for sync status 18 9 19 - use std::collections::BTreeMap; 10 + use std::collections::HashMap; 20 11 21 12 use super::document::{EditorDocument, LoadedDocState}; 22 13 use crate::fetch::Fetcher; 23 - use jacquard::bytes::Bytes; 24 - use jacquard::cowstr::ToCowStr; 14 + use jacquard::IntoStatic; 25 15 use jacquard::prelude::*; 26 - use jacquard::smol_str::format_smolstr; 27 - use jacquard::types::blob::MimeType; 28 - #[allow(unused_imports)] 29 - use jacquard::types::collection::Collection; 30 16 use jacquard::types::ident::AtIdentifier; 31 - use jacquard::types::recordkey::RecordKey; 32 - use jacquard::types::string::{AtUri, Cid, Did, Nsid}; 33 - use jacquard::types::tid::Ticker; 34 - use jacquard::types::uri::Uri; 35 - use jacquard::url::Url; 36 - use jacquard::{CowStr, IntoStatic, to_data}; 17 + use jacquard::types::string::{AtUri, Cid, Did}; 37 18 use loro::LoroDoc; 38 19 use loro::ToJson; 39 - use weaver_api::com_atproto::repo::create_record::CreateRecord; 40 20 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 41 - use weaver_api::com_atproto::sync::get_blob::GetBlob; 42 - use weaver_api::sh_weaver::edit::diff::Diff; 43 21 use weaver_api::sh_weaver::edit::draft::Draft; 44 - use weaver_api::sh_weaver::edit::root::Root; 45 - use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; 46 - use weaver_common::constellation::{GetBacklinksQuery, RecordId}; 47 - #[allow(unused_imports)] 22 + use weaver_api::sh_weaver::edit::{DocRef, DocRefValue}; 48 23 use weaver_common::{WeaverError, WeaverExt}; 49 24 50 - const ROOT_NSID: &str = "sh.weaver.edit.root"; 51 - const DIFF_NSID: &str = "sh.weaver.edit.diff"; 52 - const DRAFT_NSID: &str = "sh.weaver.edit.draft"; 53 - const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 25 + // Re-export crdt sync types for convenience. 26 + pub use weaver_editor_crdt::{ 27 + CreateRootResult, PdsEditState, RemoteDraft, SyncResult, build_draft_uri, find_all_edit_roots, 28 + find_diffs_for_root, find_edit_root_for_draft, list_drafts, load_all_edit_states, 29 + load_edit_state_from_draft, load_edit_state_from_entry, 30 + }; 54 31 55 32 /// Extract record embeds from a LoroDoc and pre-fetch their rendered content. 56 33 /// ··· 182 159 resolved 183 160 } 184 161 185 - /// Build a DocRef for either a published entry or an unpublished draft. 186 - /// 187 - /// If entry_uri and entry_cid are provided, creates an EntryRef. 188 - /// Otherwise, creates a DraftRef with a synthetic AT-URI for Constellation indexing. 189 - /// 190 - /// The synthetic URI format is: `at://{did}/sh.weaver.edit.draft/{rkey}` 191 - /// This allows Constellation to index drafts as backlinks, enabling discovery. 192 - fn build_doc_ref( 193 - did: &Did<'_>, 194 - draft_key: &str, 195 - entry_uri: Option<&AtUri<'_>>, 196 - entry_cid: Option<&Cid<'_>>, 197 - ) -> DocRef<'static> { 198 - match (entry_uri, entry_cid) { 199 - (Some(uri), Some(cid)) => DocRef { 200 - value: DocRefValue::EntryRef(Box::new(EntryRef { 201 - entry: StrongRef::new() 202 - .uri(uri.clone().into_static()) 203 - .cid(cid.clone().into_static()) 204 - .build(), 205 - extra_data: None, 206 - })), 207 - extra_data: None, 208 - }, 209 - _ => { 210 - // Transform localStorage key to synthetic AT-URI for Constellation indexing 211 - // localStorage uses "new:{tid}" or AT-URI, PDS uses "at://{did}/sh.weaver.edit.draft/{rkey}" 212 - let rkey = if let Some(tid) = draft_key.strip_prefix("new:") { 213 - // New draft: extract TID as rkey 214 - tid.to_string() 215 - } else if draft_key.starts_with("at://") { 216 - // Editing existing entry: use the entry's rkey 217 - draft_key.split('/').last().unwrap_or(draft_key).to_string() 218 - } else if draft_key.starts_with("did:") && draft_key.contains(':') { 219 - // Old canonical format "did:xxx:rkey" - extract rkey 220 - draft_key 221 - .rsplit(':') 222 - .next() 223 - .unwrap_or(draft_key) 224 - .to_string() 225 - } else { 226 - // Fallback: use as-is 227 - draft_key.to_string() 228 - }; 229 - 230 - // Build AT-URI pointing to actual draft record: at://{did}/sh.weaver.edit.draft/{rkey} 231 - let canonical_uri = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); 232 - 233 - DocRef { 234 - value: DocRefValue::DraftRef(Box::new(DraftRef { 235 - draft_key: CowStr::from(canonical_uri), 236 - extra_data: None, 237 - })), 238 - extra_data: None, 239 - } 240 - } 241 - } 242 - } 243 - 244 162 /// Convert a DocRef to an entry_ref StrongRef. 245 163 /// 246 164 /// For EntryRef: returns the entry's StrongRef directly ··· 277 195 } 278 196 } 279 197 280 - /// Result of a sync operation. 281 - #[derive(Clone, Debug)] 282 - pub enum SyncResult { 283 - /// Created a new root record (first sync) 284 - CreatedRoot { 285 - uri: AtUri<'static>, 286 - cid: Cid<'static>, 287 - }, 288 - /// Created a new diff record 289 - CreatedDiff { 290 - uri: AtUri<'static>, 291 - cid: Cid<'static>, 292 - }, 293 - /// No changes to sync 294 - NoChanges, 295 - } 296 - 297 - /// Find ALL edit.root records across collaborators for an entry. 298 - /// 299 - /// With use-index: Uses weaver-index getEditHistory endpoint. 300 - /// Without use-index: Queries Constellation for backlinks. 301 - #[cfg(feature = "use-index")] 302 - pub async fn find_all_edit_roots_for_entry( 303 - fetcher: &Fetcher, 304 - entry_uri: &AtUri<'_>, 305 - ) -> Result<Vec<RecordId<'static>>, WeaverError> { 306 - let output = fetcher 307 - .get_edit_history(entry_uri) 308 - .await 309 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to get edit history: {}", e)))?; 310 - 311 - // Convert EditHistoryEntry roots to RecordId format 312 - let roots: Vec<RecordId<'static>> = output 313 - .roots 314 - .into_iter() 315 - .filter_map(|entry| { 316 - let uri = AtUri::new(entry.uri.as_ref()).ok()?; 317 - let did = match uri.authority() { 318 - AtIdentifier::Did(d) => d.clone().into_static(), 319 - _ => return None, 320 - }; 321 - let rkey = uri.rkey()?.clone().into_static(); 322 - Some(RecordId { 323 - did, 324 - collection: Nsid::raw(ROOT_NSID).into_static(), 325 - rkey, 326 - }) 327 - }) 328 - .collect(); 329 - 330 - tracing::debug!( 331 - "find_all_edit_roots_for_entry (index): found {} roots", 332 - roots.len() 333 - ); 334 - 335 - Ok(roots) 336 - } 337 - 338 - /// Find ALL edit.root records across collaborators for an entry. 339 - /// 340 - /// 1. Gets list of collaborators via permissions 341 - /// 2. Queries Constellation for edit.root in each collaborator's repo 342 - /// 3. Returns all found roots for CRDT merge 343 - #[cfg(not(feature = "use-index"))] 344 - pub async fn find_all_edit_roots_for_entry( 345 - fetcher: &Fetcher, 346 - entry_uri: &AtUri<'_>, 347 - ) -> Result<Vec<RecordId<'static>>, WeaverError> { 348 - // Get collaborators from permissions 349 - let collaborators = fetcher 350 - .get_client() 351 - .find_collaborators_for_resource(entry_uri) 352 - .await 353 - .unwrap_or_default(); 354 - 355 - // Include the entry owner 356 - let owner_did = match entry_uri.authority() { 357 - AtIdentifier::Did(d) => d.clone().into_static(), 358 - AtIdentifier::Handle(h) => fetcher 359 - .client 360 - .resolve_handle(h) 361 - .await 362 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)))? 363 - .into_static(), 364 - }; 365 - 366 - let all_dids: Vec<Did<'static>> = std::iter::once(owner_did) 367 - .chain(collaborators.into_iter()) 368 - .collect(); 369 - 370 - let constellation_url = Url::parse(CONSTELLATION_URL) 371 - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; 372 - 373 - let mut all_roots = Vec::new(); 374 - 375 - // Query for edit.root records from this DID that reference entry_uri 376 - let query = GetBacklinksQuery { 377 - subject: Uri::At(entry_uri.clone().into_static()), 378 - source: format_smolstr!("{}:doc.value.entry.uri", ROOT_NSID).into(), 379 - cursor: None, 380 - did: all_dids.clone(), 381 - limit: 10, 382 - }; 383 - 384 - let response = fetcher 385 - .get_client() 386 - .xrpc(constellation_url.clone()) 387 - .send(&query) 388 - .await; 389 - 390 - if let Ok(response) = response { 391 - if let Ok(output) = response.into_output() { 392 - all_roots.extend(output.records.into_iter().map(|r| r.into_static())); 393 - } else { 394 - tracing::warn!("Failed to parse response for edit root query"); 395 - } 396 - } else { 397 - tracing::warn!("Failed to fetch edit root query"); 398 - } 399 - 400 - tracing::debug!( 401 - "find_all_edit_roots_for_entry: found {} roots across {} collaborators", 402 - all_roots.len(), 403 - all_dids.len() 404 - ); 405 - 406 - Ok(all_roots) 407 - } 408 - 409 - /// Find the edit root for a draft using constellation backlinks. 410 - /// 411 - /// Queries constellation for `sh.weaver.edit.root` records that reference 412 - /// the given draft URI via the `.doc.value.draft_key` path. 413 - /// 414 - /// The draft_uri should be in canonical format: `at://{did}/sh.weaver.edit.draft/{rkey}` 415 - pub async fn find_edit_root_for_draft( 416 - fetcher: &Fetcher, 417 - draft_uri: &AtUri<'_>, 418 - ) -> Result<Option<RecordId<'static>>, WeaverError> { 419 - let constellation_url = Url::parse(CONSTELLATION_URL) 420 - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; 421 - 422 - let query = GetBacklinksQuery { 423 - subject: Uri::At(draft_uri.clone().into_static()), 424 - source: format_smolstr!("{}:doc.value.draft_key", ROOT_NSID).into(), 425 - cursor: None, 426 - did: vec![], 427 - limit: 1, 428 - }; 429 - 430 - let response = fetcher 431 - .client 432 - .xrpc(constellation_url) 433 - .send(&query) 434 - .await 435 - .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; 436 - 437 - let output = response.into_output().map_err(|e| { 438 - WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) 439 - })?; 440 - 441 - Ok(output.records.into_iter().next().map(|r| r.into_static())) 442 - } 443 - 444 - /// Build a canonical draft URI from localStorage key and DID. 445 - /// 446 - /// Transforms localStorage format ("new:{tid}" or AT-URI) to 447 - /// draft record URI format: `at://{did}/sh.weaver.edit.draft/{rkey}` 448 - pub fn build_draft_uri(did: &Did<'_>, draft_key: &str) -> AtUri<'static> { 449 - let rkey = if let Some(tid) = draft_key.strip_prefix("new:") { 450 - tid.to_string() 451 - } else if draft_key.starts_with("at://") { 452 - draft_key.split('/').last().unwrap_or(draft_key).to_string() 453 - } else { 454 - draft_key.to_string() 455 - }; 456 - 457 - let uri_str = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); 458 - // Safe to unwrap: we're constructing a valid AT-URI 459 - AtUri::new(&uri_str).unwrap().into_static() 460 - } 461 - 462 - /// Extract the rkey (TID) from a localStorage draft key. 463 - fn extract_draft_rkey(draft_key: &str) -> String { 464 - if let Some(tid) = draft_key.strip_prefix("new:") { 465 - tid.to_string() 466 - } else if draft_key.starts_with("at://") { 467 - draft_key.split('/').last().unwrap_or(draft_key).to_string() 468 - } else { 469 - draft_key.to_string() 470 - } 471 - } 472 - 473 - /// Create the draft stub record on PDS. 474 - /// 475 - /// This creates a minimal `sh.weaver.edit.draft` record that acts as an anchor 476 - /// for edit.root/diff records and enables draft discovery via listRecords. 477 - async fn create_draft_stub( 478 - fetcher: &Fetcher, 479 - did: &Did<'_>, 480 - rkey: &str, 481 - ) -> Result<(AtUri<'static>, Cid<'static>), WeaverError> { 482 - // Build minimal draft record with just createdAt 483 - let draft = Draft::new() 484 - .created_at(jacquard::types::datetime::Datetime::now()) 485 - .build(); 486 - 487 - let draft_data = to_data(&draft) 488 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize draft: {}", e)))?; 489 - 490 - let record_key = 491 - RecordKey::any(rkey).map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 492 - 493 - let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?; 494 - 495 - let request = CreateRecord::new() 496 - .repo(AtIdentifier::Did(did.clone().into_static())) 497 - .collection(collection) 498 - .rkey(record_key) 499 - .record(draft_data) 500 - .build(); 501 - 502 - let response = fetcher 503 - .send(request) 504 - .await 505 - .map_err(jacquard::client::AgentError::from)?; 506 - 507 - let output = response 508 - .into_output() 509 - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 510 - 511 - Ok((output.uri.into_static(), output.cid.into_static())) 512 - } 513 - 514 - /// Remote draft info from PDS. 515 - #[derive(Clone, Debug)] 516 - pub struct RemoteDraft { 517 - /// The draft record URI 518 - pub uri: AtUri<'static>, 519 - /// The rkey (TID) of the draft 520 - pub rkey: String, 521 - /// When the draft was created 522 - pub created_at: String, 523 - } 524 - 525 - /// List all drafts for the current user. 526 - /// 527 - /// With use-index: Uses weaver-index listDrafts endpoint. 528 - /// Without use-index: Uses direct PDS ListRecords query. 529 - #[cfg(feature = "use-index")] 530 - pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result<Vec<RemoteDraft>, WeaverError> { 531 - let did = fetcher 532 - .current_did() 533 - .await 534 - .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 535 - 536 - let actor = AtIdentifier::Did(did); 537 - let output = fetcher 538 - .list_drafts(&actor) 539 - .await 540 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list drafts: {}", e)))?; 541 - 542 - tracing::debug!( 543 - "list_drafts_from_pds (index): found {} drafts", 544 - output.drafts.len() 545 - ); 546 - 547 - let drafts = output 548 - .drafts 549 - .into_iter() 550 - .filter_map(|draft| { 551 - let uri = AtUri::new(draft.uri.as_ref()).ok()?.into_static(); 552 - let rkey = uri.rkey()?.0.as_str().to_string(); 553 - let created_at = draft.created_at.to_string(); 554 - Some(RemoteDraft { 555 - uri, 556 - rkey, 557 - created_at, 558 - }) 559 - }) 560 - .collect(); 561 - 562 - Ok(drafts) 563 - } 564 - 565 198 /// List all drafts from PDS for the current user. 566 199 /// 567 - /// Returns a list of draft records from `sh.weaver.edit.draft` collection. 568 - #[cfg(not(feature = "use-index"))] 200 + /// Wraps the crdt crate's list_drafts function with Fetcher support. 569 201 pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result<Vec<RemoteDraft>, WeaverError> { 570 - use weaver_api::com_atproto::repo::list_records::ListRecords; 571 - 572 202 let did = fetcher 573 203 .current_did() 574 204 .await 575 205 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 576 206 577 - let client = fetcher.get_client(); 578 - let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?; 579 - 580 - let request = ListRecords::new() 581 - .repo(did) 582 - .collection(collection) 583 - .limit(100) 584 - .build(); 585 - 586 - let response = client 587 - .send(request) 207 + list_drafts(fetcher.get_client().as_ref(), &did) 588 208 .await 589 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list drafts: {}", e)))?; 590 - 591 - let output = response.into_output().map_err(|e| { 592 - WeaverError::InvalidNotebook(format!("Failed to parse list records response: {}", e)) 593 - })?; 594 - 595 - tracing::debug!( 596 - "list_drafts_from_pds: found {} records", 597 - output.records.len() 598 - ); 599 - 600 - let mut drafts = Vec::new(); 601 - for record in output.records { 602 - let rkey = record 603 - .uri 604 - .rkey() 605 - .map(|r| r.0.as_str().to_string()) 606 - .unwrap_or_default(); 607 - 608 - tracing::debug!(" Draft record: uri={}, rkey={}", record.uri, rkey); 609 - 610 - // Parse the draft record to get createdAt 611 - let created_at = 612 - jacquard::from_data::<weaver_api::sh_weaver::edit::draft::Draft>(&record.value) 613 - .map(|d| d.created_at.to_string()) 614 - .unwrap_or_default(); 615 - 616 - drafts.push(RemoteDraft { 617 - uri: record.uri.into_static(), 618 - rkey, 619 - created_at, 620 - }); 621 - } 622 - 623 - Ok(drafts) 624 - } 625 - 626 - /// Find all diffs for a root record using constellation backlinks. 627 - pub async fn find_diffs_for_root( 628 - fetcher: &Fetcher, 629 - root_uri: &AtUri<'_>, 630 - ) -> Result<Vec<RecordId<'static>>, WeaverError> { 631 - let constellation_url = Url::parse(CONSTELLATION_URL) 632 - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; 633 - 634 - let mut all_diffs = Vec::new(); 635 - let mut cursor: Option<String> = None; 636 - 637 - loop { 638 - let query = GetBacklinksQuery { 639 - subject: Uri::At(root_uri.clone().into_static()), 640 - source: format_smolstr!("{}:root.uri", DIFF_NSID).into(), 641 - cursor: cursor.map(Into::into), 642 - did: vec![], 643 - limit: 100, 644 - }; 645 - 646 - let response = fetcher 647 - .client 648 - .xrpc(constellation_url.clone()) 649 - .send(&query) 650 - .await 651 - .map_err(|e| { 652 - WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)) 653 - })?; 654 - 655 - let output = response.into_output().map_err(|e| { 656 - WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) 657 - })?; 658 - 659 - all_diffs.extend(output.records.into_iter().map(|r| r.into_static())); 660 - 661 - match output.cursor { 662 - Some(c) => cursor = Some(c.to_string()), 663 - None => break, 664 - } 665 - } 666 - 667 - Ok(all_diffs) 668 - } 669 - 670 - /// Result of creating an edit root, includes optional draft stub info. 671 - pub struct CreateRootResult { 672 - /// The root record URI 673 - pub root_uri: AtUri<'static>, 674 - /// The root record CID 675 - pub root_cid: Cid<'static>, 676 - /// Draft stub StrongRef if this was a new draft (not editing published entry) 677 - pub draft_ref: Option<StrongRef<'static>>, 209 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) 678 210 } 679 211 680 212 /// Create the edit root record for an entry. 681 213 /// 682 - /// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root` 683 - /// record referencing the entry (or draft key if unpublished). 684 - /// 685 - /// For drafts, also creates the `sh.weaver.edit.draft` stub record first. 686 - /// Returns the draft stub info so caller can set entry_ref. 214 + /// Wraps the crdt crate's create_edit_root with Fetcher support. 687 215 pub async fn create_edit_root( 688 216 fetcher: &Fetcher, 689 217 doc: &EditorDocument, ··· 691 219 entry_uri: Option<&AtUri<'_>>, 692 220 entry_cid: Option<&Cid<'_>>, 693 221 ) -> Result<CreateRootResult, WeaverError> { 694 - let client = fetcher.get_client(); 695 - let did = fetcher 696 - .current_did() 697 - .await 698 - .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 699 - 700 - // For drafts, create the stub record first (makes it discoverable via listRecords) 701 - let draft_ref: Option<StrongRef<'static>> = if entry_uri.is_none() { 702 - let rkey = extract_draft_rkey(draft_key); 703 - // Try to create draft stub, or get existing one 704 - match create_draft_stub(fetcher, &did, &rkey).await { 705 - Ok((uri, cid)) => { 706 - tracing::debug!("Created draft stub: {}", uri); 707 - Some(StrongRef::new().uri(uri).cid(cid).build()) 708 - } 709 - Err(e) => { 710 - // Check if it's a "record already exists" error 711 - let err_str = e.to_string(); 712 - if err_str.contains("RecordAlreadyExists") || err_str.contains("already exists") { 713 - // Draft exists - fetch it to get the CID 714 - let draft_uri_str = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey); 715 - if let Ok(draft_uri) = AtUri::new(&draft_uri_str) { 716 - if let Ok(response) = 717 - fetcher.get_client().get_record::<Draft>(&draft_uri).await 718 - { 719 - if let Ok(output) = response.into_output() { 720 - if let Some(cid) = output.cid { 721 - Some( 722 - StrongRef::new() 723 - .uri(draft_uri.into_static()) 724 - .cid(cid.into_static()) 725 - .build(), 726 - ) 727 - } else { 728 - tracing::warn!("Draft exists but has no CID"); 729 - None 730 - } 731 - } else { 732 - tracing::warn!("Draft exists but couldn't parse output"); 733 - None 734 - } 735 - } else { 736 - tracing::warn!("Draft exists but couldn't fetch record"); 737 - None 738 - } 739 - } else { 740 - None 741 - } 742 - } else { 743 - tracing::warn!("Failed to create draft stub (continuing anyway): {}", e); 744 - None 745 - } 746 - } 747 - } 748 - } else { 749 - None // Published entry, not a draft 750 - }; 751 - 752 - // Export full snapshot 753 - let snapshot = doc.export_snapshot(); 754 - 755 - // Upload snapshot blob 756 - let mime_type = MimeType::new_static("application/octet-stream"); 757 - let blob_ref = client 758 - .upload_blob(snapshot, mime_type) 759 - .await 760 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload snapshot: {}", e)))?; 761 - 762 - // Build DocRef - use EntryRef if published, DraftRef if not 763 - let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); 764 - 765 - // Build root record 766 - let root = Root::new().doc(doc_ref).snapshot(blob_ref).build(); 767 - 768 - let root_data = to_data(&root) 769 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize root: {}", e)))?; 770 - 771 - // Generate TID for the root rkey 772 - let root_tid = Ticker::new().next(None); 773 - let rkey = RecordKey::any(root_tid.as_str()) 774 - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 775 - 776 - let collection = Nsid::new(ROOT_NSID).map_err(|e| WeaverError::AtprotoString(e))?; 777 - 778 - let request = CreateRecord::new() 779 - .repo(AtIdentifier::Did(did)) 780 - .collection(collection) 781 - .rkey(rkey) 782 - .record(root_data) 783 - .build(); 784 - 785 - let response = fetcher 786 - .send(request) 787 - .await 788 - .map_err(jacquard::client::AgentError::from)?; 789 - 790 - let output = response 791 - .into_output() 792 - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 793 - 794 - Ok(CreateRootResult { 795 - root_uri: output.uri.into_static(), 796 - root_cid: output.cid.into_static(), 797 - draft_ref, 798 - }) 222 + weaver_editor_crdt::create_edit_root( 223 + fetcher.get_client().as_ref(), 224 + doc, 225 + draft_key, 226 + entry_uri, 227 + entry_cid, 228 + ) 229 + .await 230 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) 799 231 } 800 232 801 233 /// Create a diff record with updates since the last sync. 234 + /// 235 + /// Wraps the crdt crate's create_diff with Fetcher support. 802 236 pub async fn create_diff( 803 237 fetcher: &Fetcher, 804 238 doc: &EditorDocument, ··· 809 243 entry_uri: Option<&AtUri<'_>>, 810 244 entry_cid: Option<&Cid<'_>>, 811 245 ) -> Result<Option<(AtUri<'static>, Cid<'static>)>, WeaverError> { 812 - // Export updates since last sync 813 - let updates = match doc.export_updates_since_sync() { 814 - Some(u) => u, 815 - None => return Ok(None), // No changes 816 - }; 817 - 818 - let client = fetcher.get_client(); 819 - let did = fetcher 820 - .current_did() 821 - .await 822 - .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 823 - 824 - // Threshold for inline vs blob storage (8KB max for inline per lexicon) 825 - const INLINE_THRESHOLD: usize = 8192; 826 - 827 - // Use inline for small diffs, blob for larger ones 828 - let (blob_ref, inline_diff): (Option<jacquard::types::blob::BlobRef<'static>>, _) = 829 - if updates.len() <= INLINE_THRESHOLD { 830 - (None, Some(jacquard::bytes::Bytes::from(updates))) 831 - } else { 832 - let mime_type = MimeType::new_static("application/octet-stream"); 833 - let blob = client.upload_blob(updates, mime_type).await.map_err(|e| { 834 - WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e)) 835 - })?; 836 - (Some(blob.into()), None) 837 - }; 838 - 839 - // Build DocRef - use EntryRef if published, DraftRef if not 840 - let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); 841 - 842 - // Build root reference 843 - let root_ref = StrongRef::new() 844 - .uri(root_uri.clone().into_static()) 845 - .cid(root_cid.clone().into_static()) 846 - .build(); 847 - 848 - // Build prev reference if we have a previous diff 849 - let prev_ref = prev_diff.map(|(uri, cid)| { 850 - StrongRef::new() 851 - .uri(uri.clone().into_static()) 852 - .cid(cid.clone().into_static()) 853 - .build() 854 - }); 855 - 856 - // Build diff record 857 - let diff = Diff::new() 858 - .doc(doc_ref) 859 - .root(root_ref) 860 - .maybe_snapshot(blob_ref) 861 - .maybe_inline_diff(inline_diff) 862 - .maybe_prev(prev_ref) 863 - .build(); 864 - 865 - let diff_data = to_data(&diff) 866 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize diff: {}", e)))?; 867 - 868 - // Generate TID for the diff rkey 869 - let diff_tid = Ticker::new().next(None); 870 - let rkey = RecordKey::any(diff_tid.as_str()) 871 - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 872 - 873 - let collection = Nsid::new(DIFF_NSID).map_err(|e| WeaverError::AtprotoString(e))?; 874 - 875 - let request = CreateRecord::new() 876 - .repo(AtIdentifier::Did(did)) 877 - .collection(collection) 878 - .rkey(rkey) 879 - .record(diff_data) 880 - .build(); 881 - 882 - let response = fetcher 883 - .send(request) 884 - .await 885 - .map_err(jacquard::client::AgentError::from)?; 886 - 887 - let output = response 888 - .into_output() 889 - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 890 - 891 - Ok(Some((output.uri.into_static(), output.cid.into_static()))) 246 + weaver_editor_crdt::create_diff( 247 + fetcher.get_client().as_ref(), 248 + doc, 249 + root_uri, 250 + root_cid, 251 + prev_diff, 252 + draft_key, 253 + entry_uri, 254 + entry_cid, 255 + ) 256 + .await 257 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) 892 258 } 893 259 894 260 /// Sync the document to the PDS. ··· 997 363 } 998 364 } 999 365 1000 - /// Result of loading edit state from PDS. 1001 - #[derive(Clone, Debug)] 1002 - pub struct PdsEditState { 1003 - /// The root record reference 1004 - pub root_ref: StrongRef<'static>, 1005 - /// The latest diff reference (if any diffs exist) 1006 - pub last_diff_ref: Option<StrongRef<'static>>, 1007 - /// The Loro snapshot bytes from the root 1008 - pub root_snapshot: Bytes, 1009 - /// All diff update bytes in order (oldest first, by TID) 1010 - pub diff_updates: Vec<Bytes>, 1011 - /// Last seen diff URI per collaborator root (for incremental sync). 1012 - /// Maps root URI -> last diff URI we've imported from that root. 1013 - pub last_seen_diffs: std::collections::HashMap<AtUri<'static>, AtUri<'static>>, 1014 - /// The DocRef from the root record (tells us what's being edited) 1015 - pub doc_ref: DocRef<'static>, 1016 - } 1017 - 1018 - /// Fetch a blob from the PDS. 1019 - async fn fetch_blob(fetcher: &Fetcher, did: &Did<'_>, cid: &Cid<'_>) -> Result<Bytes, WeaverError> { 1020 - let pds_url = fetcher 1021 - .client 1022 - .pds_for_did(did) 1023 - .await 1024 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)))?; 1025 - 1026 - let request = GetBlob::new().did(did.clone()).cid(cid.clone()).build(); 1027 - 1028 - let response = fetcher 1029 - .client 1030 - .xrpc(pds_url) 1031 - .send(&request) 1032 - .await 1033 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to fetch blob: {}", e)))?; 1034 - 1035 - let output = response.into_output().map_err(|e| { 1036 - WeaverError::InvalidNotebook(format!("Failed to parse blob response: {}", e)) 1037 - })?; 1038 - 1039 - Ok(output.body) 1040 - } 1041 - 1042 366 /// Load edit state from the PDS for an entry. 1043 367 /// 1044 - /// Finds the edit root via constellation backlinks, fetches all diffs, 1045 - /// and returns the snapshot + updates needed to reconstruct the document. 368 + /// Wraps the crdt crate's load_edit_state_from_entry with Fetcher support. 1046 369 pub async fn load_edit_state_from_pds( 1047 370 fetcher: &Fetcher, 1048 371 entry_uri: &AtUri<'_>, 1049 372 ) -> Result<Option<PdsEditState>, WeaverError> { 1050 - // Find the edit root for this entry (take first if multiple exist) 1051 - let root_id = match find_all_edit_roots_for_entry(fetcher, entry_uri) 1052 - .await? 1053 - .into_iter() 1054 - .next() 1055 - { 1056 - Some(id) => id, 1057 - None => return Ok(None), 1058 - }; 373 + let client = fetcher.get_client(); 374 + // Get collaborators for this resource. 375 + let collaborators = client 376 + .find_collaborators_for_resource(entry_uri) 377 + .await 378 + .unwrap_or_default(); 1059 379 1060 - load_edit_state_from_root_id(fetcher, root_id, None).await 1061 - } 1062 - 1063 - /// Load edit state from the PDS for a draft. 1064 - /// 1065 - /// Finds the edit root via constellation backlinks using the draft URI, 1066 - /// fetches all diffs, and returns the snapshot + updates. 1067 - pub async fn load_edit_state_from_draft( 1068 - fetcher: &Fetcher, 1069 - draft_uri: &AtUri<'_>, 1070 - ) -> Result<Option<PdsEditState>, WeaverError> { 1071 - // Find the edit root for this draft 1072 - let root_id = match find_edit_root_for_draft(fetcher, draft_uri).await? { 1073 - Some(id) => id, 1074 - None => return Ok(None), 1075 - }; 1076 - 1077 - load_edit_state_from_root_id(fetcher, root_id, None).await 380 + load_edit_state_from_entry(client.as_ref(), entry_uri, collaborators) 381 + .await 382 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) 1078 383 } 1079 384 1080 385 /// Load edit state from ALL collaborator repos for an entry, returning merged state. 1081 386 /// 1082 - /// For each edit.root found across collaborators: 1083 - /// - Fetches the root snapshot 1084 - /// - Finds and fetches all diffs for that root (skipping already-seen diffs) 1085 - /// - Merges all Loro states into one unified document 1086 - /// 1087 - /// `last_seen_diffs` maps root URI -> last diff URI we've imported from that root. 1088 - /// This enables incremental sync by only fetching new diffs. 1089 - /// 1090 - /// Returns merged state suitable for CRDT collaboration, including updated last_seen_diffs. 387 + /// Wraps the crdt crate's load_all_edit_states with Fetcher support. 1091 388 pub async fn load_all_edit_states_from_pds( 1092 389 fetcher: &Fetcher, 1093 390 entry_uri: &AtUri<'_>, 1094 - last_seen_diffs: &std::collections::HashMap<AtUri<'static>, AtUri<'static>>, 391 + last_seen_diffs: &HashMap<AtUri<'static>, AtUri<'static>>, 1095 392 ) -> Result<Option<PdsEditState>, WeaverError> { 1096 - let all_roots = find_all_edit_roots_for_entry(fetcher, entry_uri).await?; 393 + let client = fetcher.get_client(); 1097 394 1098 - if all_roots.is_empty() { 1099 - return Ok(None); 1100 - } 1101 - 1102 - // We'll merge all snapshots and diffs into one unified LoroDoc 1103 - let merged_doc = LoroDoc::new(); 1104 - let mut our_root_ref: Option<StrongRef<'static>> = None; 1105 - let mut our_last_diff_ref: Option<StrongRef<'static>> = None; 1106 - let mut merged_doc_ref: Option<DocRef<'static>> = None; 1107 - let mut updated_last_seen = last_seen_diffs.clone(); 395 + // Get collaborators for this resource. 396 + let collaborators = client 397 + .find_collaborators_for_resource(entry_uri) 398 + .await 399 + .unwrap_or_default(); 1108 400 1109 - // Get current user's DID to identify "our" root for sync state tracking 1110 401 let current_did = fetcher.current_did().await; 1111 402 1112 - for root_id in all_roots { 1113 - // Save the DID before consuming root_id 1114 - let root_did = root_id.did.clone(); 1115 - 1116 - // Build root URI to look up last seen diff 1117 - let root_uri = AtUri::new(&format_smolstr!( 1118 - "at://{}/{}/{}", 1119 - root_id.did, 1120 - ROOT_NSID, 1121 - root_id.rkey.as_ref() 1122 - )) 1123 - .ok() 1124 - .map(|u| u.into_static()); 1125 - 1126 - // Get the last seen diff rkey for this root (if any) 1127 - let after_rkey = root_uri.as_ref().and_then(|uri| { 1128 - last_seen_diffs 1129 - .get(uri) 1130 - .and_then(|diff_uri| diff_uri.rkey().map(|rk| rk.0.to_string())) 1131 - }); 1132 - 1133 - // Load state from this root (skipping already-seen diffs) 1134 - if let Some(pds_state) = 1135 - load_edit_state_from_root_id(fetcher, root_id, after_rkey.as_deref()).await? 1136 - { 1137 - // Import root snapshot into merged doc 1138 - if let Err(e) = merged_doc.import(&pds_state.root_snapshot) { 1139 - tracing::warn!("Failed to import root snapshot from {}: {:?}", root_did, e); 1140 - continue; 1141 - } 1142 - 1143 - // Import all diffs 1144 - for diff in &pds_state.diff_updates { 1145 - if let Err(e) = merged_doc.import(diff) { 1146 - tracing::warn!("Failed to import diff from {}: {:?}", root_did, e); 1147 - } 1148 - } 1149 - 1150 - // Update last seen diff for this root (for incremental sync next time) 1151 - if let (Some(uri), Some(last_diff)) = (&root_uri, &pds_state.last_diff_ref) { 1152 - updated_last_seen.insert(uri.clone(), last_diff.uri.clone().into_static()); 1153 - } 1154 - 1155 - // Track doc_ref from the first root we process (they should all match) 1156 - if merged_doc_ref.is_none() { 1157 - merged_doc_ref = Some(pds_state.doc_ref.clone()); 1158 - } 1159 - 1160 - // Track "our" root/diff refs for sync state (used when syncing back) 1161 - // We want to track our own edit.root so subsequent diffs go to the right place 1162 - let is_our_root = current_did.as_ref().is_some_and(|did| root_did == *did); 1163 - 1164 - if is_our_root { 1165 - // This is our own root - use it for sync state 1166 - our_root_ref = Some(pds_state.root_ref); 1167 - our_last_diff_ref = pds_state.last_diff_ref; 1168 - } else if our_root_ref.is_none() { 1169 - // We don't have our own root yet - use the first one we find 1170 - // (this handles the case where we're a new collaborator with no edit state) 1171 - our_root_ref = Some(pds_state.root_ref); 1172 - our_last_diff_ref = pds_state.last_diff_ref; 1173 - } 1174 - } 1175 - } 1176 - 1177 - // Export merged state as new snapshot 1178 - let merged_snapshot = merged_doc.export(loro::ExportMode::Snapshot).map_err(|e| { 1179 - WeaverError::InvalidNotebook(format!("Failed to export merged snapshot: {}", e)) 1180 - })?; 1181 - 1182 - tracing::debug!( 1183 - "load_all_edit_states_from_pds: merged document, snapshot size = {} bytes", 1184 - merged_snapshot.len() 1185 - ); 1186 - 1187 - // If we found any roots, return the merged state (includes updated last_seen map) 1188 - // Note: our_root_ref might be from another collaborator if we haven't created our own yet 1189 - Ok(our_root_ref.map(|root_ref| PdsEditState { 1190 - root_ref, 1191 - last_diff_ref: our_last_diff_ref, 1192 - root_snapshot: merged_snapshot.into(), 1193 - diff_updates: vec![], // Already merged into snapshot 1194 - last_seen_diffs: updated_last_seen, 1195 - doc_ref: merged_doc_ref.expect("Should have at least one doc_ref if we have a root"), 1196 - })) 1197 - } 1198 - 1199 - /// Internal helper to load edit state given a root record ID. 1200 - /// 1201 - /// If `after_rkey` is provided, only diffs with rkey > after_rkey are fetched. 1202 - /// This enables incremental sync by skipping diffs we've already imported. 1203 - async fn load_edit_state_from_root_id( 1204 - fetcher: &Fetcher, 1205 - root_id: RecordId<'static>, 1206 - after_rkey: Option<&str>, 1207 - ) -> Result<Option<PdsEditState>, WeaverError> { 1208 - // Build root URI 1209 - let root_uri = AtUri::new(&format_smolstr!( 1210 - "at://{}/{}/{}", 1211 - root_id.did, 1212 - ROOT_NSID, 1213 - root_id.rkey.as_ref() 1214 - )) 1215 - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid root URI: {}", e)))? 1216 - .into_static(); 1217 - 1218 - // Fetch the root record using get_record helper 1219 - let root_response = fetcher 1220 - .client 1221 - .get_record::<Root>(&root_uri) 1222 - .await 1223 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to fetch root: {}", e)))?; 1224 - 1225 - let root_output = root_response 1226 - .into_output() 1227 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to parse root: {}", e)))?; 1228 - 1229 - let root_cid = root_output 1230 - .cid 1231 - .ok_or_else(|| WeaverError::InvalidNotebook("Root response missing CID".into()))?; 1232 - 1233 - let root_ref = StrongRef::new() 1234 - .uri(root_uri.clone()) 1235 - .cid(root_cid.into_static()) 1236 - .build(); 1237 - 1238 - // Extract the DocRef from the root record 1239 - let doc_ref = root_output.value.doc.into_static(); 1240 - 1241 - // Fetch the root snapshot blob 1242 - let root_snapshot = fetch_blob( 1243 - fetcher, 1244 - &root_id.did, 1245 - root_output.value.snapshot.blob().cid(), 403 + load_all_edit_states( 404 + client.as_ref(), 405 + entry_uri, 406 + collaborators, 407 + current_did.as_ref(), 408 + last_seen_diffs, 1246 409 ) 1247 - .await?; 1248 - 1249 - // Find all diffs for this root 1250 - let diff_ids = find_diffs_for_root(fetcher, &root_uri).await?; 1251 - 1252 - if diff_ids.is_empty() { 1253 - return Ok(Some(PdsEditState { 1254 - root_ref, 1255 - last_diff_ref: None, 1256 - root_snapshot, 1257 - diff_updates: vec![], 1258 - last_seen_diffs: std::collections::HashMap::new(), 1259 - doc_ref, 1260 - })); 1261 - } 1262 - 1263 - // Fetch all diffs and store in BTreeMap keyed by rkey (TID) for sorted order 1264 - // TIDs are lexicographically sortable timestamps 1265 - let mut diffs_by_rkey: BTreeMap< 1266 - CowStr<'static>, 1267 - (Diff<'static>, Cid<'static>, AtUri<'static>), 1268 - > = BTreeMap::new(); 1269 - 1270 - for diff_id in &diff_ids { 1271 - let rkey_str: &str = diff_id.rkey.as_ref(); 1272 - 1273 - // Skip diffs we've already seen (rkey/TID is lexicographically sortable by time) 1274 - if let Some(after) = after_rkey { 1275 - if rkey_str <= after { 1276 - tracing::trace!("Skipping already-seen diff rkey: {}", rkey_str); 1277 - continue; 1278 - } 1279 - } 1280 - 1281 - let diff_uri = AtUri::new(&format_smolstr!( 1282 - "at://{}/{}/{}", 1283 - diff_id.did, 1284 - DIFF_NSID, 1285 - rkey_str 1286 - )) 1287 - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid diff URI: {}", e)))? 1288 - .into_static(); 1289 - 1290 - let diff_response = fetcher 1291 - .client 1292 - .get_record::<Diff>(&diff_uri) 1293 - .await 1294 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to fetch diff: {}", e)))?; 1295 - 1296 - let diff_output = diff_response 1297 - .into_output() 1298 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to parse diff: {}", e)))?; 1299 - 1300 - let diff_cid = diff_output 1301 - .cid 1302 - .ok_or_else(|| WeaverError::InvalidNotebook("Diff response missing CID".into()))?; 1303 - 1304 - diffs_by_rkey.insert( 1305 - rkey_str.to_cowstr().into_static(), 1306 - ( 1307 - diff_output.value.into_static(), 1308 - diff_cid.into_static(), 1309 - diff_uri, 1310 - ), 1311 - ); 1312 - } 1313 - 1314 - // Fetch all diff data in TID order (BTreeMap iterates in sorted order) 1315 - // Diffs can be stored either inline or as blobs 1316 - let mut diff_updates = Vec::new(); 1317 - let mut last_diff_ref = None; 1318 - 1319 - for (_rkey, (diff, cid, uri)) in &diffs_by_rkey { 1320 - // Check for inline diff first, then fall back to blob 1321 - let diff_bytes = if let Some(ref inline) = diff.inline_diff { 1322 - inline.clone() 1323 - } else if let Some(ref snapshot) = diff.snapshot { 1324 - fetch_blob(fetcher, &root_id.did, snapshot.blob().cid()).await? 1325 - } else { 1326 - tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); 1327 - continue; 1328 - }; 1329 - 1330 - diff_updates.push(diff_bytes); 1331 - 1332 - // Track the last diff (will be the one with highest TID after iteration) 1333 - last_diff_ref = Some(StrongRef::new().uri(uri.clone()).cid(cid.clone()).build()); 1334 - } 1335 - 1336 - Ok(Some(PdsEditState { 1337 - root_ref, 1338 - last_diff_ref, 1339 - root_snapshot, 1340 - diff_updates, 1341 - last_seen_diffs: std::collections::HashMap::new(), 1342 - doc_ref, 1343 - })) 410 + .await 411 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) 1344 412 } 1345 413 1346 414 /// Load document state by merging local storage and PDS state. ··· 1365 433 // for drafts use single-repo loading (draft sharing requires knowing the URI) 1366 434 let pds_state = if let Some(uri) = entry_uri { 1367 435 // Published entry: load from ALL collaborators (multi-repo CRDT merge) 1368 - let empty_last_seen = std::collections::HashMap::new(); 436 + let empty_last_seen = HashMap::new(); 1369 437 load_all_edit_states_from_pds(fetcher, uri, &empty_last_seen).await? 1370 438 } else if let Some(did) = fetcher.current_did().await { 1371 439 // Unpublished draft: single-repo for now 1372 440 // (draft sharing would require collaborator to know the draft URI) 1373 441 let draft_uri = build_draft_uri(&did, draft_key); 1374 - load_edit_state_from_draft(fetcher, &draft_uri).await? 442 + load_edit_state_from_draft(fetcher.get_client().as_ref(), &draft_uri) 443 + .await 444 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))? 1375 445 } else { 1376 446 // Not authenticated, can't query PDS 1377 447 None
+72 -51
crates/weaver-app/src/components/editor/worker.rs crates/weaver-editor-crdt/src/worker/reactor.rs
··· 1 - //! Web Worker for offloading expensive editor operations. 1 + //! Web Worker reactor for offloading expensive editor operations. 2 2 //! 3 3 //! This worker maintains a shadow copy of the Loro document and handles 4 4 //! CPU-intensive operations like snapshot export and base64 encoding 5 5 //! off the main thread. 6 6 //! 7 - //! When the `collab-worker` feature is enabled, also handles iroh P2P 7 + //! When the `collab` feature is enabled, also handles iroh P2P 8 8 //! networking for real-time collaboration. 9 9 10 10 #[cfg(all(target_family = "wasm", target_os = "unknown"))] ··· 138 138 use gloo_worker::reactor::{ReactorScope, reactor}; 139 139 use weaver_common::transport::CollaboratorInfo; 140 140 141 - #[cfg(feature = "collab-worker")] 141 + #[cfg(feature = "collab")] 142 142 use jacquard::smol_str::ToSmolStr; 143 - #[cfg(feature = "collab-worker")] 143 + #[cfg(feature = "collab")] 144 144 use std::sync::Arc; 145 - #[cfg(feature = "collab-worker")] 145 + #[cfg(feature = "collab")] 146 146 use weaver_common::transport::{ 147 147 CollabMessage, CollabNode, CollabSession, PresenceTracker, SessionEvent, TopicId, 148 148 parse_node_id, 149 149 }; 150 150 151 151 /// Internal event from gossip handler task to main reactor loop. 152 - #[cfg(feature = "collab-worker")] 152 + #[cfg(feature = "collab")] 153 153 enum CollabEvent { 154 154 RemoteUpdates { data: Vec<u8> }, 155 155 PresenceChanged(PresenceSnapshot), ··· 162 162 let mut doc: Option<loro::LoroDoc> = None; 163 163 let mut draft_key = SmolStr::default(); 164 164 165 - // Collab state (only used when collab-worker feature enabled) 166 - #[cfg(feature = "collab-worker")] 165 + // Collab state (only used when collab feature enabled) 166 + #[cfg(feature = "collab")] 167 167 let mut collab_node: Option<Arc<CollabNode>> = None; 168 - #[cfg(feature = "collab-worker")] 168 + #[cfg(feature = "collab")] 169 169 let mut collab_session: Option<Arc<CollabSession>> = None; 170 - #[cfg(feature = "collab-worker")] 170 + #[cfg(feature = "collab")] 171 171 let mut collab_event_rx: Option<tokio::sync::mpsc::UnboundedReceiver<CollabEvent>> = None; 172 - #[cfg(feature = "collab-worker")] 172 + #[cfg(feature = "collab")] 173 173 const OUR_COLOR: u32 = 0x4ECDC4FF; 174 174 175 175 // Helper enum for racing coordinator messages vs collab events 176 - #[cfg(feature = "collab-worker")] 176 + #[cfg(feature = "collab")] 177 177 enum RaceResult { 178 178 CoordinatorMsg(Option<WorkerInput>), 179 179 CollabEvent(Option<CollabEvent>), ··· 181 181 182 182 loop { 183 183 // Race between coordinator messages and collab events 184 - #[cfg(feature = "collab-worker")] 184 + #[cfg(feature = "collab")] 185 185 let race_result = if let Some(ref mut event_rx) = collab_event_rx { 186 186 use n0_future::FutureExt; 187 187 let coord_fut = async { RaceResult::CoordinatorMsg(scope.next().await) }; ··· 191 191 RaceResult::CoordinatorMsg(scope.next().await) 192 192 }; 193 193 194 - #[cfg(feature = "collab-worker")] 194 + #[cfg(feature = "collab")] 195 195 match race_result { 196 196 RaceResult::CollabEvent(Some(event)) => { 197 197 match event { ··· 281 281 continue; 282 282 }; 283 283 284 - let export_start = crate::perf::now(); 284 + let export_start = weaver_common::perf::now(); 285 285 let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 286 286 Ok(bytes) => bytes, 287 287 Err(e) => { ··· 298 298 continue; 299 299 } 300 300 }; 301 - let export_ms = crate::perf::now() - export_start; 301 + let export_ms = weaver_common::perf::now() - export_start; 302 302 303 - let encode_start = crate::perf::now(); 303 + let encode_start = weaver_common::perf::now(); 304 304 let b64_snapshot = BASE64.encode(&snapshot_bytes); 305 - let encode_ms = crate::perf::now() - encode_start; 305 + let encode_ms = weaver_common::perf::now() - encode_start; 306 306 307 307 let content = doc.get_text("content").to_string(); 308 308 let title: SmolStr = doc.get_text("title").to_string().into(); ··· 327 327 } 328 328 329 329 // ============================================================ 330 - // Collab handlers - full impl when collab-worker feature enabled 330 + // Collab handlers - full impl when collab feature enabled 331 331 // ============================================================ 332 - #[cfg(feature = "collab-worker")] 332 + #[cfg(feature = "collab")] 333 333 WorkerInput::StartCollab { 334 334 topic, 335 335 bootstrap_peers, ··· 388 388 "Failed to send CollabJoined to coordinator: {e}" 389 389 ); 390 390 } 391 - 392 - // NOTE: Don't broadcast Join here - wait for BroadcastJoin message 393 - // after peers have been added via AddPeers 394 391 395 392 // Create channel for events from spawned task 396 393 let (event_tx, event_rx) = ··· 461 458 selection, 462 459 .. 463 460 } => { 464 - // Note: cursor updates require the collaborator to exist 465 - // (added via Join message) 466 461 let exists = presence.contains(&from); 467 462 tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); 468 463 presence.update_cursor( ··· 485 480 } 486 481 SessionEvent::PeerJoined(peer) => { 487 482 tracing::info!(%peer, "PeerJoined - notifying coordinator"); 488 - // Notify coordinator so it can send BroadcastJoin 489 - // Don't add to presence yet - wait for their Join message 490 483 if event_tx 491 484 .send(CollabEvent::PeerConnected) 492 485 .is_err() ··· 531 524 } 532 525 } 533 526 534 - #[cfg(feature = "collab-worker")] 527 + #[cfg(feature = "collab")] 535 528 WorkerInput::BroadcastUpdate { data } => { 536 529 if let Some(ref session) = collab_session { 537 530 let msg = CollabMessage::LoroUpdate { ··· 544 537 } 545 538 } 546 539 547 - #[cfg(feature = "collab-worker")] 540 + #[cfg(feature = "collab")] 548 541 WorkerInput::BroadcastCursor { 549 542 position, 550 543 selection, ··· 572 565 } 573 566 } 574 567 575 - #[cfg(feature = "collab-worker")] 568 + #[cfg(feature = "collab")] 576 569 WorkerInput::AddPeers { peers } => { 577 570 tracing::info!(count = peers.len(), "Worker: received AddPeers"); 578 571 if let Some(ref session) = collab_session { 579 572 let peer_ids: Vec<_> = peers 580 - .iter() 581 - .filter_map(|s| { 582 - match parse_node_id(s) { 583 - Ok(id) => Some(id), 584 - Err(e) => { 585 - tracing::warn!(node_id = %s, error = %e, "Failed to parse node_id"); 586 - None 587 - } 588 - } 589 - }) 590 - .collect(); 573 + .iter() 574 + .filter_map(|s| { 575 + match parse_node_id(s) { 576 + Ok(id) => Some(id), 577 + Err(e) => { 578 + tracing::warn!(node_id = %s, error = %e, "Failed to parse node_id"); 579 + None 580 + } 581 + } 582 + }) 583 + .collect(); 591 584 tracing::info!( 592 585 parsed_count = peer_ids.len(), 593 586 "Worker: joining peers" ··· 600 593 } 601 594 } 602 595 603 - #[cfg(feature = "collab-worker")] 596 + #[cfg(feature = "collab")] 604 597 WorkerInput::BroadcastJoin { did, display_name } => { 605 598 if let Some(ref session) = collab_session { 606 599 let join_msg = CollabMessage::Join { did, display_name }; ··· 610 603 } 611 604 } 612 605 613 - #[cfg(feature = "collab-worker")] 606 + #[cfg(feature = "collab")] 614 607 WorkerInput::StopCollab => { 615 608 collab_session = None; 616 609 collab_node = None; ··· 619 612 tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 620 613 } 621 614 } 615 + 616 + // Non-collab stubs for when collab feature is enabled but message doesn't match 617 + #[cfg(not(feature = "collab"))] 618 + WorkerInput::StartCollab { .. } => { 619 + if let Err(e) = scope 620 + .send(WorkerOutput::Error { 621 + message: "Collab not enabled".into(), 622 + }) 623 + .await 624 + { 625 + tracing::error!("Failed to send Error to coordinator: {e}"); 626 + } 627 + } 628 + #[cfg(not(feature = "collab"))] 629 + WorkerInput::BroadcastUpdate { .. } => {} 630 + #[cfg(not(feature = "collab"))] 631 + WorkerInput::AddPeers { .. } => {} 632 + #[cfg(not(feature = "collab"))] 633 + WorkerInput::BroadcastJoin { .. } => {} 634 + #[cfg(not(feature = "collab"))] 635 + WorkerInput::BroadcastCursor { .. } => {} 636 + #[cfg(not(feature = "collab"))] 637 + WorkerInput::StopCollab => { 638 + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { 639 + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 640 + } 641 + } 622 642 } // end match msg 623 643 } // end RaceResult::CoordinatorMsg(Some(msg)) 624 644 } // end match race_result 625 645 626 - // Non-collab-worker: simple message loop 627 - #[cfg(not(feature = "collab-worker"))] 646 + // Non-collab: simple message loop 647 + #[cfg(not(feature = "collab"))] 628 648 { 629 649 let Some(msg) = scope.next().await else { break }; 630 650 tracing::debug!(?msg, "Worker: received message"); ··· 679 699 } 680 700 continue; 681 701 }; 682 - let export_start = crate::perf::now(); 702 + let export_start = weaver_common::perf::now(); 683 703 let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 684 704 Ok(bytes) => bytes, 685 705 Err(e) => { ··· 696 716 continue; 697 717 } 698 718 }; 699 - let export_ms = crate::perf::now() - export_start; 700 - let encode_start = crate::perf::now(); 719 + let export_ms = weaver_common::perf::now() - export_start; 720 + let encode_start = weaver_common::perf::now(); 701 721 let b64_snapshot = BASE64.encode(&snapshot_bytes); 702 - let encode_ms = crate::perf::now() - encode_start; 722 + let encode_ms = weaver_common::perf::now() - encode_start; 703 723 let content = doc.get_text("content").to_string(); 704 724 let title: SmolStr = doc.get_text("title").to_string().into(); 705 725 if let Err(e) = scope ··· 720 740 tracing::error!("Failed to send Snapshot to coordinator: {e}"); 721 741 } 722 742 } 723 - // Collab stubs for non-collab-worker build 743 + // Collab stubs for non-collab build 724 744 WorkerInput::StartCollab { .. } => { 725 745 if let Err(e) = scope 726 746 .send(WorkerOutput::Error { ··· 746 766 } 747 767 748 768 /// Convert PresenceTracker to serializable PresenceSnapshot. 749 - #[cfg(feature = "collab-worker")] 769 + #[cfg(feature = "collab")] 750 770 fn presence_to_snapshot(tracker: &PresenceTracker) -> PresenceSnapshot { 771 + use jacquard::smol_str::ToSmolStr; 751 772 let collaborators = tracker 752 773 .collaborators() 753 774 .map(|c| CollaboratorInfo {
+29
crates/weaver-common/src/agent.rs
··· 2589 2589 Ok(contributors.into_iter().collect()) 2590 2590 } 2591 2591 } 2592 + 2593 + /// Fetch a blob from any PDS by DID and CID. 2594 + /// 2595 + /// Resolves the DID to find its PDS, then fetches the blob. 2596 + fn fetch_blob<'a>( 2597 + &'a self, 2598 + did: &'a Did<'_>, 2599 + cid: &'a jacquard::types::string::Cid<'_>, 2600 + ) -> impl Future<Output = Result<Bytes, WeaverError>> + 'a { 2601 + async move { 2602 + use weaver_api::com_atproto::sync::get_blob::GetBlob; 2603 + 2604 + let pds_url = self.pds_for_did(did).await.map_err(|e| { 2605 + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) 2606 + })?; 2607 + 2608 + let request = GetBlob::new().did(did.clone()).cid(cid.clone()).build(); 2609 + 2610 + let response = self 2611 + .xrpc(pds_url) 2612 + .send(&request) 2613 + .await 2614 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 2615 + 2616 + let output = response.into_output().map_err(|e| AgentError::xrpc(e))?; 2617 + 2618 + Ok(output.body) 2619 + } 2620 + } 2592 2621 } 2593 2622 2594 2623 /// A version of a record from a collaborator's repository.
+46
crates/weaver-editor-crdt/Cargo.toml
··· 1 + [package] 2 + name = "weaver-editor-crdt" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + description = "CRDT-backed editor with AT Protocol sync" 7 + 8 + [features] 9 + default = [] 10 + collab = ["weaver-common/iroh"] 11 + weaver = ["dep:weaver-renderer"] # Entry-specific conveniences 12 + use-index = ["weaver-common/use-index"] # Use weaver-index for reads 13 + 14 + [[bin]] 15 + name = "editor_worker" 16 + path = "src/bin/editor_worker.rs" 17 + 18 + [dependencies] 19 + weaver-editor-core = { path = "../weaver-editor-core" } 20 + weaver-common = { path = "../weaver-common", features = ["perf"] } 21 + weaver-api = { path = "../weaver-api" } 22 + jacquard = { workspace = true } 23 + loro = "1.9" 24 + serde = { workspace = true } 25 + smol_str = "0.3" 26 + tracing = { workspace = true } 27 + thiserror = "2" 28 + futures-util = "0.3" 29 + n0-future = { workspace = true } 30 + 31 + # Optional weaver-specific deps 32 + weaver-renderer = { path = "../weaver-renderer", optional = true } 33 + 34 + [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] 35 + gloo-worker = { version = "0.5", features = ["futures"] } 36 + wasm-bindgen = "0.2" 37 + wasm-bindgen-futures = "0.4" 38 + base64 = "0.22" 39 + tokio = { version = "1", features = ["sync"] } 40 + console_error_panic_hook = "0.1" 41 + tracing-wasm = "0.2" 42 + tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry", "env-filter"] } 43 + 44 + [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] 45 + base64 = "0.22" 46 + tokio = { version = "1", features = ["sync"] }
+45
crates/weaver-editor-crdt/src/bin/editor_worker.rs
··· 1 + //! Entry point for the editor web worker. 2 + //! 3 + //! This binary is compiled separately and loaded by the main app 4 + //! to handle CPU-intensive editor operations off the main thread. 5 + 6 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 + fn main() { 8 + console_error_panic_hook::set_once(); 9 + use tracing::Level; 10 + use tracing::subscriber::set_global_default; 11 + use tracing_subscriber::Registry; 12 + use tracing_subscriber::filter::EnvFilter; 13 + use tracing_subscriber::layer::SubscriberExt; 14 + 15 + let console_level = if cfg!(debug_assertions) { 16 + Level::DEBUG 17 + } else { 18 + Level::DEBUG 19 + }; 20 + 21 + let wasm_layer = tracing_wasm::WASMLayer::new( 22 + tracing_wasm::WASMLayerConfigBuilder::new() 23 + .set_max_level(console_level) 24 + .build(), 25 + ); 26 + 27 + // Filter out noisy crates 28 + let filter = EnvFilter::new( 29 + "debug,loro_internal=warn,jacquard_identity=info,jacquard_common=info,iroh=info", 30 + ); 31 + 32 + let reg = Registry::default().with(filter).with(wasm_layer); 33 + 34 + let _ = set_global_default(reg); 35 + 36 + use gloo_worker::Registrable; 37 + use weaver_editor_crdt::EditorReactor; 38 + 39 + EditorReactor::registrar().register(); 40 + } 41 + 42 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 43 + fn main() { 44 + eprintln!("This binary is only meant to run as a WASM web worker"); 45 + }
+237
crates/weaver-editor-crdt/src/buffer.rs
··· 1 + //! Loro-backed text buffer implementing core editor traits. 2 + 3 + use std::cell::RefCell; 4 + use std::ops::Range; 5 + use std::rc::Rc; 6 + 7 + use loro::{LoroDoc, LoroText, UndoManager as LoroUndoManager, VersionVector}; 8 + use smol_str::{SmolStr, ToSmolStr}; 9 + use weaver_editor_core::{TextBuffer, UndoManager}; 10 + 11 + use crate::CrdtError; 12 + 13 + /// Loro-backed text buffer with undo/redo support. 14 + /// 15 + /// Wraps a `LoroDoc` with a text container and provides implementations 16 + /// of the `TextBuffer` and `UndoManager` traits from weaver-editor-core. 17 + #[derive(Clone)] 18 + pub struct LoroTextBuffer { 19 + doc: LoroDoc, 20 + content: LoroText, 21 + undo_mgr: Rc<RefCell<LoroUndoManager>>, 22 + } 23 + 24 + impl LoroTextBuffer { 25 + /// Create a new empty buffer. 26 + pub fn new() -> Self { 27 + let doc = LoroDoc::new(); 28 + let content = doc.get_text("content"); 29 + let undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&doc))); 30 + 31 + Self { 32 + doc, 33 + content, 34 + undo_mgr, 35 + } 36 + } 37 + 38 + /// Create a buffer from an existing Loro snapshot. 39 + pub fn from_snapshot(snapshot: &[u8]) -> Result<Self, CrdtError> { 40 + let doc = LoroDoc::new(); 41 + doc.import(snapshot)?; 42 + let content = doc.get_text("content"); 43 + let undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&doc))); 44 + 45 + Ok(Self { 46 + doc, 47 + content, 48 + undo_mgr, 49 + }) 50 + } 51 + 52 + /// Get the underlying Loro document. 53 + pub fn doc(&self) -> &LoroDoc { 54 + &self.doc 55 + } 56 + 57 + /// Get the text container. 58 + pub fn content(&self) -> &LoroText { 59 + &self.content 60 + } 61 + 62 + /// Export full snapshot. 63 + pub fn export_snapshot(&self) -> Vec<u8> { 64 + self.doc 65 + .export(loro::ExportMode::Snapshot) 66 + .expect("snapshot export should not fail") 67 + } 68 + 69 + /// Export updates since given version. 70 + pub fn export_updates_since(&self, version: &VersionVector) -> Option<Vec<u8>> { 71 + use std::borrow::Cow; 72 + 73 + let current_vv = self.doc.oplog_vv(); 74 + 75 + if *version == current_vv { 76 + return None; 77 + } 78 + 79 + let updates = self 80 + .doc 81 + .export(loro::ExportMode::Updates { 82 + from: Cow::Owned(version.clone()), 83 + }) 84 + .ok()?; 85 + 86 + if updates.is_empty() { 87 + return None; 88 + } 89 + 90 + Some(updates) 91 + } 92 + 93 + /// Import remote changes. 94 + pub fn import(&mut self, data: &[u8]) -> Result<(), CrdtError> { 95 + self.doc.import(data)?; 96 + Ok(()) 97 + } 98 + 99 + /// Get current version vector. 100 + pub fn version(&self) -> VersionVector { 101 + self.doc.oplog_vv() 102 + } 103 + } 104 + 105 + impl Default for LoroTextBuffer { 106 + fn default() -> Self { 107 + Self::new() 108 + } 109 + } 110 + 111 + impl TextBuffer for LoroTextBuffer { 112 + fn len_bytes(&self) -> usize { 113 + self.content.to_string().len() 114 + } 115 + 116 + fn len_chars(&self) -> usize { 117 + self.content.len_unicode() 118 + } 119 + 120 + fn insert(&mut self, char_offset: usize, text: &str) { 121 + self.content.insert(char_offset, text).ok(); 122 + } 123 + 124 + fn delete(&mut self, char_range: Range<usize>) { 125 + self.content.delete(char_range.start, char_range.len()).ok(); 126 + } 127 + 128 + fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> { 129 + let s = self.content.to_string(); 130 + let chars: Vec<char> = s.chars().collect(); 131 + 132 + if char_range.end > chars.len() { 133 + return None; 134 + } 135 + 136 + let slice: String = chars[char_range].iter().collect(); 137 + Some(slice.to_smolstr()) 138 + } 139 + 140 + fn char_at(&self, char_offset: usize) -> Option<char> { 141 + let s = self.content.to_string(); 142 + s.chars().nth(char_offset) 143 + } 144 + 145 + fn to_string(&self) -> String { 146 + self.content.to_string() 147 + } 148 + 149 + fn char_to_byte(&self, char_offset: usize) -> usize { 150 + let s = self.content.to_string(); 151 + s.char_indices() 152 + .nth(char_offset) 153 + .map(|(i, _)| i) 154 + .unwrap_or(s.len()) 155 + } 156 + 157 + fn byte_to_char(&self, byte_offset: usize) -> usize { 158 + let s = self.content.to_string(); 159 + s[..byte_offset.min(s.len())].chars().count() 160 + } 161 + } 162 + 163 + impl UndoManager for LoroTextBuffer { 164 + fn can_undo(&self) -> bool { 165 + self.undo_mgr.borrow().can_undo() 166 + } 167 + 168 + fn can_redo(&self) -> bool { 169 + self.undo_mgr.borrow().can_redo() 170 + } 171 + 172 + fn undo(&mut self) -> bool { 173 + self.undo_mgr.borrow_mut().undo().is_ok() 174 + } 175 + 176 + fn redo(&mut self) -> bool { 177 + self.undo_mgr.borrow_mut().redo().is_ok() 178 + } 179 + 180 + fn clear_history(&mut self) { 181 + // Loro's UndoManager doesn't have a clear method 182 + // Create a new one to effectively clear history 183 + self.undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&self.doc))); 184 + } 185 + } 186 + 187 + #[cfg(test)] 188 + mod tests { 189 + use super::*; 190 + 191 + #[test] 192 + fn test_basic_operations() { 193 + let mut buffer = LoroTextBuffer::new(); 194 + 195 + buffer.insert(0, "Hello"); 196 + assert_eq!(buffer.to_string(), "Hello"); 197 + 198 + buffer.insert(5, " World"); 199 + assert_eq!(buffer.to_string(), "Hello World"); 200 + 201 + buffer.delete(5..6); 202 + assert_eq!(buffer.to_string(), "HelloWorld"); 203 + } 204 + 205 + #[test] 206 + fn test_snapshot_roundtrip() { 207 + let mut buffer = LoroTextBuffer::new(); 208 + buffer.insert(0, "Test content"); 209 + 210 + let snapshot = buffer.export_snapshot(); 211 + let restored = LoroTextBuffer::from_snapshot(&snapshot).unwrap(); 212 + 213 + assert_eq!(restored.to_string(), "Test content"); 214 + } 215 + 216 + #[test] 217 + fn test_slice() { 218 + let mut buffer = LoroTextBuffer::new(); 219 + buffer.insert(0, "Hello World"); 220 + 221 + assert_eq!(buffer.slice(0..5).as_deref(), Some("Hello")); 222 + assert_eq!(buffer.slice(6..11).as_deref(), Some("World")); 223 + assert_eq!(buffer.slice(0..100), None); 224 + } 225 + 226 + #[test] 227 + fn test_offset_conversion() { 228 + let mut buffer = LoroTextBuffer::new(); 229 + buffer.insert(0, "hello 🌍"); 230 + 231 + assert_eq!(buffer.len_chars(), 7); // h e l l o 🌍 232 + assert_eq!(buffer.len_bytes(), 10); // 6 + 4 233 + 234 + assert_eq!(buffer.char_to_byte(6), 6); // before emoji 235 + assert_eq!(buffer.char_to_byte(7), 10); // after emoji 236 + } 237 + }
+159
crates/weaver-editor-crdt/src/document.rs
··· 1 + //! CRDT document trait and sync state tracking. 2 + 3 + use loro::VersionVector; 4 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 5 + 6 + /// Sync state for a CRDT document. 7 + /// 8 + /// Tracks the edit root, last diff, and version at last sync. 9 + #[derive(Clone, Debug, Default)] 10 + pub struct SyncState { 11 + /// StrongRef to the sh.weaver.edit.root record. 12 + pub edit_root: Option<StrongRef<'static>>, 13 + 14 + /// StrongRef to the most recent sh.weaver.edit.diff record. 15 + pub last_diff: Option<StrongRef<'static>>, 16 + 17 + /// Version vector at the time of last sync. 18 + pub last_synced_version: Option<VersionVector>, 19 + } 20 + 21 + impl SyncState { 22 + /// Create new empty sync state. 23 + pub fn new() -> Self { 24 + Self::default() 25 + } 26 + 27 + /// Check if we have an edit root (i.e., have synced at least once). 28 + pub fn has_root(&self) -> bool { 29 + self.edit_root.is_some() 30 + } 31 + } 32 + 33 + /// Trait for CRDT documents that can be synced to AT Protocol PDS. 34 + /// 35 + /// Implementors provide access to the underlying CRDT operations 36 + /// and sync state tracking. 37 + pub trait CrdtDocument { 38 + /// Export full snapshot bytes. 39 + fn export_snapshot(&self) -> Vec<u8>; 40 + 41 + /// Export updates since the last synced version. 42 + /// Returns None if no changes since last sync. 43 + fn export_updates_since_sync(&self) -> Option<Vec<u8>>; 44 + 45 + /// Import remote changes. 46 + fn import(&mut self, data: &[u8]) -> Result<(), crate::CrdtError>; 47 + 48 + /// Get current version vector. 49 + fn version(&self) -> VersionVector; 50 + 51 + /// Get the edit root StrongRef. 52 + fn edit_root(&self) -> Option<StrongRef<'static>>; 53 + 54 + /// Set the edit root StrongRef. 55 + fn set_edit_root(&mut self, root: Option<StrongRef<'static>>); 56 + 57 + /// Get the last diff StrongRef. 58 + fn last_diff(&self) -> Option<StrongRef<'static>>; 59 + 60 + /// Set the last diff StrongRef. 61 + fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>); 62 + 63 + /// Mark current version as synced. 64 + fn mark_synced(&mut self); 65 + 66 + /// Check if there are changes since last sync. 67 + fn has_unsynced_changes(&self) -> bool; 68 + } 69 + 70 + // Blanket implementation for LoroTextBuffer with embedded SyncState 71 + // (Concrete types can provide their own implementations) 72 + 73 + /// A simple CRDT document wrapping LoroTextBuffer with sync state. 74 + pub struct SimpleCrdtDocument { 75 + buffer: crate::LoroTextBuffer, 76 + sync_state: SyncState, 77 + } 78 + 79 + impl SimpleCrdtDocument { 80 + /// Create a new empty document. 81 + pub fn new() -> Self { 82 + Self { 83 + buffer: crate::LoroTextBuffer::new(), 84 + sync_state: SyncState::new(), 85 + } 86 + } 87 + 88 + /// Create from snapshot. 89 + pub fn from_snapshot(snapshot: &[u8]) -> Result<Self, crate::CrdtError> { 90 + Ok(Self { 91 + buffer: crate::LoroTextBuffer::from_snapshot(snapshot)?, 92 + sync_state: SyncState::new(), 93 + }) 94 + } 95 + 96 + /// Get the underlying buffer. 97 + pub fn buffer(&self) -> &crate::LoroTextBuffer { 98 + &self.buffer 99 + } 100 + 101 + /// Get mutable access to the buffer. 102 + pub fn buffer_mut(&mut self) -> &mut crate::LoroTextBuffer { 103 + &mut self.buffer 104 + } 105 + } 106 + 107 + impl Default for SimpleCrdtDocument { 108 + fn default() -> Self { 109 + Self::new() 110 + } 111 + } 112 + 113 + impl CrdtDocument for SimpleCrdtDocument { 114 + fn export_snapshot(&self) -> Vec<u8> { 115 + self.buffer.export_snapshot() 116 + } 117 + 118 + fn export_updates_since_sync(&self) -> Option<Vec<u8>> { 119 + self.sync_state 120 + .last_synced_version 121 + .as_ref() 122 + .and_then(|v| self.buffer.export_updates_since(v)) 123 + } 124 + 125 + fn import(&mut self, data: &[u8]) -> Result<(), crate::CrdtError> { 126 + self.buffer.import(data) 127 + } 128 + 129 + fn version(&self) -> VersionVector { 130 + self.buffer.version() 131 + } 132 + 133 + fn edit_root(&self) -> Option<StrongRef<'static>> { 134 + self.sync_state.edit_root.clone() 135 + } 136 + 137 + fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) { 138 + self.sync_state.edit_root = root; 139 + } 140 + 141 + fn last_diff(&self) -> Option<StrongRef<'static>> { 142 + self.sync_state.last_diff.clone() 143 + } 144 + 145 + fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) { 146 + self.sync_state.last_diff = diff; 147 + } 148 + 149 + fn mark_synced(&mut self) { 150 + self.sync_state.last_synced_version = Some(self.buffer.version()); 151 + } 152 + 153 + fn has_unsynced_changes(&self) -> bool { 154 + match &self.sync_state.last_synced_version { 155 + None => true, // Never synced 156 + Some(last) => self.buffer.version() != *last, 157 + } 158 + } 159 + }
+52
crates/weaver-editor-crdt/src/error.rs
··· 1 + //! Error types for CRDT operations. 2 + 3 + use thiserror::Error; 4 + 5 + /// Errors that can occur during CRDT operations. 6 + #[derive(Error, Debug)] 7 + #[non_exhaustive] 8 + pub enum CrdtError { 9 + /// Failed to import CRDT data. 10 + #[error("failed to import CRDT data: {0}")] 11 + Import(String), 12 + 13 + /// Failed to export CRDT data. 14 + #[error("failed to export CRDT data: {0}")] 15 + Export(String), 16 + 17 + /// Sync operation failed. 18 + #[error("sync failed: {0}")] 19 + Sync(String), 20 + 21 + /// Not authenticated. 22 + #[error("not authenticated")] 23 + NotAuthenticated, 24 + 25 + /// Invalid AT-URI. 26 + #[error("invalid AT-URI: {0}")] 27 + InvalidUri(String), 28 + 29 + /// XRPC call failed. 30 + #[error("XRPC error: {0}")] 31 + Xrpc(String), 32 + 33 + /// Serialization error. 34 + #[error("serialization error: {0}")] 35 + Serialization(String), 36 + 37 + /// Loro CRDT error. 38 + #[error("loro error: {0}")] 39 + Loro(String), 40 + } 41 + 42 + impl From<loro::LoroError> for CrdtError { 43 + fn from(e: loro::LoroError) -> Self { 44 + CrdtError::Import(e.to_string()) 45 + } 46 + } 47 + 48 + impl From<jacquard::client::AgentError> for CrdtError { 49 + fn from(e: jacquard::client::AgentError) -> Self { 50 + CrdtError::Xrpc(e.to_string()) 51 + } 52 + }
+33
crates/weaver-editor-crdt/src/lib.rs
··· 1 + //! CRDT-backed editor with AT Protocol sync. 2 + //! 3 + //! This crate provides: 4 + //! - `LoroTextBuffer`: Loro-backed text buffer implementing `TextBuffer` + `UndoManager` 5 + //! - `CrdtDocument`: Trait for documents that can sync to AT Protocol PDS 6 + //! - Generic sync logic for edit records (root/diff/draft) 7 + //! - Worker implementation for off-main-thread CRDT operations 8 + 9 + mod buffer; 10 + mod document; 11 + mod error; 12 + mod sync; 13 + 14 + pub mod worker; 15 + 16 + pub use buffer::LoroTextBuffer; 17 + pub use document::{CrdtDocument, SimpleCrdtDocument, SyncState}; 18 + pub use error::CrdtError; 19 + pub use sync::{ 20 + CreateRootResult, PdsEditState, RemoteDraft, SyncResult, 21 + build_draft_uri, create_diff, create_edit_root, 22 + find_all_edit_roots, find_diffs_for_root, find_edit_root_for_draft, 23 + list_drafts, load_all_edit_states, load_edit_state_from_draft, 24 + load_edit_state_from_entry, sync_to_pds, 25 + }; 26 + 27 + // Re-export worker types 28 + pub use worker::{WorkerInput, WorkerOutput}; 29 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 30 + pub use worker::EditorReactor; 31 + 32 + // Re-export Loro types that consumers need 33 + pub use loro::{ExportMode, LoroDoc, LoroText, VersionVector};
+963
crates/weaver-editor-crdt/src/sync.rs
··· 1 + //! PDS synchronization for CRDT documents. 2 + //! 3 + //! Generic sync logic for AT Protocol edit records (root/diff/draft). 4 + //! Works with any client implementing the required traits. 5 + 6 + use std::collections::{BTreeMap, HashMap}; 7 + 8 + use jacquard::bytes::Bytes; 9 + use jacquard::cowstr::ToCowStr; 10 + use jacquard::prelude::*; 11 + use jacquard::smol_str::format_smolstr; 12 + use jacquard::types::blob::MimeType; 13 + use jacquard::types::ident::AtIdentifier; 14 + use jacquard::types::recordkey::RecordKey; 15 + use jacquard::types::string::{AtUri, Cid, Did, Nsid}; 16 + use jacquard::types::tid::Ticker; 17 + use jacquard::types::uri::Uri; 18 + use jacquard::url::Url; 19 + use jacquard::{CowStr, IntoStatic, to_data}; 20 + use loro::{ExportMode, LoroDoc}; 21 + use weaver_api::com_atproto::repo::create_record::CreateRecord; 22 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 23 + use weaver_api::sh_weaver::edit::diff::Diff; 24 + use weaver_api::sh_weaver::edit::draft::Draft; 25 + use weaver_api::sh_weaver::edit::root::Root; 26 + use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; 27 + use weaver_common::agent::WeaverExt; 28 + use weaver_common::constellation::{GetBacklinksQuery, RecordId}; 29 + 30 + use crate::CrdtError; 31 + use crate::document::CrdtDocument; 32 + 33 + const ROOT_NSID: &str = "sh.weaver.edit.root"; 34 + const DIFF_NSID: &str = "sh.weaver.edit.diff"; 35 + const DRAFT_NSID: &str = "sh.weaver.edit.draft"; 36 + const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 37 + 38 + /// Result of a sync operation. 39 + #[derive(Clone, Debug)] 40 + pub enum SyncResult { 41 + /// Created a new root record (first sync). 42 + CreatedRoot { 43 + uri: AtUri<'static>, 44 + cid: Cid<'static>, 45 + }, 46 + /// Created a new diff record. 47 + CreatedDiff { 48 + uri: AtUri<'static>, 49 + cid: Cid<'static>, 50 + }, 51 + /// No changes to sync. 52 + NoChanges, 53 + } 54 + 55 + /// Result of creating an edit root. 56 + pub struct CreateRootResult { 57 + /// The root record URI. 58 + pub root_uri: AtUri<'static>, 59 + /// The root record CID. 60 + pub root_cid: Cid<'static>, 61 + /// Draft stub StrongRef if this was a new draft. 62 + pub draft_ref: Option<StrongRef<'static>>, 63 + } 64 + 65 + /// Build a DocRef for either a published entry or an unpublished draft. 66 + fn build_doc_ref( 67 + did: &Did<'_>, 68 + draft_key: &str, 69 + entry_uri: Option<&AtUri<'_>>, 70 + entry_cid: Option<&Cid<'_>>, 71 + ) -> DocRef<'static> { 72 + match (entry_uri, entry_cid) { 73 + (Some(uri), Some(cid)) => DocRef { 74 + value: DocRefValue::EntryRef(Box::new(EntryRef { 75 + entry: StrongRef::new() 76 + .uri(uri.clone().into_static()) 77 + .cid(cid.clone().into_static()) 78 + .build(), 79 + extra_data: None, 80 + })), 81 + extra_data: None, 82 + }, 83 + _ => { 84 + // Transform localStorage key to synthetic AT-URI 85 + let rkey = extract_draft_rkey(draft_key); 86 + let canonical_uri = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); 87 + 88 + DocRef { 89 + value: DocRefValue::DraftRef(Box::new(DraftRef { 90 + draft_key: canonical_uri.into(), 91 + extra_data: None, 92 + })), 93 + extra_data: None, 94 + } 95 + } 96 + } 97 + } 98 + 99 + /// Extract the rkey (TID) from a draft key. 100 + fn extract_draft_rkey(draft_key: &str) -> String { 101 + if let Some(tid) = draft_key.strip_prefix("new:") { 102 + tid.to_string() 103 + } else if draft_key.starts_with("at://") { 104 + draft_key.split('/').last().unwrap_or(draft_key).to_string() 105 + } else { 106 + draft_key.to_string() 107 + } 108 + } 109 + 110 + /// Get current DID from session. 111 + async fn get_current_did<C>(client: &C) -> Result<Did<'static>, CrdtError> 112 + where 113 + C: AgentSession, 114 + { 115 + client 116 + .session_info() 117 + .await 118 + .map(|(did, _)| did) 119 + .ok_or(CrdtError::NotAuthenticated) 120 + } 121 + 122 + /// Create the draft stub record on PDS. 123 + async fn create_draft_stub<C>( 124 + client: &C, 125 + did: &Did<'_>, 126 + rkey: &str, 127 + ) -> Result<(AtUri<'static>, Cid<'static>), CrdtError> 128 + where 129 + C: XrpcClient + AgentSession, 130 + { 131 + let draft = Draft::new() 132 + .created_at(jacquard::types::datetime::Datetime::now()) 133 + .build(); 134 + 135 + let draft_data = 136 + to_data(&draft).map_err(|e| CrdtError::Serialization(format!("draft: {}", e)))?; 137 + 138 + let record_key = 139 + RecordKey::any(rkey).map_err(|e| CrdtError::InvalidUri(format!("rkey: {}", e)))?; 140 + 141 + let collection = 142 + Nsid::new(DRAFT_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; 143 + 144 + let request = CreateRecord::new() 145 + .repo(AtIdentifier::Did(did.clone().into_static())) 146 + .collection(collection) 147 + .rkey(record_key) 148 + .record(draft_data) 149 + .build(); 150 + 151 + let response = client 152 + .send(request) 153 + .await 154 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 155 + 156 + let output = response 157 + .into_output() 158 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 159 + 160 + Ok((output.uri.into_static(), output.cid.into_static())) 161 + } 162 + 163 + /// Create the edit root record for a document. 164 + pub async fn create_edit_root<C, D>( 165 + client: &C, 166 + doc: &D, 167 + draft_key: &str, 168 + entry_uri: Option<&AtUri<'_>>, 169 + entry_cid: Option<&Cid<'_>>, 170 + ) -> Result<CreateRootResult, CrdtError> 171 + where 172 + C: XrpcClient + IdentityResolver + AgentSession, 173 + D: CrdtDocument, 174 + { 175 + let did = get_current_did(client).await?; 176 + 177 + // For drafts, create the stub record first 178 + let draft_ref: Option<StrongRef<'static>> = if entry_uri.is_none() { 179 + let rkey = extract_draft_rkey(draft_key); 180 + match create_draft_stub(client, &did, &rkey).await { 181 + Ok((uri, cid)) => Some(StrongRef::new().uri(uri).cid(cid).build()), 182 + Err(e) => { 183 + let err_str = e.to_string(); 184 + if err_str.contains("RecordAlreadyExists") || err_str.contains("already exists") { 185 + // Draft exists, try to fetch it 186 + let draft_uri_str = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey); 187 + if let Ok(draft_uri) = AtUri::new(&draft_uri_str) { 188 + if let Ok(response) = client.get_record::<Draft>(&draft_uri).await { 189 + if let Ok(output) = response.into_output() { 190 + output.cid.map(|cid| { 191 + StrongRef::new() 192 + .uri(draft_uri.into_static()) 193 + .cid(cid.into_static()) 194 + .build() 195 + }) 196 + } else { 197 + None 198 + } 199 + } else { 200 + None 201 + } 202 + } else { 203 + None 204 + } 205 + } else { 206 + tracing::warn!("Failed to create draft stub: {}", e); 207 + None 208 + } 209 + } 210 + } 211 + } else { 212 + None 213 + }; 214 + 215 + // Export full snapshot 216 + let snapshot = doc.export_snapshot(); 217 + 218 + // Upload snapshot blob 219 + let mime_type = MimeType::new_static("application/octet-stream"); 220 + let blob_ref = client 221 + .upload_blob(snapshot, mime_type) 222 + .await 223 + .map_err(|e| CrdtError::Xrpc(format!("upload blob: {}", e)))?; 224 + 225 + // Build DocRef 226 + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); 227 + 228 + // Build root record 229 + let root = Root::new().doc(doc_ref).snapshot(blob_ref).build(); 230 + 231 + let root_data = to_data(&root).map_err(|e| CrdtError::Serialization(format!("root: {}", e)))?; 232 + 233 + // Generate TID for the root rkey 234 + let root_tid = Ticker::new().next(None); 235 + let rkey = RecordKey::any(root_tid.as_str()) 236 + .map_err(|e| CrdtError::InvalidUri(format!("rkey: {}", e)))?; 237 + 238 + let collection = 239 + Nsid::new(ROOT_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; 240 + 241 + let request = CreateRecord::new() 242 + .repo(AtIdentifier::Did(did)) 243 + .collection(collection) 244 + .rkey(rkey) 245 + .record(root_data) 246 + .build(); 247 + 248 + let response = client 249 + .send(request) 250 + .await 251 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 252 + 253 + let output = response 254 + .into_output() 255 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 256 + 257 + Ok(CreateRootResult { 258 + root_uri: output.uri.into_static(), 259 + root_cid: output.cid.into_static(), 260 + draft_ref, 261 + }) 262 + } 263 + 264 + /// Create a diff record with updates since the last sync. 265 + pub async fn create_diff<C, D>( 266 + client: &C, 267 + doc: &D, 268 + root_uri: &AtUri<'_>, 269 + root_cid: &Cid<'_>, 270 + prev_diff: Option<(&AtUri<'_>, &Cid<'_>)>, 271 + draft_key: &str, 272 + entry_uri: Option<&AtUri<'_>>, 273 + entry_cid: Option<&Cid<'_>>, 274 + ) -> Result<Option<(AtUri<'static>, Cid<'static>)>, CrdtError> 275 + where 276 + C: XrpcClient + IdentityResolver + AgentSession, 277 + D: CrdtDocument, 278 + { 279 + // Export updates since last sync 280 + let updates = match doc.export_updates_since_sync() { 281 + Some(u) => u, 282 + None => return Ok(None), 283 + }; 284 + 285 + let did = get_current_did(client).await?; 286 + 287 + // Threshold for inline vs blob storage (8KB max for inline per lexicon) 288 + const INLINE_THRESHOLD: usize = 8192; 289 + 290 + let (blob_ref, inline_diff): (Option<jacquard::types::blob::BlobRef<'static>>, _) = 291 + if updates.len() <= INLINE_THRESHOLD { 292 + (None, Some(jacquard::bytes::Bytes::from(updates))) 293 + } else { 294 + let mime_type = MimeType::new_static("application/octet-stream"); 295 + let blob = client 296 + .upload_blob(updates, mime_type) 297 + .await 298 + .map_err(|e| CrdtError::Xrpc(format!("upload diff: {}", e)))?; 299 + (Some(blob.into()), None) 300 + }; 301 + 302 + // Build DocRef 303 + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); 304 + 305 + // Build root reference 306 + let root_ref = StrongRef::new() 307 + .uri(root_uri.clone().into_static()) 308 + .cid(root_cid.clone().into_static()) 309 + .build(); 310 + 311 + // Build prev reference 312 + let prev_ref = prev_diff.map(|(uri, cid)| { 313 + StrongRef::new() 314 + .uri(uri.clone().into_static()) 315 + .cid(cid.clone().into_static()) 316 + .build() 317 + }); 318 + 319 + // Build diff record 320 + let diff = Diff::new() 321 + .doc(doc_ref) 322 + .root(root_ref) 323 + .maybe_snapshot(blob_ref) 324 + .maybe_inline_diff(inline_diff) 325 + .maybe_prev(prev_ref) 326 + .build(); 327 + 328 + let diff_data = to_data(&diff).map_err(|e| CrdtError::Serialization(format!("diff: {}", e)))?; 329 + 330 + // Generate TID for the diff rkey 331 + let diff_tid = Ticker::new().next(None); 332 + let rkey = RecordKey::any(diff_tid.as_str()) 333 + .map_err(|e| CrdtError::InvalidUri(format!("rkey: {}", e)))?; 334 + 335 + let collection = 336 + Nsid::new(DIFF_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; 337 + 338 + let request = CreateRecord::new() 339 + .repo(AtIdentifier::Did(did)) 340 + .collection(collection) 341 + .rkey(rkey) 342 + .record(diff_data) 343 + .build(); 344 + 345 + let response = client 346 + .send(request) 347 + .await 348 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 349 + 350 + let output = response 351 + .into_output() 352 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 353 + 354 + Ok(Some((output.uri.into_static(), output.cid.into_static()))) 355 + } 356 + 357 + /// Sync the document to the PDS. 358 + pub async fn sync_to_pds<C, D>( 359 + client: &C, 360 + doc: &mut D, 361 + draft_key: &str, 362 + entry_uri: Option<&AtUri<'_>>, 363 + entry_cid: Option<&Cid<'_>>, 364 + ) -> Result<SyncResult, CrdtError> 365 + where 366 + C: XrpcClient + IdentityResolver + AgentSession, 367 + D: CrdtDocument, 368 + { 369 + if !doc.has_unsynced_changes() { 370 + return Ok(SyncResult::NoChanges); 371 + } 372 + 373 + if doc.edit_root().is_none() { 374 + // First sync - create root 375 + let result = create_edit_root(client, doc, draft_key, entry_uri, entry_cid).await?; 376 + 377 + let root_ref = StrongRef::new() 378 + .uri(result.root_uri.clone()) 379 + .cid(result.root_cid.clone()) 380 + .build(); 381 + 382 + doc.set_edit_root(Some(root_ref)); 383 + doc.set_last_diff(None); 384 + doc.mark_synced(); 385 + 386 + Ok(SyncResult::CreatedRoot { 387 + uri: result.root_uri, 388 + cid: result.root_cid, 389 + }) 390 + } else { 391 + // Subsequent sync - create diff 392 + let root = doc.edit_root().unwrap(); 393 + let prev = doc.last_diff(); 394 + 395 + let prev_refs = prev.as_ref().map(|p| (&p.uri, &p.cid)); 396 + 397 + let result = create_diff( 398 + client, doc, &root.uri, &root.cid, prev_refs, draft_key, entry_uri, entry_cid, 399 + ) 400 + .await?; 401 + 402 + match result { 403 + Some((uri, cid)) => { 404 + let diff_ref = StrongRef::new().uri(uri.clone()).cid(cid.clone()).build(); 405 + doc.set_last_diff(Some(diff_ref)); 406 + doc.mark_synced(); 407 + 408 + Ok(SyncResult::CreatedDiff { uri, cid }) 409 + } 410 + None => Ok(SyncResult::NoChanges), 411 + } 412 + } 413 + } 414 + 415 + /// Find all edit roots for an entry using weaver-index. 416 + #[cfg(feature = "use-index")] 417 + pub async fn find_all_edit_roots<C>( 418 + client: &C, 419 + entry_uri: &AtUri<'_>, 420 + _collaborator_dids: Vec<Did<'static>>, 421 + ) -> Result<Vec<RecordId<'static>>, CrdtError> 422 + where 423 + C: WeaverExt, 424 + { 425 + use jacquard::types::ident::AtIdentifier; 426 + use jacquard::types::nsid::Nsid; 427 + use weaver_api::sh_weaver::edit::get_edit_history::GetEditHistory; 428 + 429 + let response = client 430 + .send(GetEditHistory::new().resource(entry_uri.clone()).build()) 431 + .await 432 + .map_err(|e| CrdtError::Xrpc(format!("get edit history: {}", e)))?; 433 + 434 + let output = response 435 + .into_output() 436 + .map_err(|e| CrdtError::Xrpc(format!("parse edit history: {}", e)))?; 437 + 438 + let roots: Vec<RecordId<'static>> = output 439 + .roots 440 + .into_iter() 441 + .filter_map(|entry| { 442 + let uri = AtUri::new(entry.uri.as_ref()).ok()?; 443 + let did = match uri.authority() { 444 + AtIdentifier::Did(d) => d.clone().into_static(), 445 + _ => return None, 446 + }; 447 + let rkey = uri.rkey()?.clone().into_static(); 448 + Some(RecordId { 449 + did, 450 + collection: Nsid::raw(ROOT_NSID).into_static(), 451 + rkey, 452 + }) 453 + }) 454 + .collect(); 455 + 456 + tracing::debug!("find_all_edit_roots (index): found {} roots", roots.len()); 457 + 458 + Ok(roots) 459 + } 460 + 461 + /// Find all edit roots for an entry using Constellation backlinks. 462 + #[cfg(not(feature = "use-index"))] 463 + pub async fn find_all_edit_roots<C>( 464 + client: &C, 465 + entry_uri: &AtUri<'_>, 466 + collaborator_dids: Vec<Did<'static>>, 467 + ) -> Result<Vec<RecordId<'static>>, CrdtError> 468 + where 469 + C: XrpcClient, 470 + { 471 + let constellation_url = 472 + Url::parse(CONSTELLATION_URL).map_err(|e| CrdtError::InvalidUri(e.to_string()))?; 473 + 474 + let query = GetBacklinksQuery { 475 + subject: Uri::At(entry_uri.clone().into_static()), 476 + source: format_smolstr!("{}:doc.value.entry.uri", ROOT_NSID).into(), 477 + cursor: None, 478 + did: collaborator_dids, 479 + limit: 100, 480 + }; 481 + 482 + let response = client 483 + .xrpc(constellation_url) 484 + .send(&query) 485 + .await 486 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 487 + 488 + let output = response 489 + .into_output() 490 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 491 + 492 + Ok(output 493 + .records 494 + .into_iter() 495 + .map(|r| r.into_static()) 496 + .collect()) 497 + } 498 + 499 + /// Find all diffs for a root record using Constellation backlinks. 500 + pub async fn find_diffs_for_root<C>( 501 + client: &C, 502 + root_uri: &AtUri<'_>, 503 + ) -> Result<Vec<RecordId<'static>>, CrdtError> 504 + where 505 + C: XrpcClient, 506 + { 507 + let constellation_url = 508 + Url::parse(CONSTELLATION_URL).map_err(|e| CrdtError::InvalidUri(e.to_string()))?; 509 + 510 + let mut all_diffs = Vec::new(); 511 + let mut cursor: Option<String> = None; 512 + 513 + loop { 514 + let query = GetBacklinksQuery { 515 + subject: Uri::At(root_uri.clone().into_static()), 516 + source: format_smolstr!("{}:root.uri", DIFF_NSID).into(), 517 + cursor: cursor.map(Into::into), 518 + did: vec![], 519 + limit: 100, 520 + }; 521 + 522 + let response = client 523 + .xrpc(constellation_url.clone()) 524 + .send(&query) 525 + .await 526 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 527 + 528 + let output = response 529 + .into_output() 530 + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; 531 + 532 + all_diffs.extend(output.records.into_iter().map(|r| r.into_static())); 533 + 534 + match output.cursor { 535 + Some(c) => cursor = Some(c.to_string()), 536 + None => break, 537 + } 538 + } 539 + 540 + Ok(all_diffs) 541 + } 542 + 543 + // ============================================================================ 544 + // Loading functions 545 + // ============================================================================ 546 + 547 + /// Result of loading edit state from PDS. 548 + #[derive(Clone, Debug)] 549 + pub struct PdsEditState { 550 + /// The root record reference. 551 + pub root_ref: StrongRef<'static>, 552 + /// The latest diff reference (if any diffs exist). 553 + pub last_diff_ref: Option<StrongRef<'static>>, 554 + /// The Loro snapshot bytes from the root. 555 + pub root_snapshot: Bytes, 556 + /// All diff update bytes in order (oldest first, by TID). 557 + pub diff_updates: Vec<Bytes>, 558 + /// Last seen diff URI per collaborator root (for incremental sync). 559 + pub last_seen_diffs: HashMap<AtUri<'static>, AtUri<'static>>, 560 + /// The DocRef from the root record. 561 + pub doc_ref: DocRef<'static>, 562 + } 563 + 564 + /// Find edit root for a draft using Constellation backlinks. 565 + pub async fn find_edit_root_for_draft<C>( 566 + client: &C, 567 + draft_uri: &AtUri<'_>, 568 + ) -> Result<Option<RecordId<'static>>, CrdtError> 569 + where 570 + C: XrpcClient, 571 + { 572 + let constellation_url = 573 + Url::parse(CONSTELLATION_URL).map_err(|e| CrdtError::InvalidUri(e.to_string()))?; 574 + 575 + let query = GetBacklinksQuery { 576 + subject: Uri::At(draft_uri.clone().into_static()), 577 + source: format_smolstr!("{}:doc.value.draft_key", ROOT_NSID).into(), 578 + cursor: None, 579 + did: vec![], 580 + limit: 1, 581 + }; 582 + 583 + let response = client 584 + .xrpc(constellation_url) 585 + .send(&query) 586 + .await 587 + .map_err(|e| CrdtError::Xrpc(format!("constellation query: {}", e)))?; 588 + 589 + let output = response 590 + .into_output() 591 + .map_err(|e| CrdtError::Xrpc(format!("parse constellation: {}", e)))?; 592 + 593 + Ok(output.records.into_iter().next().map(|r| r.into_static())) 594 + } 595 + 596 + /// Build a canonical draft URI from draft key and DID. 597 + pub fn build_draft_uri(did: &Did<'_>, draft_key: &str) -> AtUri<'static> { 598 + let rkey = extract_draft_rkey(draft_key); 599 + let uri_str = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); 600 + AtUri::new(&uri_str).unwrap().into_static() 601 + } 602 + 603 + /// Load edit state from a root record ID. 604 + async fn load_edit_state_from_root_id<C>( 605 + client: &C, 606 + root_id: RecordId<'static>, 607 + after_rkey: Option<&str>, 608 + ) -> Result<Option<PdsEditState>, CrdtError> 609 + where 610 + C: WeaverExt, 611 + { 612 + let root_uri = AtUri::new(&format_smolstr!( 613 + "at://{}/{}/{}", 614 + root_id.did, 615 + ROOT_NSID, 616 + root_id.rkey.as_ref() 617 + )) 618 + .map_err(|e| CrdtError::InvalidUri(format!("root URI: {}", e)))? 619 + .into_static(); 620 + 621 + let root_response = client 622 + .get_record::<Root>(&root_uri) 623 + .await 624 + .map_err(|e| CrdtError::Xrpc(format!("fetch root: {}", e)))?; 625 + 626 + let root_output = root_response 627 + .into_output() 628 + .map_err(|e| CrdtError::Xrpc(format!("parse root: {}", e)))?; 629 + 630 + let root_cid = root_output 631 + .cid 632 + .ok_or_else(|| CrdtError::Xrpc("root missing CID".into()))?; 633 + 634 + let root_ref = StrongRef::new() 635 + .uri(root_uri.clone()) 636 + .cid(root_cid.into_static()) 637 + .build(); 638 + 639 + let doc_ref = root_output.value.doc.into_static(); 640 + 641 + let root_snapshot = client 642 + .fetch_blob(&root_id.did, root_output.value.snapshot.blob().cid()) 643 + .await 644 + .map_err(|e| CrdtError::Xrpc(format!("fetch snapshot blob: {}", e)))?; 645 + 646 + let diff_ids = find_diffs_for_root(client, &root_uri).await?; 647 + 648 + if diff_ids.is_empty() { 649 + return Ok(Some(PdsEditState { 650 + root_ref, 651 + last_diff_ref: None, 652 + root_snapshot, 653 + diff_updates: vec![], 654 + last_seen_diffs: HashMap::new(), 655 + doc_ref, 656 + })); 657 + } 658 + 659 + let mut diffs_by_rkey: BTreeMap< 660 + CowStr<'static>, 661 + (Diff<'static>, Cid<'static>, AtUri<'static>), 662 + > = BTreeMap::new(); 663 + 664 + for diff_id in &diff_ids { 665 + let rkey_str: &str = diff_id.rkey.as_ref(); 666 + 667 + if let Some(after) = after_rkey { 668 + if rkey_str <= after { 669 + continue; 670 + } 671 + } 672 + 673 + let diff_uri = AtUri::new(&format_smolstr!( 674 + "at://{}/{}/{}", 675 + diff_id.did, 676 + DIFF_NSID, 677 + rkey_str 678 + )) 679 + .map_err(|e| CrdtError::InvalidUri(format!("diff URI: {}", e)))? 680 + .into_static(); 681 + 682 + let diff_response = client 683 + .get_record::<Diff>(&diff_uri) 684 + .await 685 + .map_err(|e| CrdtError::Xrpc(format!("fetch diff: {}", e)))?; 686 + 687 + let diff_output = diff_response 688 + .into_output() 689 + .map_err(|e| CrdtError::Xrpc(format!("parse diff: {}", e)))?; 690 + 691 + let diff_cid = diff_output 692 + .cid 693 + .ok_or_else(|| CrdtError::Xrpc("diff missing CID".into()))?; 694 + 695 + diffs_by_rkey.insert( 696 + rkey_str.to_cowstr().into_static(), 697 + ( 698 + diff_output.value.into_static(), 699 + diff_cid.into_static(), 700 + diff_uri, 701 + ), 702 + ); 703 + } 704 + 705 + let mut diff_updates = Vec::new(); 706 + let mut last_diff_ref = None; 707 + 708 + for (_rkey, (diff, cid, uri)) in &diffs_by_rkey { 709 + let diff_bytes = if let Some(ref inline) = diff.inline_diff { 710 + inline.clone() 711 + } else if let Some(ref snapshot) = diff.snapshot { 712 + client 713 + .fetch_blob(&root_id.did, snapshot.blob().cid()) 714 + .await 715 + .map_err(|e| CrdtError::Xrpc(format!("fetch diff blob: {}", e)))? 716 + } else { 717 + tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); 718 + continue; 719 + }; 720 + 721 + diff_updates.push(diff_bytes); 722 + last_diff_ref = Some(StrongRef::new().uri(uri.clone()).cid(cid.clone()).build()); 723 + } 724 + 725 + Ok(Some(PdsEditState { 726 + root_ref, 727 + last_diff_ref, 728 + root_snapshot, 729 + diff_updates, 730 + last_seen_diffs: HashMap::new(), 731 + doc_ref, 732 + })) 733 + } 734 + 735 + /// Load edit state from PDS for an entry (single root). 736 + pub async fn load_edit_state_from_entry<C>( 737 + client: &C, 738 + entry_uri: &AtUri<'_>, 739 + collaborator_dids: Vec<Did<'static>>, 740 + ) -> Result<Option<PdsEditState>, CrdtError> 741 + where 742 + C: WeaverExt, 743 + { 744 + let root_id = match find_all_edit_roots(client, entry_uri, collaborator_dids) 745 + .await? 746 + .into_iter() 747 + .next() 748 + { 749 + Some(id) => id, 750 + None => return Ok(None), 751 + }; 752 + 753 + load_edit_state_from_root_id(client, root_id, None).await 754 + } 755 + 756 + /// Load edit state from PDS for a draft. 757 + pub async fn load_edit_state_from_draft<C>( 758 + client: &C, 759 + draft_uri: &AtUri<'_>, 760 + ) -> Result<Option<PdsEditState>, CrdtError> 761 + where 762 + C: WeaverExt, 763 + { 764 + let root_id = match find_edit_root_for_draft(client, draft_uri).await? { 765 + Some(id) => id, 766 + None => return Ok(None), 767 + }; 768 + 769 + load_edit_state_from_root_id(client, root_id, None).await 770 + } 771 + 772 + /// Load and merge edit states from ALL collaborator repos. 773 + pub async fn load_all_edit_states<C>( 774 + client: &C, 775 + entry_uri: &AtUri<'_>, 776 + collaborator_dids: Vec<Did<'static>>, 777 + current_did: Option<&Did<'_>>, 778 + last_seen_diffs: &HashMap<AtUri<'static>, AtUri<'static>>, 779 + ) -> Result<Option<PdsEditState>, CrdtError> 780 + where 781 + C: WeaverExt, 782 + { 783 + let all_roots = find_all_edit_roots(client, entry_uri, collaborator_dids).await?; 784 + 785 + if all_roots.is_empty() { 786 + return Ok(None); 787 + } 788 + 789 + let merged_doc = LoroDoc::new(); 790 + let mut our_root_ref: Option<StrongRef<'static>> = None; 791 + let mut our_last_diff_ref: Option<StrongRef<'static>> = None; 792 + let mut merged_doc_ref: Option<DocRef<'static>> = None; 793 + let mut updated_last_seen = last_seen_diffs.clone(); 794 + 795 + for root_id in all_roots { 796 + let root_did = root_id.did.clone(); 797 + 798 + let root_uri = AtUri::new(&format_smolstr!( 799 + "at://{}/{}/{}", 800 + root_id.did, 801 + ROOT_NSID, 802 + root_id.rkey.as_ref() 803 + )) 804 + .ok() 805 + .map(|u| u.into_static()); 806 + 807 + let after_rkey = root_uri.as_ref().and_then(|uri| { 808 + last_seen_diffs 809 + .get(uri) 810 + .and_then(|diff_uri| diff_uri.rkey().map(|rk| rk.0.to_string())) 811 + }); 812 + 813 + if let Some(pds_state) = 814 + load_edit_state_from_root_id(client, root_id, after_rkey.as_deref()).await? 815 + { 816 + if let Err(e) = merged_doc.import(&pds_state.root_snapshot) { 817 + tracing::warn!("Failed to import root snapshot from {}: {:?}", root_did, e); 818 + continue; 819 + } 820 + 821 + for diff in &pds_state.diff_updates { 822 + if let Err(e) = merged_doc.import(diff) { 823 + tracing::warn!("Failed to import diff from {}: {:?}", root_did, e); 824 + } 825 + } 826 + 827 + if let (Some(uri), Some(last_diff)) = (&root_uri, &pds_state.last_diff_ref) { 828 + updated_last_seen.insert(uri.clone(), last_diff.uri.clone().into_static()); 829 + } 830 + 831 + if merged_doc_ref.is_none() { 832 + merged_doc_ref = Some(pds_state.doc_ref.clone()); 833 + } 834 + 835 + let is_our_root = current_did.is_some_and(|did| root_did == *did); 836 + 837 + if is_our_root { 838 + our_root_ref = Some(pds_state.root_ref); 839 + our_last_diff_ref = pds_state.last_diff_ref; 840 + } else if our_root_ref.is_none() { 841 + our_root_ref = Some(pds_state.root_ref); 842 + our_last_diff_ref = pds_state.last_diff_ref; 843 + } 844 + } 845 + } 846 + 847 + let merged_snapshot = merged_doc 848 + .export(ExportMode::Snapshot) 849 + .map_err(|e| CrdtError::Loro(format!("export merged: {}", e)))?; 850 + 851 + Ok(our_root_ref.map(|root_ref| PdsEditState { 852 + root_ref, 853 + last_diff_ref: our_last_diff_ref, 854 + root_snapshot: merged_snapshot.into(), 855 + diff_updates: vec![], 856 + last_seen_diffs: updated_last_seen, 857 + doc_ref: merged_doc_ref.expect("Should have doc_ref if we have root"), 858 + })) 859 + } 860 + 861 + /// Remote draft info from PDS. 862 + #[derive(Clone, Debug)] 863 + pub struct RemoteDraft { 864 + /// The draft record URI. 865 + pub uri: AtUri<'static>, 866 + /// The rkey (TID) of the draft. 867 + pub rkey: String, 868 + /// When the draft was created. 869 + pub created_at: String, 870 + } 871 + 872 + /// List all drafts for a user using weaver-index. 873 + #[cfg(feature = "use-index")] 874 + pub async fn list_drafts<C>(client: &C, did: &Did<'_>) -> Result<Vec<RemoteDraft>, CrdtError> 875 + where 876 + C: WeaverExt, 877 + { 878 + use jacquard::types::ident::AtIdentifier; 879 + use weaver_api::sh_weaver::edit::list_drafts::ListDrafts; 880 + 881 + let actor = AtIdentifier::Did(did.clone().into_static()); 882 + let response = client 883 + .send(ListDrafts::new().actor(actor).build()) 884 + .await 885 + .map_err(|e| CrdtError::Xrpc(format!("list drafts: {}", e)))?; 886 + 887 + let output = response 888 + .into_output() 889 + .map_err(|e| CrdtError::Xrpc(format!("parse list drafts: {}", e)))?; 890 + 891 + tracing::debug!("list_drafts (index): found {} drafts", output.drafts.len()); 892 + 893 + let drafts = output 894 + .drafts 895 + .into_iter() 896 + .filter_map(|draft| { 897 + let uri = AtUri::new(draft.uri.as_ref()).ok()?.into_static(); 898 + let rkey = uri.rkey()?.0.as_str().to_string(); 899 + let created_at = draft.created_at.to_string(); 900 + Some(RemoteDraft { 901 + uri, 902 + rkey, 903 + created_at, 904 + }) 905 + }) 906 + .collect(); 907 + 908 + Ok(drafts) 909 + } 910 + 911 + /// List all drafts for a user (direct PDS query, no index). 912 + #[cfg(not(feature = "use-index"))] 913 + pub async fn list_drafts<C>(client: &C, did: &Did<'_>) -> Result<Vec<RemoteDraft>, CrdtError> 914 + where 915 + C: WeaverExt, 916 + { 917 + use weaver_api::com_atproto::repo::list_records::ListRecords; 918 + 919 + let pds_url = client 920 + .pds_for_did(did) 921 + .await 922 + .map_err(|e| CrdtError::Xrpc(format!("resolve DID: {}", e)))?; 923 + 924 + let collection = 925 + Nsid::new(DRAFT_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; 926 + 927 + let request = ListRecords::new() 928 + .repo(did.clone()) 929 + .collection(collection) 930 + .limit(100) 931 + .build(); 932 + 933 + let response = client 934 + .xrpc(pds_url) 935 + .send(&request) 936 + .await 937 + .map_err(|e| CrdtError::Xrpc(format!("list records: {}", e)))?; 938 + 939 + let output = response 940 + .into_output() 941 + .map_err(|e| CrdtError::Xrpc(format!("parse list records: {}", e)))?; 942 + 943 + let mut drafts = Vec::new(); 944 + for record in output.records { 945 + let rkey = record 946 + .uri 947 + .rkey() 948 + .map(|r| r.0.as_str().to_string()) 949 + .unwrap_or_default(); 950 + 951 + let created_at = jacquard::from_data::<Draft>(&record.value) 952 + .map(|d| d.created_at.to_string()) 953 + .unwrap_or_default(); 954 + 955 + drafts.push(RemoteDraft { 956 + uri: record.uri.into_static(), 957 + rkey, 958 + created_at, 959 + }); 960 + } 961 + 962 + Ok(drafts) 963 + }
+11
crates/weaver-editor-crdt/src/worker/mod.rs
··· 1 + //! Worker implementation for off-main-thread CRDT operations. 2 + //! 3 + //! Currently WASM-specific using gloo-worker, but the core state machine 4 + //! could be abstracted to work with any async channel pair. 5 + 6 + mod reactor; 7 + 8 + pub use reactor::{WorkerInput, WorkerOutput}; 9 + 10 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 11 + pub use reactor::EditorReactor;
+165
docs/graph-data.json
··· 1605 1605 "created_at": "2026-01-06T13:29:10.289780508-05:00", 1606 1606 "updated_at": "2026-01-06T13:29:10.289780508-05:00", 1607 1607 "metadata_json": "{\"confidence\":100}" 1608 + }, 1609 + { 1610 + "id": 148, 1611 + "change_id": "ba10fea1-443c-4255-810a-1eaf0d5460eb", 1612 + "node_type": "observation", 1613 + "title": "Generic caching XrpcClient/Agent impl could be extracted to weaver-common - Fetcher's caching pattern is reusable", 1614 + "description": null, 1615 + "status": "pending", 1616 + "created_at": "2026-01-06T14:09:59.296473570-05:00", 1617 + "updated_at": "2026-01-06T14:09:59.296473570-05:00", 1618 + "metadata_json": "{\"confidence\":70}" 1619 + }, 1620 + { 1621 + "id": 149, 1622 + "change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", 1623 + "node_type": "decision", 1624 + "title": "weaver-editor-crdt crate design - generic CRDT/sync layer for AT Protocol apps", 1625 + "description": null, 1626 + "status": "pending", 1627 + "created_at": "2026-01-06T14:12:12.637577921-05:00", 1628 + "updated_at": "2026-01-06T14:12:12.637577921-05:00", 1629 + "metadata_json": "{\"confidence\":85}" 1630 + }, 1631 + { 1632 + "id": 150, 1633 + "change_id": "9b277ea3-0d58-4d30-8b81-2a589aed2942", 1634 + "node_type": "action", 1635 + "title": "Design documented in plans/2026-01-06-weaver-editor-crdt-design.md", 1636 + "description": null, 1637 + "status": "pending", 1638 + "created_at": "2026-01-06T14:12:12.655230849-05:00", 1639 + "updated_at": "2026-01-06T14:12:12.655230849-05:00", 1640 + "metadata_json": "{\"confidence\":95}" 1641 + }, 1642 + { 1643 + "id": 151, 1644 + "change_id": "601d0155-f773-4c6d-af26-aa45234d8b2a", 1645 + "node_type": "action", 1646 + "title": "WIP: Scaffolding weaver-editor-crdt crate - buffer.rs, document.rs, sync.rs created", 1647 + "description": null, 1648 + "status": "pending", 1649 + "created_at": "2026-01-06T14:17:26.809060420-05:00", 1650 + "updated_at": "2026-01-06T14:17:26.809060420-05:00", 1651 + "metadata_json": "{\"confidence\":75}" 1652 + }, 1653 + { 1654 + "id": 152, 1655 + "change_id": "321430d0-ecea-41db-baa3-48b146fb6cf2", 1656 + "node_type": "outcome", 1657 + "title": "weaver-editor-crdt scaffolded and compiling - buffer.rs, document.rs, sync.rs, worker/mod.rs", 1658 + "description": null, 1659 + "status": "pending", 1660 + "created_at": "2026-01-06T14:19:37.822498862-05:00", 1661 + "updated_at": "2026-01-06T14:19:37.822498862-05:00", 1662 + "metadata_json": "{\"confidence\":90}" 1663 + }, 1664 + { 1665 + "id": 153, 1666 + "change_id": "f357a30f-0000-4b09-bb35-fe6c23b1dccf", 1667 + "node_type": "outcome", 1668 + "title": "weaver-editor-crdt complete - LoroTextBuffer, CrdtDocument, sync, EditorReactor worker all extracted and building", 1669 + "description": null, 1670 + "status": "pending", 1671 + "created_at": "2026-01-06T14:26:25.977005171-05:00", 1672 + "updated_at": "2026-01-06T14:26:25.977005171-05:00", 1673 + "metadata_json": "{\"confidence\":95}" 1674 + }, 1675 + { 1676 + "id": 154, 1677 + "change_id": "ca300515-473e-4d26-b08e-24279d8d8ce7", 1678 + "node_type": "action", 1679 + "title": "Fixing EnvFilter error in editor_worker binary - adding env-filter feature to tracing-subscriber", 1680 + "description": null, 1681 + "status": "pending", 1682 + "created_at": "2026-01-06T14:31:53.901825123-05:00", 1683 + "updated_at": "2026-01-06T14:31:53.901825123-05:00", 1684 + "metadata_json": "{\"confidence\":90}" 1685 + }, 1686 + { 1687 + "id": 155, 1688 + "change_id": "8d336294-6ea4-4112-9120-99ba857836e7", 1689 + "node_type": "goal", 1690 + "title": "Extract worker manager from collab.rs to weaver-editor-crdt - non-Dioxus coordination logic", 1691 + "description": null, 1692 + "status": "pending", 1693 + "created_at": "2026-01-06T14:34:17.012426477-05:00", 1694 + "updated_at": "2026-01-06T14:34:17.012426477-05:00", 1695 + "metadata_json": "{\"confidence\":85}" 1696 + }, 1697 + { 1698 + "id": 156, 1699 + "change_id": "93f2714e-aacc-4982-88ae-69f3a06eb195", 1700 + "node_type": "outcome", 1701 + "title": "Worker build working - EditorReactor moved to weaver-editor-crdt, imports fixed in weaver-app", 1702 + "description": null, 1703 + "status": "pending", 1704 + "created_at": "2026-01-06T14:36:23.710415853-05:00", 1705 + "updated_at": "2026-01-06T14:36:23.710415853-05:00", 1706 + "metadata_json": "{\"confidence\":95}" 1608 1707 } 1609 1708 ], 1610 1709 "edges": [ ··· 3312 3411 "weight": 1.0, 3313 3412 "rationale": "Decision outcome", 3314 3413 "created_at": "2026-01-06T13:29:10.392856789-05:00" 3414 + }, 3415 + { 3416 + "id": 157, 3417 + "from_node_id": 149, 3418 + "to_node_id": 150, 3419 + "from_change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", 3420 + "to_change_id": "9b277ea3-0d58-4d30-8b81-2a589aed2942", 3421 + "edge_type": "leads_to", 3422 + "weight": 1.0, 3423 + "rationale": "Design written to plans file", 3424 + "created_at": "2026-01-06T14:12:18.629424162-05:00" 3425 + }, 3426 + { 3427 + "id": 158, 3428 + "from_node_id": 149, 3429 + "to_node_id": 148, 3430 + "from_change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", 3431 + "to_change_id": "ba10fea1-443c-4255-810a-1eaf0d5460eb", 3432 + "edge_type": "leads_to", 3433 + "weight": 1.0, 3434 + "rationale": "Related future work identified during design", 3435 + "created_at": "2026-01-06T14:12:18.645517562-05:00" 3436 + }, 3437 + { 3438 + "id": 159, 3439 + "from_node_id": 151, 3440 + "to_node_id": 152, 3441 + "from_change_id": "601d0155-f773-4c6d-af26-aa45234d8b2a", 3442 + "to_change_id": "321430d0-ecea-41db-baa3-48b146fb6cf2", 3443 + "edge_type": "leads_to", 3444 + "weight": 1.0, 3445 + "rationale": "Scaffold completed", 3446 + "created_at": "2026-01-06T14:19:37.838628283-05:00" 3447 + }, 3448 + { 3449 + "id": 160, 3450 + "from_node_id": 149, 3451 + "to_node_id": 153, 3452 + "from_change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", 3453 + "to_change_id": "f357a30f-0000-4b09-bb35-fe6c23b1dccf", 3454 + "edge_type": "leads_to", 3455 + "weight": 1.0, 3456 + "rationale": "Implementation complete", 3457 + "created_at": "2026-01-06T14:26:25.993259775-05:00" 3458 + }, 3459 + { 3460 + "id": 161, 3461 + "from_node_id": 154, 3462 + "to_node_id": 155, 3463 + "from_change_id": "ca300515-473e-4d26-b08e-24279d8d8ce7", 3464 + "to_change_id": "8d336294-6ea4-4112-9120-99ba857836e7", 3465 + "edge_type": "leads_to", 3466 + "weight": 1.0, 3467 + "rationale": "Part of same extraction session", 3468 + "created_at": "2026-01-06T14:34:17.133444402-05:00" 3469 + }, 3470 + { 3471 + "id": 162, 3472 + "from_node_id": 154, 3473 + "to_node_id": 156, 3474 + "from_change_id": "ca300515-473e-4d26-b08e-24279d8d8ce7", 3475 + "to_change_id": "93f2714e-aacc-4982-88ae-69f3a06eb195", 3476 + "edge_type": "leads_to", 3477 + "weight": 1.0, 3478 + "rationale": "Completed fix", 3479 + "created_at": "2026-01-06T14:36:23.726201054-05:00" 3315 3480 } 3316 3481 ] 3317 3482 }