more resiliency

Orual f79bbd8d cd388c2e

+529 -102
+14
crates/weaver-app/assets/styling/entry-card.css
··· 111 margin-left: auto; 112 } 113 114 .entry-card-tags { 115 display: flex; 116 gap: 0.4rem; 117 flex-wrap: wrap; 118 } 119 120 .entry-card-tag {
··· 111 margin-left: auto; 112 } 113 114 + .entry-card-stats { 115 + display: flex; 116 + gap: 0.5rem; 117 + font-size: 0.75rem; 118 + color: var(--color-muted); 119 + margin-top: 0.5rem; 120 + } 121 + 122 + .entry-card-stats .word-count::after { 123 + content: "·"; 124 + margin-left: 0.5rem; 125 + } 126 + 127 .entry-card-tags { 128 display: flex; 129 gap: 0.4rem; 130 flex-wrap: wrap; 131 + margin-top: 0.5rem; 132 } 133 134 .entry-card-tag {
+25 -4
crates/weaver-app/assets/styling/entry.css
··· 100 101 /* Entry metadata header */ 102 .entry-metadata { 103 - margin-bottom: calc(2rem * var(--spacing-scale, 1.5)); 104 - padding-bottom: calc(1rem * var(--spacing-scale, 1.5)); 105 border-bottom: 2px solid var(--color-border); 106 } 107 ··· 122 .entry-meta-info { 123 display: flex; 124 flex-wrap: wrap; 125 - gap: calc(1.5rem * var(--spacing-scale, 1.5)); 126 font-size: 0.95rem; 127 color: var(--color-subtle); 128 } 129 130 .entry-authors, 131 .entry-date, 132 - .entry-tags { 133 display: flex; 134 align-items: center; 135 font-family: var(--font-ui); 136 gap: 0.5rem; 137 } 138 139 .entry-date { 140 margin-left: auto; 141 font-weight: 400; 142 color: var(--color-subtle); 143 } 144 ··· 157 .author-name:hover { 158 color: var(--color-emphasis); 159 text-decoration: underline; 160 } 161 162 .entry-tags {
··· 100 101 /* Entry metadata header */ 102 .entry-metadata { 103 + margin-bottom: calc(1rem * var(--spacing-scale, 1.5)); 104 + padding-bottom: calc(0.5rem * var(--spacing-scale, 1.5)); 105 border-bottom: 2px solid var(--color-border); 106 } 107 ··· 122 .entry-meta-info { 123 display: flex; 124 flex-wrap: wrap; 125 + gap: calc(0.25rem * var(--spacing-scale, 1.5)) calc(1rem * var(--spacing-scale, 1.5)); 126 font-size: 0.95rem; 127 color: var(--color-subtle); 128 } 129 130 .entry-authors, 131 .entry-date, 132 + .entry-tags, 133 + .entry-reading-stats { 134 display: flex; 135 align-items: center; 136 font-family: var(--font-ui); 137 gap: 0.5rem; 138 } 139 140 + .entry-reading-stats { 141 + color: var(--color-subtle); 142 + margin-left: auto; 143 + margin-top: 0.25rem; 144 + } 145 + 146 + .entry-reading-stats .word-count::after { 147 + content: "·"; 148 + margin-left: 0.5rem; 149 + } 150 + 151 .entry-date { 152 margin-left: auto; 153 font-weight: 400; 154 + align-items: last baseline; 155 color: var(--color-subtle); 156 } 157 ··· 170 .author-name:hover { 171 color: var(--color-emphasis); 172 text-decoration: underline; 173 + } 174 + 175 + .entry-meta-secondary { 176 + display: flex; 177 + flex-wrap: wrap; 178 + align-items: center; 179 + gap: 0.5rem 1rem; 180 + flex-basis: 100%; 181 } 182 183 .entry-tags {
+14 -14
crates/weaver-app/public/editor_worker.js
··· 364 const ret = arg0.node; 365 return ret; 366 }; 367 - imports.wbg.__wbg_now_8a87c5466cc7d560 = function() { 368 - const ret = Date.now(); 369 - return ret; 370 - }; 371 imports.wbg.__wbg_now_8cf15d6e317793e1 = function(arg0) { 372 const ret = arg0.now(); 373 return ret; 374 }; 375 imports.wbg.__wbg_performance_c77a440eff2efd9b = function(arg0) { ··· 442 const ret = arg0.versions; 443 return ret; 444 }; 445 - imports.wbg.__wbindgen_cast_1511eb630aa228f5 = function(arg0, arg1) { 446 - // Cast intrinsic for `Closure(Closure { dtor_idx: 940, function: Function { arguments: [Externref], shim_idx: 941, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 447 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 448 - return ret; 449 - }; 450 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 451 // Cast intrinsic for `Ref(String) -> Externref`. 452 const ret = getStringFromWasm0(arg0, arg1); 453 return ret; 454 }; 455 - imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 456 - // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 457 - const ret = getArrayU8FromWasm0(arg0, arg1); 458 return ret; 459 }; 460 - imports.wbg.__wbindgen_cast_ce6245619dc560a7 = function(arg0, arg1) { 461 - // Cast intrinsic for `Closure(Closure { dtor_idx: 102, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 103, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 462 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 463 return ret; 464 }; 465 imports.wbg.__wbindgen_init_externref_table = function() {
··· 364 const ret = arg0.node; 365 return ret; 366 }; 367 imports.wbg.__wbg_now_8cf15d6e317793e1 = function(arg0) { 368 const ret = arg0.now(); 369 + return ret; 370 + }; 371 + imports.wbg.__wbg_now_c8bdc8efc8c495eb = function() { 372 + const ret = Date.now(); 373 return ret; 374 }; 375 imports.wbg.__wbg_performance_c77a440eff2efd9b = function(arg0) { ··· 442 const ret = arg0.versions; 443 return ret; 444 }; 445 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 446 // Cast intrinsic for `Ref(String) -> Externref`. 447 const ret = getStringFromWasm0(arg0, arg1); 448 return ret; 449 }; 450 + imports.wbg.__wbindgen_cast_3fda284bdcf7704e = function(arg0, arg1) { 451 + // Cast intrinsic for `Closure(Closure { dtor_idx: 941, function: Function { arguments: [Externref], shim_idx: 942, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 452 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 453 return ret; 454 }; 455 + imports.wbg.__wbindgen_cast_56c1ebb4e8528c2a = function(arg0, arg1) { 456 + // Cast intrinsic for `Closure(Closure { dtor_idx: 132, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 133, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 457 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 458 + return ret; 459 + }; 460 + imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 461 + // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 462 + const ret = getArrayU8FromWasm0(arg0, arg1); 463 return ret; 464 }; 465 imports.wbg.__wbindgen_init_externref_table = function() {
+26 -26
crates/weaver-app/public/embed_worker.js
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 236 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 237 } 238 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 240 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 241 - } 242 - 243 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 244 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 245 } 246 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { ··· 362 imports.wbg.__wbg_clearTimeout_15dfc3d1dcb635c6 = function() { return handleError(function (arg0, arg1) { 363 arg0.clearTimeout(arg1); 364 }, arguments) }; 365 - imports.wbg.__wbg_clearTimeout_7a42b49784aea641 = function(arg0) { 366 const ret = clearTimeout(arg0); 367 return ret; 368 }; ··· 388 wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 389 } 390 }; 391 - imports.wbg.__wbg_fetch_74a3e84ebd2c9a0e = function(arg0) { 392 - const ret = fetch(arg0); 393 - return ret; 394 - }; 395 imports.wbg.__wbg_fetch_90447c28cc0b095e = function(arg0, arg1) { 396 const ret = arg0.fetch(arg1); 397 return ret; 398 }; 399 imports.wbg.__wbg_getTime_ad1e9878a735af08 = function(arg0) { ··· 517 const ret = Promise.resolve(arg0); 518 return ret; 519 }; 520 imports.wbg.__wbg_setTimeout_4eb823e8b72fbe79 = function() { return handleError(function (arg0, arg1, arg2) { 521 const ret = arg0.setTimeout(arg1, arg2); 522 return ret; 523 }, arguments) }; 524 - imports.wbg.__wbg_setTimeout_7bb3429662ab1e70 = function(arg0, arg1) { 525 - const ret = setTimeout(arg0, arg1); 526 - return ret; 527 - }; 528 imports.wbg.__wbg_set_body_8e743242d6076a4f = function(arg0, arg1) { 529 arg0.body = arg1; 530 }; ··· 607 const ret = arg0.value; 608 return ret; 609 }; 610 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 611 // Cast intrinsic for `Ref(String) -> Externref`. 612 const ret = getStringFromWasm0(arg0, arg1); 613 return ret; 614 }; 615 - imports.wbg.__wbindgen_cast_36dddc5933837ecc = function(arg0, arg1) { 616 - // Cast intrinsic for `Closure(Closure { dtor_idx: 2216, function: Function { arguments: [Externref], shim_idx: 2217, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 617 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 618 - return ret; 619 - }; 620 - imports.wbg.__wbindgen_cast_3eeb44a0158730cb = function(arg0, arg1) { 621 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1188, function: Function { arguments: [], shim_idx: 1189, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 622 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 623 return ret; 624 }; 625 - imports.wbg.__wbindgen_cast_b5fa8180acb99032 = function(arg0, arg1) { 626 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1448, function: Function { arguments: [], shim_idx: 1449, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 627 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 628 return ret; 629 }; 630 - imports.wbg.__wbindgen_cast_d976f3c9b97a6409 = function(arg0, arg1) { 631 - // Cast intrinsic for `Closure(Closure { dtor_idx: 272, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 273, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 632 - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 633 return ret; 634 }; 635 imports.wbg.__wbindgen_init_externref_table = function() {
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 236 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 237 + } 238 + 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 240 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 241 } 242 243 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 244 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 245 } 246 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { ··· 362 imports.wbg.__wbg_clearTimeout_15dfc3d1dcb635c6 = function() { return handleError(function (arg0, arg1) { 363 arg0.clearTimeout(arg1); 364 }, arguments) }; 365 + imports.wbg.__wbg_clearTimeout_3b6a9a13d4bde075 = function(arg0) { 366 const ret = clearTimeout(arg0); 367 return ret; 368 }; ··· 388 wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 389 } 390 }; 391 imports.wbg.__wbg_fetch_90447c28cc0b095e = function(arg0, arg1) { 392 const ret = arg0.fetch(arg1); 393 + return ret; 394 + }; 395 + imports.wbg.__wbg_fetch_df3fa17a5772dafb = function(arg0) { 396 + const ret = fetch(arg0); 397 return ret; 398 }; 399 imports.wbg.__wbg_getTime_ad1e9878a735af08 = function(arg0) { ··· 517 const ret = Promise.resolve(arg0); 518 return ret; 519 }; 520 + imports.wbg.__wbg_setTimeout_35a07631c669fbee = function(arg0, arg1) { 521 + const ret = setTimeout(arg0, arg1); 522 + return ret; 523 + }; 524 imports.wbg.__wbg_setTimeout_4eb823e8b72fbe79 = function() { return handleError(function (arg0, arg1, arg2) { 525 const ret = arg0.setTimeout(arg1, arg2); 526 return ret; 527 }, arguments) }; 528 imports.wbg.__wbg_set_body_8e743242d6076a4f = function(arg0, arg1) { 529 arg0.body = arg1; 530 }; ··· 607 const ret = arg0.value; 608 return ret; 609 }; 610 + imports.wbg.__wbindgen_cast_067e3c0104867449 = function(arg0, arg1) { 611 + // Cast intrinsic for `Closure(Closure { dtor_idx: 225, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 226, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 612 + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 613 + return ret; 614 + }; 615 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 616 // Cast intrinsic for `Ref(String) -> Externref`. 617 const ret = getStringFromWasm0(arg0, arg1); 618 return ret; 619 }; 620 + imports.wbg.__wbindgen_cast_4c40eebfb345262b = function(arg0, arg1) { 621 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1169, function: Function { arguments: [], shim_idx: 1170, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 622 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 623 return ret; 624 }; 625 + imports.wbg.__wbindgen_cast_ba06f0048889102c = function(arg0, arg1) { 626 + // Cast intrinsic for `Closure(Closure { dtor_idx: 2202, function: Function { arguments: [Externref], shim_idx: 2203, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 627 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 628 return ret; 629 }; 630 + imports.wbg.__wbindgen_cast_dd8568660229206d = function(arg0, arg1) { 631 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1434, function: Function { arguments: [], shim_idx: 1435, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 632 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 633 return ret; 634 }; 635 imports.wbg.__wbindgen_init_externref_table = function() {
+4
crates/weaver-app/src/components/author_list/author.css
··· 35 color: inherit; 36 } 37 38 .author-block:hover .embed-author-name { 39 color: var(--color-secondary); 40 }
··· 35 color: inherit; 36 } 37 38 + .author-block .embed-author { 39 + padding-bottom: 0px !important; 40 + } 41 + 42 .author-block:hover .embed-author-name { 43 color: var(--color-secondary); 44 }
+56 -14
crates/weaver-app/src/components/entry.rs
··· 81 } 82 } 83 84 /// Extract a plain-text preview from markdown content (first ~160 chars) 85 pub fn extract_preview(content: &str, max_len: usize) -> String { 86 // Simple extraction: skip markdown syntax, get plain text ··· 232 // Main content area 233 div { class: "entry-content-main notebook-content", 234 // Metadata header 235 - EntryMetadata { 236 - entry_view: entry_view.clone(), 237 - created_at: entry_record().created_at.clone(), 238 - entry_uri: entry_view.uri.clone().into_static(), 239 - book_title: Some(book_title()), 240 - ident: ident() 241 } 242 243 // Rendered markdown ··· 339 html_buf 340 }); 341 342 rsx! { 343 div { class: "entry-card", 344 div { class: "entry-card-meta", ··· 388 } 389 } 390 } 391 } 392 } 393 } ··· 463 html_buf 464 }; 465 466 rsx! { 467 div { class: "entry-card feed-entry-card", 468 // Header: title (and date if no author) ··· 518 } 519 } 520 } 521 } 522 } 523 } 524 } 525 526 - /// Metadata header showing title, authors, date, tags 527 #[component] 528 pub fn EntryMetadata( 529 entry_view: EntryView<'static>, ··· 531 entry_uri: AtUri<'static>, 532 book_title: Option<SmolStr>, 533 ident: AtIdentifier<'static>, 534 ) -> Element { 535 let navigator = use_navigator(); 536 ··· 592 } 593 } 594 595 - // Tags 596 - if let Some(ref tags) = entry_view.tags { 597 - div { class: "entry-tags", 598 - // TODO: Parse tags structure 599 - span { class: "meta-label", "Tags:" } 600 - for tag in tags.iter() { 601 - span { class: "entry-tag", "{tag}" } 602 } 603 } 604 }
··· 81 } 82 } 83 84 + /// Calculate word count and estimated reading time (in minutes) for content 85 + pub fn calculate_reading_stats(content: &str) -> (usize, usize) { 86 + let word_count = content.split_whitespace().count(); 87 + let reading_time_mins = (word_count + 199) / 200; // ~200 wpm, rounded up 88 + (word_count, reading_time_mins.max(1)) 89 + } 90 + 91 /// Extract a plain-text preview from markdown content (first ~160 chars) 92 pub fn extract_preview(content: &str, max_len: usize) -> String { 93 // Simple extraction: skip markdown syntax, get plain text ··· 239 // Main content area 240 div { class: "entry-content-main notebook-content", 241 // Metadata header 242 + { 243 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content); 244 + rsx! { 245 + EntryMetadata { 246 + entry_view: entry_view.clone(), 247 + created_at: entry_record().created_at.clone(), 248 + entry_uri: entry_view.uri.clone().into_static(), 249 + book_title: Some(book_title()), 250 + ident: ident(), 251 + word_count: Some(word_count), 252 + reading_time_mins: Some(reading_time_mins) 253 + } 254 + } 255 } 256 257 // Rendered markdown ··· 353 html_buf 354 }); 355 356 + // Calculate reading stats 357 + let reading_stats = parsed_entry 358 + .as_ref() 359 + .map(|entry| calculate_reading_stats(&entry.content)); 360 + 361 rsx! { 362 div { class: "entry-card", 363 div { class: "entry-card-meta", ··· 407 } 408 } 409 } 410 + if let Some((words, mins)) = reading_stats { 411 + div { class: "entry-card-stats", 412 + span { class: "word-count", "{words} words" } 413 + span { class: "reading-time", "{mins} min read" } 414 + } 415 + } 416 } 417 } 418 } ··· 488 html_buf 489 }; 490 491 + // Calculate reading stats 492 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry.content); 493 + 494 rsx! { 495 div { class: "entry-card feed-entry-card", 496 // Header: title (and date if no author) ··· 546 } 547 } 548 } 549 + } 550 + div { class: "entry-card-stats", 551 + span { class: "word-count", "{word_count} words" } 552 + span { class: "reading-time", "{reading_time_mins} min read" } 553 } 554 } 555 } 556 } 557 558 + /// Metadata header showing title, authors, date, tags, reading stats 559 #[component] 560 pub fn EntryMetadata( 561 entry_view: EntryView<'static>, ··· 563 entry_uri: AtUri<'static>, 564 book_title: Option<SmolStr>, 565 ident: AtIdentifier<'static>, 566 + #[props(default)] word_count: Option<usize>, 567 + #[props(default)] reading_time_mins: Option<usize>, 568 ) -> Element { 569 let navigator = use_navigator(); 570 ··· 626 } 627 } 628 629 + // Tags and reading stats on their own line 630 + div { class: "entry-meta-secondary", 631 + if let Some(ref tags) = entry_view.tags { 632 + div { class: "entry-tags", 633 + span { class: "meta-label", "Tags:" } 634 + for tag in tags.iter() { 635 + span { class: "entry-tag", "{tag}" } 636 + } 637 + } 638 + } 639 + 640 + if let (Some(words), Some(mins)) = (word_count, reading_time_mins) { 641 + div { class: "entry-reading-stats", 642 + span { class: "word-count", "{words} words" } 643 + span { class: "reading-time", "{mins} min read" } 644 } 645 } 646 }
+1 -1
crates/weaver-app/src/components/mod.rs
··· 9 #[allow(unused_imports)] 10 pub use entry::{ 11 ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, FeedEntryCard, 12 - NavButton, extract_preview, 13 }; 14 15 pub mod identity;
··· 9 #[allow(unused_imports)] 10 pub use entry::{ 11 ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, FeedEntryCard, 12 + NavButton, calculate_reading_stats, extract_preview, 13 }; 14 15 pub mod identity;
+41 -20
crates/weaver-app/src/views/entry.rs
··· 14 rkey: ReadSignal<SmolStr>, 15 ) -> Element { 16 use crate::components::{ 17 - ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, extract_preview, 18 }; 19 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 20 ··· 90 } 91 92 div { class: "entry-content-main notebook-content", 93 - EntryMetadata { 94 - entry_view: entry_view.clone(), 95 - created_at: entry_record.created_at.clone(), 96 - entry_uri: entry_view.uri.clone(), 97 - book_title: Some(book_title.clone()), 98 - ident: ident() 99 } 100 EntryMarkdown { content: entry_signal, ident } 101 } ··· 128 129 div { class: "entry-page-layout", 130 div { class: "entry-content-main notebook-content", 131 - EntryMetadata { 132 - entry_view: entry_view.clone(), 133 - created_at: entry_record.created_at.clone(), 134 - entry_uri: entry_view.uri.clone(), 135 - book_title: None, 136 - ident: ident() 137 } 138 EntryMarkdown { content: entry_signal, ident } 139 } ··· 153 rkey: ReadSignal<SmolStr>, 154 ) -> Element { 155 use crate::components::{ 156 - ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, extract_preview, 157 }; 158 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 159 ··· 233 } 234 235 div { class: "entry-content-main notebook-content", 236 - EntryMetadata { 237 - entry_view: entry_view.clone(), 238 - created_at: entry_record.created_at.clone(), 239 - entry_uri: entry_view.uri.clone(), 240 - book_title: Some(book_title()), 241 - ident: ident() 242 } 243 EntryMarkdown { content: entry_signal, ident } 244 }
··· 14 rkey: ReadSignal<SmolStr>, 15 ) -> Element { 16 use crate::components::{ 17 + ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, calculate_reading_stats, extract_preview, 18 }; 19 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 20 ··· 90 } 91 92 div { class: "entry-content-main notebook-content", 93 + { 94 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record.content); 95 + rsx! { 96 + EntryMetadata { 97 + entry_view: entry_view.clone(), 98 + created_at: entry_record.created_at.clone(), 99 + entry_uri: entry_view.uri.clone(), 100 + book_title: Some(book_title.clone()), 101 + ident: ident(), 102 + word_count: Some(word_count), 103 + reading_time_mins: Some(reading_time_mins) 104 + } 105 + } 106 } 107 EntryMarkdown { content: entry_signal, ident } 108 } ··· 135 136 div { class: "entry-page-layout", 137 div { class: "entry-content-main notebook-content", 138 + { 139 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record.content); 140 + rsx! { 141 + EntryMetadata { 142 + entry_view: entry_view.clone(), 143 + created_at: entry_record.created_at.clone(), 144 + entry_uri: entry_view.uri.clone(), 145 + book_title: None, 146 + ident: ident(), 147 + word_count: Some(word_count), 148 + reading_time_mins: Some(reading_time_mins) 149 + } 150 + } 151 } 152 EntryMarkdown { content: entry_signal, ident } 153 } ··· 167 rkey: ReadSignal<SmolStr>, 168 ) -> Element { 169 use crate::components::{ 170 + ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, calculate_reading_stats, extract_preview, 171 }; 172 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 173 ··· 247 } 248 249 div { class: "entry-content-main notebook-content", 250 + { 251 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record.content); 252 + rsx! { 253 + EntryMetadata { 254 + entry_view: entry_view.clone(), 255 + created_at: entry_record.created_at.clone(), 256 + entry_uri: entry_view.uri.clone(), 257 + book_title: Some(book_title()), 258 + ident: ident(), 259 + word_count: Some(word_count), 260 + reading_time_mins: Some(reading_time_mins) 261 + } 262 + } 263 } 264 EntryMarkdown { content: entry_signal, ident } 265 }
+1 -1
crates/weaver-index/migrations/clickhouse/012_profiles_weaver_mv.sql
··· 8 coalesce(record.description, '') as description, 9 coalesce(record.avatar.ref.`$link`, '') as avatar_cid, 10 coalesce(record.banner.ref.`$link`, '') as banner_cid, 11 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 12 event_time, 13 now64(3) as indexed_at, 14 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 8 coalesce(record.description, '') as description, 9 coalesce(record.avatar.ref.`$link`, '') as avatar_cid, 10 coalesce(record.banner.ref.`$link`, '') as banner_cid, 11 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 12 event_time, 13 now64(3) as indexed_at, 14 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/014_profiles_bsky_mv.sql
··· 8 coalesce(record.description, '') as description, 9 coalesce(record.avatar.ref.`$link`, '') as avatar_cid, 10 coalesce(record.banner.ref.`$link`, '') as banner_cid, 11 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 12 event_time, 13 now64(3) as indexed_at, 14 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 8 coalesce(record.description, '') as description, 9 coalesce(record.avatar.ref.`$link`, '') as avatar_cid, 10 coalesce(record.banner.ref.`$link`, '') as banner_cid, 11 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 12 event_time, 13 now64(3) as indexed_at, 14 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/018_notebooks_mv.sql
··· 12 if(record.publishGlobal = true, 1, 0) as publish_global, 13 arrayMap(x -> JSONExtractString(x, 'did'), JSONExtractArrayRaw(toString(record), 'authors')) as author_dids, 14 length(JSONExtractArrayRaw(toString(record), 'entryList')) as entry_count, 15 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 16 - coalesce(toDateTime64(record.updatedAt, 3), toDateTime64(0, 3)) as updated_at, 17 event_time, 18 now64(3) as indexed_at, 19 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 12 if(record.publishGlobal = true, 1, 0) as publish_global, 13 arrayMap(x -> JSONExtractString(x, 'did'), JSONExtractArrayRaw(toString(record), 'authors')) as author_dids, 14 length(JSONExtractArrayRaw(toString(record), 'entryList')) as entry_count, 15 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 16 + parseDateTime64BestEffortOrZero(toString(record.updatedAt), 3) as updated_at, 17 event_time, 18 now64(3) as indexed_at, 19 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/021_entries_mv.sql
··· 11 JSONExtract(toString(record), 'tags', 'Array(String)') as tags, 12 arrayMap(x -> JSONExtractString(x, 'did'), JSONExtractArrayRaw(toString(record), 'authors')) as author_dids, 13 substring(coalesce(record.content, ''), 1, 500) as content_preview, 14 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 15 - coalesce(toDateTime64(record.updatedAt, 3), toDateTime64(0, 3)) as updated_at, 16 event_time, 17 now64(3) as indexed_at, 18 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 11 JSONExtract(toString(record), 'tags', 'Array(String)') as tags, 12 arrayMap(x -> JSONExtractString(x, 'did'), JSONExtractArrayRaw(toString(record), 'authors')) as author_dids, 13 substring(coalesce(record.content, ''), 1, 500) as content_preview, 14 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 15 + parseDateTime64BestEffortOrZero(toString(record.updatedAt), 3) as updated_at, 16 event_time, 17 now64(3) as indexed_at, 18 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/024_drafts_mv.sql
··· 5 did, 6 rkey, 7 cid, 8 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 9 event_time, 10 now64(3) as indexed_at, 11 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 5 did, 6 rkey, 7 cid, 8 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 9 event_time, 10 now64(3) as indexed_at, 11 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/027_edit_diffs_mv.sql
··· 60 if(length(toString(record.inlineDiff)) > 0, 1, 0) as has_inline_diff, 61 if(toString(record.snapshot.ref.`$link`) != '', 1, 0) as has_snapshot, 62 63 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 64 event_time, 65 now64(3) as indexed_at, 66 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 60 if(length(toString(record.inlineDiff)) > 0, 1, 0) as has_inline_diff, 61 if(toString(record.snapshot.ref.`$link`) != '', 1, 0) as has_snapshot, 62 63 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 64 event_time, 65 now64(3) as indexed_at, 66 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/030_collab_invites_mv.sql
··· 14 toString(record.invitee) as invitee_did, 15 coalesce(toString(record.scope), '') as scope, 16 coalesce(toString(record.message), '') as message, 17 - coalesce(toDateTime64(record.expiresAt, 3), toDateTime64(0, 3)) as expires_at, 18 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 19 event_time, 20 now64(3) as indexed_at, 21 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 14 toString(record.invitee) as invitee_did, 15 coalesce(toString(record.scope), '') as scope, 16 coalesce(toString(record.message), '') as message, 17 + parseDateTime64BestEffortOrZero(toString(record.expiresAt), 3) as expires_at, 18 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 19 event_time, 20 now64(3) as indexed_at, 21 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/032_collab_accepts_mv.sql
··· 16 splitByChar('/', replaceOne(toString(record.resource), 'at://', ''))[2] as resource_collection, 17 splitByChar('/', replaceOne(toString(record.resource), 'at://', ''))[3] as resource_rkey, 18 19 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 20 event_time, 21 now64(3) as indexed_at, 22 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 16 splitByChar('/', replaceOne(toString(record.resource), 'at://', ''))[2] as resource_collection, 17 splitByChar('/', replaceOne(toString(record.resource), 'at://', ''))[3] as resource_rkey, 18 19 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 20 event_time, 21 now64(3) as indexed_at, 22 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/034_collab_sessions_mv.sql
··· 13 14 toString(record.nodeId) as node_id, 15 coalesce(toString(record.relayUrl), '') as relay_url, 16 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 17 - coalesce(toDateTime64(record.expiresAt, 3), toDateTime64(0, 3)) as expires_at, 18 event_time, 19 now64(3) as indexed_at, 20 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
··· 13 14 toString(record.nodeId) as node_id, 15 coalesce(toString(record.relayUrl), '') as relay_url, 16 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 17 + parseDateTime64BestEffortOrZero(toString(record.expiresAt), 3) as expires_at, 18 event_time, 19 now64(3) as indexed_at, 20 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2
crates/weaver-index/src/clickhouse.rs
··· 1 mod client; 2 mod migrations; 3 mod schema; 4 5 pub use client::{Client, TableSize}; 6 pub use migrations::{DbObject, MigrationResult, Migrator, ObjectType}; 7 pub use schema::{ 8 AccountRevState, FirehoseCursor, RawAccountEvent, RawEventDlq, RawIdentityEvent, 9 RawRecordInsert, Tables,
··· 1 mod client; 2 mod migrations; 3 + mod resilient_inserter; 4 mod schema; 5 6 pub use client::{Client, TableSize}; 7 pub use migrations::{DbObject, MigrationResult, Migrator, ObjectType}; 8 + pub use resilient_inserter::{InserterConfig, ResilientRecordInserter}; 9 pub use schema::{ 10 AccountRevState, FirehoseCursor, RawAccountEvent, RawEventDlq, RawIdentityEvent, 11 RawRecordInsert, Tables,
+326
crates/weaver-index/src/clickhouse/resilient_inserter.rs
···
··· 1 + use std::time::Duration; 2 + 3 + use clickhouse::inserter::{Inserter, Quantities}; 4 + use smol_str::{SmolStr, ToSmolStr}; 5 + use tracing::{debug, warn}; 6 + 7 + use super::schema::{RawEventDlq, RawRecordInsert, Tables}; 8 + use crate::error::{ClickHouseError, IndexError}; 9 + 10 + /// An inserter wrapper for RawRecordInsert that handles failures gracefully 11 + /// by retrying individual rows and sending failures to a dead-letter queue. 12 + /// 13 + /// This is specifically for raw record inserts since that's where untrusted 14 + /// input (arbitrary JSON from the firehose) enters the system. 15 + /// 16 + /// When a batch insert fails, this wrapper: 17 + /// 1. Creates a fresh inserter (since the old one is poisoned after error) 18 + /// 2. Retries each pending row individually 19 + /// 3. Sends failures to the DLQ with error details and the original row data 20 + /// 4. Continues processing without crashing 21 + pub struct ResilientRecordInserter { 22 + client: clickhouse::Client, 23 + inner: Inserter<RawRecordInsert>, 24 + pending: Vec<RawRecordInsert>, 25 + dlq: Inserter<RawEventDlq>, 26 + config: InserterConfig, 27 + } 28 + 29 + /// Configuration for the inserter thresholds 30 + #[derive(Clone)] 31 + pub struct InserterConfig { 32 + pub max_rows: u64, 33 + pub max_bytes: u64, 34 + pub period: Option<Duration>, 35 + pub period_bias: f64, 36 + } 37 + 38 + impl Default for InserterConfig { 39 + fn default() -> Self { 40 + Self { 41 + max_rows: 1000, 42 + max_bytes: 1_048_576, // 1MB 43 + period: Some(Duration::from_secs(1)), 44 + period_bias: 0.1, 45 + } 46 + } 47 + } 48 + 49 + impl ResilientRecordInserter { 50 + /// Create a new resilient inserter for raw records 51 + pub fn new(client: clickhouse::Client, config: InserterConfig) -> Self { 52 + let inner = Self::create_inserter(&client, &config); 53 + let dlq = Self::create_dlq_inserter(&client, &config); 54 + 55 + Self { 56 + client, 57 + inner, 58 + pending: Vec::new(), 59 + dlq, 60 + config, 61 + } 62 + } 63 + 64 + fn create_inserter( 65 + client: &clickhouse::Client, 66 + config: &InserterConfig, 67 + ) -> Inserter<RawRecordInsert> { 68 + let mut inserter = client 69 + .inserter(Tables::RAW_RECORDS) 70 + .with_max_rows(config.max_rows) 71 + .with_max_bytes(config.max_bytes) 72 + .with_period_bias(config.period_bias); 73 + 74 + if let Some(period) = config.period { 75 + inserter = inserter.with_period(Some(period)); 76 + } 77 + 78 + inserter 79 + } 80 + 81 + fn create_dlq_inserter( 82 + client: &clickhouse::Client, 83 + config: &InserterConfig, 84 + ) -> Inserter<RawEventDlq> { 85 + let mut inserter = client 86 + .inserter(Tables::RAW_EVENTS_DLQ) 87 + .with_max_rows(config.max_rows) 88 + .with_max_bytes(config.max_bytes) 89 + .with_period_bias(config.period_bias); 90 + 91 + if let Some(period) = config.period { 92 + inserter = inserter.with_period(Some(period)); 93 + } 94 + 95 + inserter 96 + } 97 + 98 + /// Write a row to the inserter 99 + /// 100 + /// The row is buffered both in the underlying inserter and in our 101 + /// pending queue for retry on failure. 102 + pub async fn write(&mut self, row: RawRecordInsert) -> Result<(), IndexError> { 103 + self.inner 104 + .write(&row) 105 + .await 106 + .map_err(|e| ClickHouseError::Insert { 107 + message: "write failed".into(), 108 + source: e, 109 + })?; 110 + 111 + self.pending.push(row); 112 + Ok(()) 113 + } 114 + 115 + /// Commit pending data if thresholds are met 116 + /// 117 + /// On success, clears the pending buffer if rows were actually flushed. 118 + /// On failure, retries rows individually and sends failures to DLQ. 119 + pub async fn commit(&mut self) -> Result<Quantities, IndexError> { 120 + match self.inner.commit().await { 121 + Ok(q) => { 122 + if q.rows > 0 { 123 + debug!( 124 + rows = q.rows, 125 + bytes = q.bytes, 126 + "batch committed successfully" 127 + ); 128 + self.pending.clear(); 129 + } 130 + Ok(q) 131 + } 132 + Err(e) => { 133 + warn!( 134 + error = ?e, 135 + pending = self.pending.len(), 136 + "batch commit failed, retrying individually" 137 + ); 138 + self.handle_batch_failure(e).await 139 + } 140 + } 141 + } 142 + 143 + /// Force commit all pending data 144 + /// 145 + /// Same semantics as commit() but unconditionally flushes. 146 + pub async fn force_commit(&mut self) -> Result<Quantities, IndexError> { 147 + match self.inner.force_commit().await { 148 + Ok(q) => { 149 + if q.rows > 0 { 150 + debug!( 151 + rows = q.rows, 152 + bytes = q.bytes, 153 + "batch force-committed successfully" 154 + ); 155 + self.pending.clear(); 156 + } 157 + Ok(q) 158 + } 159 + Err(e) => { 160 + warn!( 161 + error = ?e, 162 + pending = self.pending.len(), 163 + "batch force-commit failed, retrying individually" 164 + ); 165 + self.handle_batch_failure(e).await 166 + } 167 + } 168 + } 169 + 170 + /// End the inserter, flushing all remaining data 171 + /// 172 + /// Consumes self. On failure, retries rows individually. 173 + pub async fn end(mut self) -> Result<Quantities, IndexError> { 174 + // Take ownership of inner to end it 175 + let inner_result = self.inner.end().await; 176 + 177 + match inner_result { 178 + Ok(q) => { 179 + debug!( 180 + rows = q.rows, 181 + bytes = q.bytes, 182 + "inserter ended successfully" 183 + ); 184 + // Flush DLQ too 185 + self.dlq.end().await.map_err(|e| ClickHouseError::Insert { 186 + message: "DLQ end failed".into(), 187 + source: e, 188 + })?; 189 + Ok(q) 190 + } 191 + Err(e) => { 192 + warn!( 193 + error = ?e, 194 + pending = self.pending.len(), 195 + "inserter end failed, retrying individually" 196 + ); 197 + // Need a fresh inserter for recovery since old one is consumed 198 + self.inner = Self::create_inserter(&self.client, &self.config); 199 + let result = self.handle_batch_failure(e).await; 200 + // Flush DLQ 201 + self.dlq.end().await.map_err(|e| ClickHouseError::Insert { 202 + message: "DLQ end failed".into(), 203 + source: e, 204 + })?; 205 + result 206 + } 207 + } 208 + } 209 + 210 + /// Get statistics on pending (unbuffered) data in the underlying inserter 211 + pub fn pending(&self) -> &Quantities { 212 + self.inner.pending() 213 + } 214 + 215 + /// Get count of rows in our retry buffer 216 + pub fn pending_retry_count(&self) -> usize { 217 + self.pending.len() 218 + } 219 + 220 + /// Handle a batch failure by retrying rows individually 221 + async fn handle_batch_failure( 222 + &mut self, 223 + original_error: clickhouse::error::Error, 224 + ) -> Result<Quantities, IndexError> { 225 + // Take pending rows 226 + let rows = std::mem::take(&mut self.pending); 227 + let total = rows.len(); 228 + 229 + if rows.is_empty() { 230 + // Nothing to retry, just propagate the error context 231 + return Err(ClickHouseError::Insert { 232 + message: "batch failed with no pending rows".into(), 233 + source: original_error, 234 + } 235 + .into()); 236 + } 237 + 238 + // Create fresh inserter (old one is poisoned after error) 239 + self.inner = Self::create_inserter(&self.client, &self.config); 240 + 241 + let mut succeeded = 0u64; 242 + let mut failed = 0u64; 243 + 244 + for row in rows { 245 + match self.try_single_insert(&row).await { 246 + Ok(()) => { 247 + succeeded += 1; 248 + } 249 + Err(e) => { 250 + failed += 1; 251 + warn!( 252 + did = %row.did, 253 + collection = %row.collection, 254 + rkey = %row.rkey, 255 + seq = row.seq, 256 + error = ?e, 257 + "row insert failed, sending to DLQ" 258 + ); 259 + self.send_to_dlq(&row, &e).await?; 260 + } 261 + } 262 + } 263 + 264 + debug!(total, succeeded, failed, "batch failure recovery complete"); 265 + 266 + Ok(Quantities { 267 + rows: succeeded, 268 + bytes: 0, 269 + transactions: 0, 270 + }) 271 + } 272 + 273 + /// Try to insert a single row using a fresh one-shot inserter 274 + async fn try_single_insert( 275 + &self, 276 + row: &RawRecordInsert, 277 + ) -> Result<(), clickhouse::error::Error> { 278 + let mut inserter: Inserter<RawRecordInsert> = 279 + self.client.inserter(Tables::RAW_RECORDS).with_max_rows(1); 280 + 281 + inserter.write(row).await?; 282 + inserter.end().await?; 283 + Ok(()) 284 + } 285 + 286 + /// Send a failed row to the dead-letter queue 287 + async fn send_to_dlq( 288 + &mut self, 289 + row: &RawRecordInsert, 290 + error: &clickhouse::error::Error, 291 + ) -> Result<(), IndexError> { 292 + let raw_data = serde_json::to_string(row) 293 + .unwrap_or_else(|e| format!("{{\"serialization_error\": \"{}\"}}", e)); 294 + 295 + let dlq_row = RawEventDlq { 296 + event_type: row.operation.clone(), 297 + raw_data: raw_data.to_smolstr(), 298 + error_message: error.to_smolstr(), 299 + seq: row.seq, 300 + }; 301 + 302 + self.dlq 303 + .write(&dlq_row) 304 + .await 305 + .map_err(|e| ClickHouseError::Insert { 306 + message: "DLQ write failed".into(), 307 + source: e, 308 + })?; 309 + 310 + // Force commit DLQ to ensure failures are persisted immediately 311 + self.dlq 312 + .force_commit() 313 + .await 314 + .map_err(|e| ClickHouseError::Insert { 315 + message: "DLQ commit failed".into(), 316 + source: e, 317 + })?; 318 + 319 + Ok(()) 320 + } 321 + } 322 + 323 + #[cfg(test)] 324 + mod tests { 325 + // TODO: Add tests with mock clickhouse client 326 + }
+1 -1
crates/weaver-index/src/indexer.rs
··· 690 source: e, 691 })?; 692 records.commit().await.map_err(|e| ClickHouseError::Query { 693 - message: format!("record commit failed for id {}", event_id), 694 source: e, 695 })?; 696
··· 690 source: e, 691 })?; 692 records.commit().await.map_err(|e| ClickHouseError::Query { 693 + message: format!("record commit failed for id {}:\n{}", event_id, json), 694 source: e, 695 })?; 696
+1 -1
crates/weaver-renderer/src/css.rs
··· 350 display: flex; 351 align-items: center; 352 gap: 0.75rem; 353 - margin-bottom: 0.75rem; 354 }} 355 356 .embed-avatar {{
··· 350 display: flex; 351 align-items: center; 352 gap: 0.75rem; 353 + padding-bottom: 0.5rem; 354 }} 355 356 .embed-avatar {{
+5 -8
docker-compose.yml
··· 3 # Build from local indigo checkout, or use pre-built image 4 tap: 5 container_name: weaver-tap 6 - build: 7 - # Build from local indigo checkout on sync-tool branch 8 - # git clone https://github.com/bluesky-social/indigo.git && git checkout sync-tool 9 - context: ../../Git_Repos/indigo 10 - dockerfile: cmd/tap/Dockerfile 11 ports: 12 - "2480:2480" 13 volumes: ··· 18 TAP_DISABLE_ACKS: "false" 19 TAP_LOG_LEVEL: info 20 # Filter to weaver collections only 21 - TAP_SIGNAL_COLLECTION: sh.weaver.edit.root 22 - TAP_COLLECTION_FILTERS: "sh.weaver.*,app.bsky.actor.profile" 23 healthcheck: 24 test: ["CMD", "wget", "-q", "--spider", "http://localhost:2480/health"] 25 interval: 20s ··· 50 # Firehose connection (when INDEXER_SOURCE=firehose) 51 FIREHOSE_RELAY_URL: wss://bsky.network 52 # Collection filters 53 - INDEXER_COLLECTIONS: "sh.weaver.*,app.bsky.actor.profile" 54 depends_on: 55 tap: 56 condition: service_healthy
··· 3 # Build from local indigo checkout, or use pre-built image 4 tap: 5 container_name: weaver-tap 6 + image: ghcr.io/bluesky-social/indigo/tap:latest 7 ports: 8 - "2480:2480" 9 volumes: ··· 14 TAP_DISABLE_ACKS: "false" 15 TAP_LOG_LEVEL: info 16 # Filter to weaver collections only 17 + #TAP_SIGNAL_COLLECTION: sh.weaver.edit.root 18 + TAP_SIGNAL_COLLECTION: sh.tangled.actor.profile 19 + TAP_COLLECTION_FILTERS: "sh.weaver.*,app.bsky.actor.profile,sh.tangled.*,pub.leaflet.*" 20 healthcheck: 21 test: ["CMD", "wget", "-q", "--spider", "http://localhost:2480/health"] 22 interval: 20s ··· 47 # Firehose connection (when INDEXER_SOURCE=firehose) 48 FIREHOSE_RELAY_URL: wss://bsky.network 49 # Collection filters 50 + INDEXER_COLLECTIONS: "sh.weaver.*,app.bsky.actor.profile,sh.tangled.*,pub.leaflet.*" 51 depends_on: 52 tap: 53 condition: service_healthy