tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
more resiliency
Orual
1 month ago
f79bbd8d
cd388c2e
+529
-102
22 changed files
expand all
collapse all
unified
split
crates
weaver-app
assets
styling
entry-card.css
entry.css
public
editor_worker.js
embed_worker.js
src
components
author_list
author.css
entry.rs
mod.rs
views
entry.rs
weaver-index
migrations
clickhouse
012_profiles_weaver_mv.sql
014_profiles_bsky_mv.sql
018_notebooks_mv.sql
021_entries_mv.sql
024_drafts_mv.sql
027_edit_diffs_mv.sql
030_collab_invites_mv.sql
032_collab_accepts_mv.sql
034_collab_sessions_mv.sql
src
clickhouse
resilient_inserter.rs
clickhouse.rs
indexer.rs
weaver-renderer
src
css.rs
docker-compose.yml
+14
crates/weaver-app/assets/styling/entry-card.css
···
111
margin-left: auto;
112
}
113
0
0
0
0
0
0
0
0
0
0
0
0
0
114
.entry-card-tags {
115
display: flex;
116
gap: 0.4rem;
117
flex-wrap: wrap;
0
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 {
0
133
display: flex;
134
align-items: center;
135
font-family: var(--font-ui);
136
gap: 0.5rem;
137
}
138
0
0
0
0
0
0
0
0
0
0
0
139
.entry-date {
140
margin-left: auto;
141
font-weight: 400;
0
142
color: var(--color-subtle);
143
}
144
···
157
.author-name:hover {
158
color: var(--color-emphasis);
159
text-decoration: underline;
0
0
0
0
0
0
0
0
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();
0
0
0
0
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_____);
0
0
0
0
0
463
return ret;
464
};
465
imports.wbg.__wbindgen_init_externref_table = function() {
···
364
const ret = arg0.node;
365
return ret;
366
};
0
0
0
0
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
};
0
0
0
0
0
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
0
0
0
0
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);
0
0
0
0
397
return ret;
398
};
399
imports.wbg.__wbg_getTime_ad1e9878a735af08 = function(arg0) {
···
517
const ret = Promise.resolve(arg0);
518
return ret;
519
};
0
0
0
0
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
};
0
0
0
0
0
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);
0
0
0
0
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
};
0
0
0
0
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) };
0
0
0
0
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`.
0
0
0
0
0
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
0
0
0
0
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
0
0
0
0
0
0
0
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()
0
0
0
0
0
0
0
241
}
242
243
// Rendered markdown
···
339
html_buf
340
});
341
0
0
0
0
0
342
rsx! {
343
div { class: "entry-card",
344
div { class: "entry-card-meta",
···
388
}
389
}
390
}
0
0
0
0
0
0
391
}
392
}
393
}
···
463
html_buf
464
};
465
0
0
0
466
rsx! {
467
div { class: "entry-card feed-entry-card",
468
// Header: title (and date if no author)
···
518
}
519
}
520
}
0
0
0
0
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>,
0
0
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}" }
0
0
0
0
0
0
0
0
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()
0
0
0
0
0
0
0
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()
0
0
0
0
0
0
0
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()
0
0
0
0
0
0
0
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;
0
3
mod schema;
4
5
pub use client::{Client, TableSize};
6
pub use migrations::{DbObject, MigrationResult, Migrator, ObjectType};
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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"
0
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
0
0
0
0
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