···
1
1
-
{
2
2
-
"db_name": "SQLite",
3
3
-
"query": "SELECT li.subject_uri AS \"subject_uri!\"\n FROM listitems li\n JOIN things t ON t.uri = li.subject_uri\n WHERE li.list_uri = ?\n ORDER BY li.created_at, li.did, li.rkey\n LIMIT ? OFFSET ?",
4
4
-
"describe": {
5
5
-
"columns": [
6
6
-
{
7
7
-
"name": "subject_uri!",
8
8
-
"ordinal": 0,
9
9
-
"type_info": "Text",
10
10
-
"origin": {
11
11
-
"Table": {
12
12
-
"table": "listitems",
13
13
-
"name": "subject_uri"
14
14
-
}
15
15
-
}
16
16
-
}
17
17
-
],
18
18
-
"parameters": {
19
19
-
"Right": 3
20
20
-
},
21
21
-
"nullable": [
22
22
-
false
23
23
-
]
24
24
-
},
25
25
-
"hash": "09f1642d54bf17a0bb6b4ba69b2890b476029661eb7e224b7d8e291b691b3109"
26
26
-
}
···
1
1
+
{
2
2
+
"db_name": "SQLite",
3
3
+
"query": "SELECT t.did AS \"did!\", t.rkey AS \"rkey!\", t.uri AS \"uri!\", t.cid AS \"cid!\",\n t.name AS \"name!\", t.summary, t.license AS \"license!\", t.tags_json, t.cover_json,\n t.derived_from_uri, t.created_at AS \"created_at!\", t.indexed_at AS \"indexed_at!\",\n t.record_json,\n COALESCE(cs.like_count, 0) AS \"like_count!: i64\",\n COALESCE(cs.save_count, 0) AS \"save_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS \"model_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS \"part_count!: i64\"\n FROM listitems li\n JOIN things t ON t.uri = li.subject_uri\n LEFT JOIN content_stats cs ON cs.uri = t.uri\n WHERE li.list_uri = ?\n ORDER BY li.created_at, li.did, li.rkey\n LIMIT ? OFFSET ?",
4
4
+
"describe": {
5
5
+
"columns": [
6
6
+
{
7
7
+
"name": "did!",
8
8
+
"ordinal": 0,
9
9
+
"type_info": "Text",
10
10
+
"origin": {
11
11
+
"Table": {
12
12
+
"table": "things",
13
13
+
"name": "did"
14
14
+
}
15
15
+
}
16
16
+
},
17
17
+
{
18
18
+
"name": "rkey!",
19
19
+
"ordinal": 1,
20
20
+
"type_info": "Text",
21
21
+
"origin": {
22
22
+
"Table": {
23
23
+
"table": "things",
24
24
+
"name": "rkey"
25
25
+
}
26
26
+
}
27
27
+
},
28
28
+
{
29
29
+
"name": "uri!",
30
30
+
"ordinal": 2,
31
31
+
"type_info": "Text",
32
32
+
"origin": {
33
33
+
"Table": {
34
34
+
"table": "things",
35
35
+
"name": "uri"
36
36
+
}
37
37
+
}
38
38
+
},
39
39
+
{
40
40
+
"name": "cid!",
41
41
+
"ordinal": 3,
42
42
+
"type_info": "Text",
43
43
+
"origin": {
44
44
+
"Table": {
45
45
+
"table": "things",
46
46
+
"name": "cid"
47
47
+
}
48
48
+
}
49
49
+
},
50
50
+
{
51
51
+
"name": "name!",
52
52
+
"ordinal": 4,
53
53
+
"type_info": "Text",
54
54
+
"origin": {
55
55
+
"Table": {
56
56
+
"table": "things",
57
57
+
"name": "name"
58
58
+
}
59
59
+
}
60
60
+
},
61
61
+
{
62
62
+
"name": "summary",
63
63
+
"ordinal": 5,
64
64
+
"type_info": "Text",
65
65
+
"origin": {
66
66
+
"Table": {
67
67
+
"table": "things",
68
68
+
"name": "summary"
69
69
+
}
70
70
+
}
71
71
+
},
72
72
+
{
73
73
+
"name": "license!",
74
74
+
"ordinal": 6,
75
75
+
"type_info": "Text",
76
76
+
"origin": {
77
77
+
"Table": {
78
78
+
"table": "things",
79
79
+
"name": "license"
80
80
+
}
81
81
+
}
82
82
+
},
83
83
+
{
84
84
+
"name": "tags_json",
85
85
+
"ordinal": 7,
86
86
+
"type_info": "Text",
87
87
+
"origin": {
88
88
+
"Table": {
89
89
+
"table": "things",
90
90
+
"name": "tags_json"
91
91
+
}
92
92
+
}
93
93
+
},
94
94
+
{
95
95
+
"name": "cover_json",
96
96
+
"ordinal": 8,
97
97
+
"type_info": "Text",
98
98
+
"origin": {
99
99
+
"Table": {
100
100
+
"table": "things",
101
101
+
"name": "cover_json"
102
102
+
}
103
103
+
}
104
104
+
},
105
105
+
{
106
106
+
"name": "derived_from_uri",
107
107
+
"ordinal": 9,
108
108
+
"type_info": "Text",
109
109
+
"origin": {
110
110
+
"Table": {
111
111
+
"table": "things",
112
112
+
"name": "derived_from_uri"
113
113
+
}
114
114
+
}
115
115
+
},
116
116
+
{
117
117
+
"name": "created_at!",
118
118
+
"ordinal": 10,
119
119
+
"type_info": "Integer",
120
120
+
"origin": {
121
121
+
"Table": {
122
122
+
"table": "things",
123
123
+
"name": "created_at"
124
124
+
}
125
125
+
}
126
126
+
},
127
127
+
{
128
128
+
"name": "indexed_at!",
129
129
+
"ordinal": 11,
130
130
+
"type_info": "Integer",
131
131
+
"origin": {
132
132
+
"Table": {
133
133
+
"table": "things",
134
134
+
"name": "indexed_at"
135
135
+
}
136
136
+
}
137
137
+
},
138
138
+
{
139
139
+
"name": "record_json",
140
140
+
"ordinal": 12,
141
141
+
"type_info": "Text",
142
142
+
"origin": {
143
143
+
"Table": {
144
144
+
"table": "things",
145
145
+
"name": "record_json"
146
146
+
}
147
147
+
}
148
148
+
},
149
149
+
{
150
150
+
"name": "like_count!: i64",
151
151
+
"ordinal": 13,
152
152
+
"type_info": "Integer",
153
153
+
"origin": "Expression"
154
154
+
},
155
155
+
{
156
156
+
"name": "save_count!: i64",
157
157
+
"ordinal": 14,
158
158
+
"type_info": "Integer",
159
159
+
"origin": "Expression"
160
160
+
},
161
161
+
{
162
162
+
"name": "model_count!: i64",
163
163
+
"ordinal": 15,
164
164
+
"type_info": "Integer",
165
165
+
"origin": "Expression"
166
166
+
},
167
167
+
{
168
168
+
"name": "part_count!: i64",
169
169
+
"ordinal": 16,
170
170
+
"type_info": "Integer",
171
171
+
"origin": "Expression"
172
172
+
}
173
173
+
],
174
174
+
"parameters": {
175
175
+
"Right": 3
176
176
+
},
177
177
+
"nullable": [
178
178
+
false,
179
179
+
false,
180
180
+
false,
181
181
+
false,
182
182
+
false,
183
183
+
true,
184
184
+
false,
185
185
+
true,
186
186
+
true,
187
187
+
true,
188
188
+
false,
189
189
+
false,
190
190
+
true,
191
191
+
false,
192
192
+
false,
193
193
+
false,
194
194
+
false
195
195
+
]
196
196
+
},
197
197
+
"hash": "114ff4742bcf2b4583776214c171eb643b5253738efa0d455cb7fc7b0044cc54"
198
198
+
}
···
7
7
8
8
use axum::extract::FromRequestParts;
9
9
use dioxus::prelude::{ServerFnError, dioxus_fullstack::FullstackContext};
10
10
-
use http::HeaderValue;
11
10
use jacquard::identity::PublicResolver;
12
11
use jacquard::oauth::client::OAuthSession;
13
12
use jacquard_axum::oauth::ExtractOptionalOAuthSession;
14
14
-
use jacquard_common::session::SessionKey;
15
13
use jacquard_common::types::ident::AtIdentifier;
16
16
-
use jacquard_common::xrpc::CallOptions;
17
14
use polymodel_api::space_polymodel::actor::ProfileViewUnion;
18
15
use polymodel_api::space_polymodel::actor::get_session::GetSessionOutput;
19
16
use polymodel_api::space_polymodel::library::FeedView;
···
87
84
}
88
85
89
86
/// Resolve a feed server-side, same path as `getFeed`. The `algorithm` string
90
90
-
/// dispatches exactly as the XRPC handler: `"recent"` or `"hot"`.
87
87
+
/// dispatches exactly as the XRPC handler.
91
88
#[cfg_attr(not(feature = "server"), allow(dead_code))]
92
89
pub(crate) async fn get_feed_from_fullstack_context(
93
90
algorithm: &str,
···
101
98
let feed = match algorithm {
102
99
"recent" => views::feed_recent(&state, limit, None, viewer).await,
103
100
"hot" => views::feed_hot(&state, limit, None, viewer).await,
101
101
+
"following" => match viewer {
102
102
+
Some(viewer) => views::feed_following(&state, viewer, limit, None).await,
103
103
+
None => Ok(views::empty_feed()),
104
104
+
},
104
105
other => return Err(ServerFnError::new(format!("unknown algorithm: {other}"))),
105
106
};
106
107
feed.map_err(|error| ServerFnError::new(format!("{error:?}")))
···
211
212
.map(|ExtractOptionalOAuthSession(session)| session)
212
213
.map_err(|error| ServerFnError::new(error.to_string()))
213
214
}
214
214
-
215
215
-
/// Build Jacquard call options carrying the encoded session-key header.
216
216
-
///
217
217
-
/// Use this only when a server-side outbound XRPC hop is unavoidable. Normal
218
218
-
/// SSR/server-function appview reads should stay in-process via the helpers
219
219
-
/// above.
220
220
-
#[allow(dead_code)]
221
221
-
pub fn session_key_call_options(
222
222
-
state: &AppState,
223
223
-
key: &SessionKey,
224
224
-
) -> Result<CallOptions, ServerFnError> {
225
225
-
let encoded = jacquard_axum::oauth::encode_session_key(key)
226
226
-
.map_err(|error| ServerFnError::new(error.to_string()))?;
227
227
-
let value =
228
228
-
HeaderValue::from_str(&encoded).map_err(|error| ServerFnError::new(error.to_string()))?;
229
229
-
Ok(CallOptions {
230
230
-
extra_headers: vec![(state.oauth_config.session_header.clone(), value)],
231
231
-
..CallOptions::default()
232
232
-
})
233
233
-
}
···
949
949
}
950
950
951
951
#[tokio::test]
952
952
+
async fn fullstack_context_feed_helper_supports_following() {
953
953
+
let state = state().await;
954
954
+
seed_identity(&state.pool, DID_A, "alice.com").await;
955
955
+
seed_identity(&state.pool, DID_B, "bob.com").await;
956
956
+
seed_thing(
957
957
+
&state.pool,
958
958
+
DID_B,
959
959
+
"3bbbbbbbbbbbb",
960
960
+
"followed project",
961
961
+
&[],
962
962
+
0,
963
963
+
)
964
964
+
.await;
965
965
+
sqlx::query(
966
966
+
"INSERT INTO follows (follower_did, followed_did, rkey, cid, created_at) VALUES (?, ?, ?, ?, ?)",
967
967
+
)
968
968
+
.bind(DID_A)
969
969
+
.bind(DID_B)
970
970
+
.bind("followb")
971
971
+
.bind("bafyreifollowb")
972
972
+
.bind(1_i64)
973
973
+
.execute(&state.pool)
974
974
+
.await
975
975
+
.unwrap();
976
976
+
let key = seed_oauth_session(&state, DID_A, "fullstack-following-session").await;
977
977
+
let encoded = jacquard_axum::oauth::encode_session_key(&key).unwrap();
978
978
+
let header_name = state.oauth_config.session_header.clone();
979
979
+
980
980
+
let mut parts = Request::builder()
981
981
+
.uri("/")
982
982
+
.header(header_name, encoded)
983
983
+
.body(())
984
984
+
.unwrap()
985
985
+
.into_parts()
986
986
+
.0;
987
987
+
parts.extensions.insert(state);
988
988
+
989
989
+
let feed = FullstackContext::new(parts)
990
990
+
.scope(super::ssr::get_feed_from_fullstack_context("following", 24))
991
991
+
.await
992
992
+
.unwrap();
993
993
+
994
994
+
assert_eq!(feed.items.len(), 1);
995
995
+
assert_eq!(feed.items[0].thing.name.as_str(), "followed project");
996
996
+
}
997
997
+
998
998
+
#[tokio::test]
952
999
async fn get_thing_route_decodes_query_and_404s_when_missing() {
953
1000
let state = state().await;
954
1001
let app = super::router().with_state(state);
···
519
519
// getThing / getModel lookups
520
520
// ---------------------------------------------------------------------------
521
521
522
522
-
/// Fetch a thing row by URI (with stats + model count). Shared by getThing and
523
523
-
/// getList's per-subject hydration.
522
522
+
/// Fetch a thing row by URI (with stats + model count).
524
523
async fn fetch_thing_by_uri(state: &AppState, uri: &str) -> AppResult<Option<ThingRow>> {
525
524
db(sqlx::query_as!(
526
525
ThingRow,
···
1147
1146
.fetch_all(&state.pool)
1148
1147
.await,
1149
1148
)?;
1150
1150
-
let cursor = if rows.len() as i64 > limit {
1151
1151
-
Some((offset + limit).to_string())
1152
1152
-
} else {
1153
1153
-
None
1154
1154
-
};
1149
1149
+
let cursor = offset_page_cursor(rows.len(), limit, offset);
1155
1150
let page: Vec<ThingRow> = rows.into_iter().take(limit as usize).collect();
1156
1151
feed_view(state, page, cursor, viewer_did).await
1157
1152
}
···
1173
1168
}
1174
1169
}
1175
1170
1171
1171
+
fn offset_page_cursor(row_count: usize, limit: i64, offset: i64) -> Option<String> {
1172
1172
+
(row_count as i64 > limit).then(|| (offset + limit).to_string())
1173
1173
+
}
1174
1174
+
1176
1175
// ---------------------------------------------------------------------------
1177
1176
// getList (thingViewBasic[] from listitems)
1178
1177
// ---------------------------------------------------------------------------
···
1186
1185
) -> AppResult<(Vec<ThingViewBasic>, Option<String>)> {
1187
1186
let offset = parse_offset(cursor)?;
1188
1187
let fetch = limit + 1;
1189
1189
-
#[derive(sqlx::FromRow)]
1190
1190
-
struct ListItemRow {
1191
1191
-
subject_uri: String,
1192
1192
-
}
1193
1188
let rows = db(sqlx::query_as!(
1194
1194
-
ListItemRow,
1195
1195
-
r#"SELECT li.subject_uri AS "subject_uri!"
1196
1196
-
FROM listitems li
1197
1197
-
JOIN things t ON t.uri = li.subject_uri
1198
1198
-
WHERE li.list_uri = ?
1199
1199
-
ORDER BY li.created_at, li.did, li.rkey
1200
1200
-
LIMIT ? OFFSET ?"#,
1189
1189
+
ThingRow,
1190
1190
+
r#"SELECT t.did AS "did!", t.rkey AS "rkey!", t.uri AS "uri!", t.cid AS "cid!",
1191
1191
+
t.name AS "name!", t.summary, t.license AS "license!", t.tags_json, t.cover_json,
1192
1192
+
t.derived_from_uri, t.created_at AS "created_at!", t.indexed_at AS "indexed_at!",
1193
1193
+
t.record_json,
1194
1194
+
COALESCE(cs.like_count, 0) AS "like_count!: i64",
1195
1195
+
COALESCE(cs.save_count, 0) AS "save_count!: i64",
1196
1196
+
(SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS "model_count!: i64",
1197
1197
+
(SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS "part_count!: i64"
1198
1198
+
FROM listitems li
1199
1199
+
JOIN things t ON t.uri = li.subject_uri
1200
1200
+
LEFT JOIN content_stats cs ON cs.uri = t.uri
1201
1201
+
WHERE li.list_uri = ?
1202
1202
+
ORDER BY li.created_at, li.did, li.rkey
1203
1203
+
LIMIT ? OFFSET ?"#,
1201
1204
list_uri,
1202
1205
fetch,
1203
1206
offset,
1204
1207
)
1205
1208
.fetch_all(&state.pool)
1206
1209
.await)?;
1207
1207
-
let cursor = if rows.len() as i64 > limit {
1208
1208
-
Some((offset + limit).to_string())
1209
1209
-
} else {
1210
1210
-
None
1211
1211
-
};
1210
1210
+
let cursor = offset_page_cursor(rows.len(), limit, offset);
1212
1211
let mut items = Vec::with_capacity(rows.len().min(limit as usize));
1213
1213
-
for li in rows.into_iter().take(limit as usize) {
1214
1214
-
if let Some(thing) = thing_basic_by_uri(state, &li.subject_uri, viewer_did.clone()).await? {
1215
1215
-
items.push(thing);
1216
1216
-
}
1212
1212
+
for row in rows.into_iter().take(limit as usize) {
1213
1213
+
items.push(thing_basic_view(state, row, viewer_did.clone()).await?);
1217
1214
}
1218
1215
Ok((items, cursor))
1219
1219
-
}
1220
1220
-
1221
1221
-
async fn thing_basic_by_uri(
1222
1222
-
state: &AppState,
1223
1223
-
uri: &str,
1224
1224
-
viewer_did: Option<Did<&str>>,
1225
1225
-
) -> AppResult<Option<ThingViewBasic>> {
1226
1226
-
match fetch_thing_by_uri(state, uri).await? {
1227
1227
-
Some(row) => Ok(Some(thing_basic_view(state, row, viewer_did).await?)),
1228
1228
-
None => Ok(None),
1229
1229
-
}
1230
1216
}
1231
1217
1232
1218
// ---------------------------------------------------------------------------
···
160
160
if dioxus::prelude::dioxus_fullstack::FullstackContext::current().is_some() {
161
161
return;
162
162
}
163
163
+
164
164
+
match session.read().clone() {
165
165
+
SessionIdentity::Authenticated(_) => {
166
166
+
if let Err(error) = client.send(BootstrapProfile).await {
167
167
+
tracing::warn!(%error, "failed to bootstrap Polymodel profile");
168
168
+
}
169
169
+
return;
170
170
+
}
171
171
+
SessionIdentity::Anonymous => return,
172
172
+
SessionIdentity::Unknown => {}
173
173
+
}
174
174
+
163
175
match client.send(GetSession).await {
164
176
Ok(response) => match response.into_output() {
165
177
Ok(output) => {
···
5
5
use crate::thing_detail::thing_detail_href;
6
6
use dioxus::prelude::*;
7
7
use jacquard_common::deps::smol_str::SmolStr;
8
8
-
use jacquard_common::types::string::{AtIdentifier, Did, Handle, UriValue};
8
8
+
use jacquard_common::types::string::{Did, Handle, UriValue};
9
9
use jacquard_common::xrpc::XrpcClient;
10
10
-
use polymodel_api::space_polymodel::actor::{ProfileViewUnion, get_profile::GetProfile};
10
10
+
use polymodel_api::space_polymodel::actor::ProfileViewUnion;
11
11
use polymodel_api::space_polymodel::graph::{
12
12
create_follow::CreateFollow, delete_follow::DeleteFollow,
13
13
};
14
14
use polymodel_api::space_polymodel::library;
15
15
-
use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings;
16
15
17
16
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18
17
enum ProfileTab {
···
166
165
.map(SmolStr::from)
167
166
}
168
167
168
168
+
#[cfg_attr(not(any(feature = "server", test)), allow(dead_code))]
169
169
fn profile_lists(
170
170
value: &ProfileViewUnion,
171
171
) -> Vec<polymodel_api::space_polymodel::actor::ProfileListView> {
···
241
241
(true, false) => current.saturating_sub(1),
242
242
_ => current,
243
243
}
244
244
-
}
245
245
-
246
246
-
async fn load_profile(client: PolymodelClient, actor_str: &str) -> Result<ProfileData, String> {
247
247
-
let actor = AtIdentifier::new_owned(actor_str)
248
248
-
.map_err(|error| format!("Invalid actor: unsupported actor identifier: {error}"))?;
249
249
-
250
250
-
let profile_response = client
251
251
-
.send(GetProfile::new().actor(actor.clone()).build())
252
252
-
.await
253
253
-
.map_err(|error| format!("Profile request failed: {error}"))?;
254
254
-
let profile_output = profile_response
255
255
-
.into_output()
256
256
-
.map_err(|error| format!("Profile decode failed: {error}"))?;
257
257
-
let header = profile_header(&profile_output.value);
258
258
-
let lists = profile_lists(&profile_output.value);
259
259
-
260
260
-
let things_response = client
261
261
-
.send(GetAuthorThings::new().actor(actor).limit(24).build())
262
262
-
.await
263
263
-
.map_err(|error| format!("Author things request failed: {error}"))?;
264
264
-
let things_output = things_response
265
265
-
.into_output()
266
266
-
.map_err(|error| format!("Author things decode failed: {error}"))?;
267
267
-
let things = things_output
268
268
-
.value
269
269
-
.items
270
270
-
.into_iter()
271
271
-
.map(|item| item.thing)
272
272
-
.collect();
273
273
-
274
274
-
Ok(ProfileData {
275
275
-
header,
276
276
-
things,
277
277
-
lists,
278
278
-
})
279
244
}
280
245
281
246
#[allow(clippy::useless_format)]
···
91
91
))
92
92
}
93
93
94
94
+
#[cfg_attr(not(feature = "server"), allow(dead_code))]
94
95
fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> {
95
96
let repo: AtIdentifier<String> = AtIdentifier::new_owned(repo)
96
97
.map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?;