···
1
1
- Bring in the svelte project
2
2
- Set it up
3
3
- try the getFeedSkelton
4
4
-
- Make sure claude is setup with tailwindcss and daisyuui's mcp
5
4
- Make a cache wrapper for fjall
6
5
- Bring in sqlx
6
6
+
7
7
+
8
8
+
# How Feeds work
9
9
+
- https://selfhosted.social/xrpc/app.bsky.feed.getFeed?feed=at%3A%2F%2Fdid%3Aplc%3A3guzzweuqraryl3rdkimjamk%2Fapp.bsky.feed.generator%2Ffor-you&cursor=tMX2oQeGpgLc1gGIqAPkUszdAdgSgrwD9EFmroABjkeGPfB2%2FBD2rAHYHej7AY6RAcwB1DfgC8a3AsYo%2FPIBtkioLJSzAdJIqjE%3D_21600&limit=30
10
10
+
11
11
+
12
12
+
- getPref to get feeds (can hard code for now)
13
13
+
- getFeedGenerator to get details about the feed
14
14
+
15
15
+
16
16
+
//What the end feed looks like
17
17
+
https://api.graze.social/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.generator/Cornbread
18
18
+
19
19
+
20
20
+
# What happens when someone logs in
21
21
+
- When the user logs in for the first time we pull their repo and get everyone they follow
22
22
+
- Should probably show a "loading" spinner while we fetch the feed
23
23
+
- When we see a record go by that the user follows we add it to the sqlx database
24
24
+
- when the user loads the feed we load in 2 feeds, a bsky one they select and one from our system which is mixed
···
11
11
fjall = "3.1.4"
12
12
futures = "0.3"
13
13
jacquard = "0.11.0"
14
14
-
jacquard-api = { version = "0.11.1", features = ["default", "app_bsky", "streaming"] }
14
14
+
jacquard-api = { version = "0.11.1", features = ["default", "app_bsky", "streaming", "sh_tangled"] }
15
15
jacquard-axum = "0.11.0"
16
16
jacquard-common = { version = "0.11.0", features = ["websocket", "streaming", "reqwest-client"] }
17
17
jacquard-derive = "0.11.0"
···
27
27
tokio-util = { version = "0.7", features = ["rt"] }
28
28
tower-http = { version = "0.6.8", features = ["cors"] }
29
29
url = "2"
30
30
+
31
31
+
[dev-dependencies]
32
32
+
tempfile = "3"
···
1
1
+
use fjall::compaction::filter::{
2
2
+
CompactionFilter, CompactionFilterResult, Context, Factory, ItemAccessor, Verdict,
3
3
+
};
4
4
+
use jacquard::deps::chrono::{self, DateTime, Utc};
5
5
+
use serde::{Deserialize, Serialize, de::DeserializeOwned};
1
6
use std::{path::Path, sync::Arc};
2
7
3
3
-
use crate::storage::error::StorageResult;
8
8
+
use crate::storage::error::{StorageError, StorageResult};
9
9
+
10
10
+
const CACHE_KEYSPACE: &str = "cache";
4
11
5
12
pub struct Cache {
6
13
pub(crate) database: fjall::Database,
···
11
18
pub type CacheRef = Arc<Cache>;
12
19
13
20
/// Open (or create) the fjall database at `path` and return a shared handle.
21
21
+
///
22
22
+
/// Registers a TTL compaction filter on the `cache` keyspace so expired
23
23
+
/// entries are reclaimed during background compactions.
14
24
pub fn open(path: &Path) -> StorageResult<CacheRef> {
15
15
-
let database = fjall::Database::builder(path).open()?;
16
16
-
let cache_keyspace = database.keyspace("cache", fjall::KeyspaceCreateOptions::default)?;
25
25
+
let database = fjall::Database::builder(path)
26
26
+
.with_compaction_filter_factories(Arc::new(|name| {
27
27
+
(name == CACHE_KEYSPACE).then(|| Arc::new(CacheFilterFactory) as Arc<dyn Factory>)
28
28
+
}))
29
29
+
.open()?;
30
30
+
let cache_keyspace =
31
31
+
database.keyspace(CACHE_KEYSPACE, fjall::KeyspaceCreateOptions::default)?;
17
32
Ok(Arc::new(Cache {
18
33
database,
19
34
cache_keyspace,
20
35
}))
21
36
}
37
37
+
38
38
+
impl Cache {
39
39
+
/// Insert `value` under `key`, expiring `ttl` from now.
40
40
+
pub fn insert<K: AsRef<[u8]>, T: Serialize>(
41
41
+
&self,
42
42
+
key: K,
43
43
+
value: &T,
44
44
+
ttl: chrono::Duration,
45
45
+
) -> StorageResult<()> {
46
46
+
let data = postcard::to_allocvec(value)?;
47
47
+
let entry = CacheEntry {
48
48
+
expires_at: Utc::now() + ttl,
49
49
+
data,
50
50
+
};
51
51
+
let bytes = postcard::to_allocvec(&entry)?;
52
52
+
self.cache_keyspace.insert(key.as_ref(), bytes)?;
53
53
+
Ok(())
54
54
+
}
55
55
+
56
56
+
/// Fetch and decode `key`. Returns `Ok(None)` if missing or expired;
57
57
+
/// expired entries are removed eagerly on read so callers don't see
58
58
+
/// stale values between compactions.
59
59
+
pub fn get<K: AsRef<[u8]>, T: DeserializeOwned>(&self, key: K) -> StorageResult<Option<T>> {
60
60
+
let Some(raw) = self.cache_keyspace.get(key.as_ref())? else {
61
61
+
return Ok(None);
62
62
+
};
63
63
+
let entry: CacheEntry = postcard::from_bytes(&raw).map_err(|_| StorageError::Corrupt {
64
64
+
key: String::from_utf8_lossy(key.as_ref()).into_owned(),
65
65
+
reason: "cache wrapper postcard decode failed",
66
66
+
})?;
67
67
+
if Utc::now() >= entry.expires_at {
68
68
+
self.cache_keyspace.remove(key.as_ref())?;
69
69
+
return Ok(None);
70
70
+
}
71
71
+
let value = postcard::from_bytes::<T>(&entry.data)?;
72
72
+
Ok(Some(value))
73
73
+
}
74
74
+
75
75
+
pub fn remove<K: AsRef<[u8]>>(&self, key: K) -> StorageResult<()> {
76
76
+
self.cache_keyspace.remove(key.as_ref())?;
77
77
+
Ok(())
78
78
+
}
79
79
+
}
80
80
+
81
81
+
#[derive(Debug, Deserialize, Serialize)]
82
82
+
struct CacheEntry {
83
83
+
expires_at: DateTime<Utc>,
84
84
+
data: Vec<u8>,
85
85
+
}
86
86
+
87
87
+
struct CacheFilter;
88
88
+
89
89
+
impl CompactionFilter for CacheFilter {
90
90
+
fn filter_item(&mut self, item: ItemAccessor<'_>, _ctx: &Context) -> CompactionFilterResult {
91
91
+
let bytes = item.value()?;
92
92
+
93
93
+
let Ok(entry) = postcard::from_bytes::<CacheEntry>(&bytes) else {
94
94
+
return Ok(Verdict::Keep);
95
95
+
};
96
96
+
97
97
+
if Utc::now() >= entry.expires_at {
98
98
+
Ok(Verdict::Remove)
99
99
+
} else {
100
100
+
Ok(Verdict::Keep)
101
101
+
}
102
102
+
}
103
103
+
}
104
104
+
105
105
+
struct CacheFilterFactory;
106
106
+
107
107
+
impl Factory for CacheFilterFactory {
108
108
+
fn name(&self) -> &str {
109
109
+
"cache-ttl"
110
110
+
}
111
111
+
112
112
+
fn make_filter(&self, _ctx: &Context) -> Box<dyn CompactionFilter> {
113
113
+
Box::new(CacheFilter)
114
114
+
}
115
115
+
}
116
116
+
117
117
+
#[cfg(test)]
118
118
+
mod tests {
119
119
+
120
120
+
use super::*;
121
121
+
use std::{thread::sleep, time::Duration};
122
122
+
123
123
+
#[derive(Debug, Clone, Serialize, Deserialize)]
124
124
+
struct ATest {
125
125
+
test: String,
126
126
+
}
127
127
+
128
128
+
#[test]
129
129
+
fn ttl_eviction_on_read() {
130
130
+
let dir = tempfile::tempdir().expect("tempdir");
131
131
+
let cache = open(dir.path()).expect("open");
132
132
+
133
133
+
cache
134
134
+
.insert(
135
135
+
"greeting",
136
136
+
&"hello".to_string(),
137
137
+
chrono::Duration::milliseconds(50),
138
138
+
)
139
139
+
.expect("insert");
140
140
+
141
141
+
let immediate: Option<String> = cache.get("greeting").expect("get");
142
142
+
assert_eq!(immediate.as_deref(), Some("hello"));
143
143
+
144
144
+
sleep(Duration::from_millis(120));
145
145
+
146
146
+
let expired: Option<String> = cache.get("greeting").expect("get after expiry");
147
147
+
assert!(expired.is_none(), "expected entry to be evicted after TTL");
148
148
+
149
149
+
assert!(
150
150
+
cache
151
151
+
.cache_keyspace
152
152
+
.get("greeting")
153
153
+
.expect("raw get")
154
154
+
.is_none(),
155
155
+
"read-path eviction should have removed the underlying key",
156
156
+
);
157
157
+
158
158
+
let test = ATest {
159
159
+
test: "hello".to_string(),
160
160
+
};
161
161
+
162
162
+
// let serialized = serde_json::to_string(&test).expect("serialize");
163
163
+
cache
164
164
+
.insert("test", &test, chrono::Duration::milliseconds(50))
165
165
+
.expect("insert");
166
166
+
167
167
+
let retrieved: Option<ATest> = cache.get("test").expect("get");
168
168
+
println!("{retrieved:?}");
169
169
+
assert!(retrieved.is_some(), "expected entry to be retrieved");
170
170
+
171
171
+
let tangled_repo = sh_tangled::repo::Repo {
172
172
+
created_at: jacquard_common::types::string::Datetime::now(),
173
173
+
description: None,
174
174
+
knot: "Test".into(),
175
175
+
labels: None,
176
176
+
name: "Test".into(),
177
177
+
source: None,
178
178
+
spindle: None,
179
179
+
topics: None,
180
180
+
website: None,
181
181
+
extra_data: None,
182
182
+
};
183
183
+
let serialized = serde_json::to_string(&tangled_repo).expect("serialize");
184
184
+
cache
185
185
+
.insert(
186
186
+
"tangled_repo",
187
187
+
&serialized,
188
188
+
chrono::Duration::milliseconds(50),
189
189
+
)
190
190
+
.expect("insert");
191
191
+
192
192
+
let retrieved: Option<String> = cache.get("tangled_repo").expect("get");
193
193
+
let idk = retrieved.unwrap();
194
194
+
let deserialized: Option<sh_tangled::repo::Repo> = serde_json::from_str(&idk).ok();
195
195
+
println!("{deserialized:?}");
196
196
+
// assert!(retrieved.is_some(), "expected entry to be retrieved");
197
197
+
}
198
198
+
}