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