feat: better APIs; WIP timeline support

This commit is contained in:
nullishamy 2025-04-11 15:47:22 +01:00
parent 022e6f9c6d
commit ce3a9bfb26
Signed by: amy
SSH key fingerprint: SHA256:WmV0uk6WgAQvDJlM8Ld4mFPHZo02CLXXP5VkwQ5xtyk
19 changed files with 425 additions and 211 deletions

1
Cargo.lock generated
View file

@ -2158,6 +2158,7 @@ dependencies = [
"rocket",
"rocket_db_pools",
"sqlx",
"url",
"uuid",
]

BIN
assets/pfp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View file

@ -2,6 +2,7 @@ use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use sqlx::Sqlite;
use std::fmt::Debug;
pub mod http;
#[derive(Debug, Clone)]
@ -11,6 +12,12 @@ pub struct Actor {
outbox: String,
}
impl Actor {
pub fn from_raw(id: String, inbox: String, outbox: String) -> Self {
Self { id, inbox, outbox }
}
}
#[derive(Debug, Clone)]
pub struct User {
id: String,
@ -100,35 +107,74 @@ impl User {
#[derive(Debug, Clone)]
pub enum ActivityType {
Follow,
Accept,
Create,
Unknown
}
impl ActivityType {
fn to_raw(self) -> String {
match self {
ActivityType::Follow => "Follow".to_string(),
ActivityType::Accept => "Accept".to_string(),
ActivityType::Create => "Create".to_string(),
ActivityType::Unknown => "FIXME".to_string()
}
}
}
#[derive(Debug, Clone)]
pub struct Activity {
pub struct Activity<T : Serialize + Debug> {
pub id: String,
pub ty: ActivityType,
pub object: String,
pub object: T,
pub published: DateTime<Local>,
pub to: Vec<String>,
pub cc: Vec<String>,
}
impl <T : Serialize + Debug + Default> Default for Activity<T> {
fn default() -> Self {
Self {
id: Default::default(),
ty: ActivityType::Unknown,
object: Default::default(),
published: Local::now(),
to: Default::default(),
cc: Default::default(),
}
}
}
pub type KeyId = String;
#[derive(Debug, Clone)]
pub struct OutgoingActivity {
pub struct OutgoingActivity<T : Serialize + Debug> {
pub signed_by: KeyId,
pub req: Activity,
pub req: Activity<T>,
pub to: Actor,
}
impl <T : Serialize + Debug> OutgoingActivity<T> {
pub async fn save(&self, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
let ty = self.req.ty.clone().to_raw();
sqlx::query!(
r#"
INSERT INTO activity (id, ty, actor_id)
VALUES (?1, ?2, ?3)
"#,
self.req.id,
ty,
self.to.id
)
.execute(conn)
.await
.unwrap();
}
}
#[derive(Serialize, Deserialize, Debug)]
struct RawActivity {
struct RawActivity<T : Serialize + Debug> {
#[serde(rename = "@context")]
#[serde(skip_deserializing)]
context: String,
@ -138,7 +184,7 @@ struct RawActivity {
ty: String,
actor: String,
object: String,
object: T,
published: String,
}
@ -153,7 +199,7 @@ impl<'a> Outbox<'a> {
&self.user
}
pub async fn post(&self, activity: OutgoingActivity) {
pub async fn post<T : Serialize + Debug>(&self, activity: OutgoingActivity<T>) {
dbg!(&activity);
let raw = RawActivity {
context: "https://www.w3.org/ns/activitystreams".to_string(),

View file

@ -10,4 +10,6 @@ rocket_db_pools = { version = "0.2.0", features = ["sqlx_sqlite"] }
reqwest = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
chrono = { workspace = true }
url = "2.5.4"

View file

@ -2,4 +2,5 @@ pub mod user;
pub mod apps;
pub mod instance;
pub mod status;
pub mod preferences;
pub mod preferences;
pub mod timeline;

View file

@ -1,14 +1,15 @@
use chrono::Local;
use main::ap::{self, http::HttpClient};
use rocket::{
FromForm,
FromForm, State,
form::Form,
post,
serde::{Deserialize, Serialize},
};
use rocket_db_pools::Connection;
use uuid::Uuid;
use main::ap;
use crate::{AuthenticatedUser, Db};
use crate::{AuthenticatedUser, Db, types::content};
#[derive(Serialize, Deserialize, Debug, FromForm)]
#[serde(crate = "rocket::serde")]
pub struct Status {
@ -16,25 +17,88 @@ pub struct Status {
}
#[post("/statuses", data = "<status>")]
pub async fn new_status(mut db: Connection<Db>, status: Form<Status>, user: AuthenticatedUser) {
pub async fn new_status(
mut db: Connection<Db>,
http: &State<HttpClient>,
status: Form<Status>,
user: AuthenticatedUser,
) {
let user = ap::User::from_actor_id(&user.actor_id, &mut **db).await;
let outbox = ap::Outbox::for_user(user.clone(), http);
let post_id = Uuid::new_v4();
let uri = format!("https://ferri.amy.mov/users/{}/posts/{}", user.username(), post_id);
let uri = format!(
"https://ferri.amy.mov/users/{}/posts/{}",
user.username(),
post_id
);
let id = user.id();
let now = Local::now().to_rfc3339();
let post = sqlx::query!(
r#"
INSERT INTO post (id, user_id, content)
VALUES (?1, ?2, ?3)
INSERT INTO post (id, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4)
RETURNING *
"#,
uri,
id,
status.status
status.status,
now
)
.fetch_one(&mut **db)
.await
.unwrap();
dbg!(user, status, post);
let actors = sqlx::query!("SELECT * FROM actor")
.fetch_all(&mut **db)
.await
.unwrap();
for record in actors {
// Don't send to ourselves
if &record.id == user.actor_id() {
continue
}
let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4());
let activity = ap::Activity {
id: create_id,
ty: ap::ActivityType::Create,
object: content::Post {
context: "https://www.w3.org/ns/activitystreams".to_string(),
id: uri.clone(),
content: status.status.clone(),
ty: "Note".to_string(),
ts: Local::now().to_rfc3339(),
to: vec![format!(
"https://ferri.amy.mov/users/{}/followers",
user.username()
)],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
},
to: vec![format!(
"https://ferri.amy.mov/users/{}/followers",
user.username()
)],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
..Default::default()
};
let actor = ap::Actor::from_raw(
record.id.clone(),
record.inbox.clone(),
record.outbox.clone(),
);
let req = ap::OutgoingActivity {
req: activity,
signed_by: format!("https://ferri.amy.mov/users/{}#main-key", user.username()),
to: actor,
};
req.save(&mut **db).await;
outbox.post(req).await;
}
}

View file

@ -0,0 +1,91 @@
use crate::{Db, endpoints::api::user::CredentialAcount};
use rocket::{
get,
serde::{Deserialize, Serialize, json::Json},
};
use rocket_db_pools::Connection;
pub type TimelineAccount = CredentialAcount;
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct TimelineStatus {
id: String,
created_at: String,
in_reply_to_id: Option<String>,
in_reply_to_account_id: Option<String>,
content: String,
visibility: String,
spoiler_text: String,
sensitive: bool,
uri: String,
url: String,
replies_count: i64,
reblogs_count: i64,
favourites_count: i64,
favourited: bool,
reblogged: bool,
muted: bool,
bookmarked: bool,
media_attachments: Vec<()>,
account: TimelineAccount,
}
#[get("/timelines/home?<limit>")]
pub async fn home(mut db: Connection<Db>, limit: i64) -> Json<Vec<TimelineStatus>> {
let posts = sqlx::query!(
r#"
SELECT p.id as "post_id", u.id as "user_id", p.content, u.username, u.display_name, u.actor_id FROM post p
INNER JOIN user u on p.user_id = u.id
"#
)
.fetch_all(&mut **db)
.await
.unwrap();
let mut out = Vec::<TimelineStatus>::new();
for record in posts {
out.push(TimelineStatus {
id: record.post_id.clone(),
created_at: "2025-04-10T22:12:09Z".to_string(),
in_reply_to_id: None,
in_reply_to_account_id: None,
content: record.content.clone(),
visibility: "public".to_string(),
spoiler_text: "".to_string(),
sensitive: false,
uri: record.post_id.clone(),
url: record.post_id.clone(),
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
muted: false,
bookmarked: false,
media_attachments: vec![],
account: CredentialAcount {
id: record.actor_id.clone(),
username: record.username.clone(),
acct: record.username.clone(),
display_name: record.display_name.clone(),
locked: false,
bot: false,
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: record.actor_id.clone(),
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
last_status_at: "2025-04-10T22:14:34Z".to_string(),
},
});
}
Json(out)
}

View file

@ -1,4 +1,3 @@
use chrono::Local;
use main::ap;
use rocket::{
State, get, post,
@ -72,7 +71,7 @@ pub async fn new_follow(
id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()),
ty: ap::ActivityType::Follow,
object: followed.actor_id().to_string(),
published: Local::now(),
..Default::default()
};
let req = ap::OutgoingActivity {
@ -84,5 +83,6 @@ pub async fn new_follow(
to: followed.actor().clone(),
};
req.save(&mut **db).await;
outbox.post(req).await;
}

View file

@ -87,7 +87,6 @@ pub async fn test(http: &State<HttpClient>) -> &'static str {
let post = activity::CreateActivity {
id: "https://ferri.amy.mov/activities/amy/20".to_string(),
ty: "Create".to_string(),
summary: "Amy create a note".to_string(),
actor: "https://ferri.amy.mov/users/amy".to_string(),
object: content::Post {
context: "https://www.w3.org/ns/activitystreams".to_string(),

View file

@ -0,0 +1,173 @@
use main::ap;
use rocket::{State, post};
use rocket_db_pools::Connection;
use rocket::serde::json::serde_json;
use sqlx::Sqlite;
use url::Url;
use uuid::Uuid;
use chrono::Local;
use crate::{
Db,
http::HttpClient,
types::{Person, activity},
};
fn handle_delete_activity(activity: activity::DeleteActivity) {
dbg!(activity);
}
async fn create_actor(user: &Person, actor: String, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
sqlx::query!(
r#"
INSERT INTO actor (id, inbox, outbox)
VALUES ( ?1, ?2, ?3 )
ON CONFLICT(id) DO NOTHING;
"#,
actor,
user.inbox,
user.outbox
)
.execute(conn)
.await
.unwrap();
}
async fn create_user(user: &Person, actor: String, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
// HACK: Allow us to formulate a `user@host` username by assuming the actor is on the same host as the user
let url = Url::parse(&actor).unwrap();
let host = url.host_str().unwrap();
let username = format!("{}@{}", user.name, host);
let uuid = Uuid::new_v4().to_string();
sqlx::query!(
r#"
INSERT INTO user (id, username, actor_id, display_name)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(actor_id) DO NOTHING;
"#,
uuid,
username,
actor,
user.preferred_username
)
.execute(conn)
.await
.unwrap();
}
async fn create_follow(activity: &activity::FollowActivity, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
sqlx::query!(
r#"
INSERT INTO follow (id, follower_id, followed_id)
VALUES ( ?1, ?2, ?3 )
ON CONFLICT(id) DO NOTHING;
"#,
activity.id,
activity.actor,
activity.object
)
.execute(conn)
.await
.unwrap();
}
async fn handle_follow_activity(followed_account: String, activity: activity::FollowActivity, http: &HttpClient, mut db: Connection<Db>) {
let user = http
.get(&activity.actor)
.activity()
.send()
.await
.unwrap()
.json::<Person>()
.await
.unwrap();
create_actor(&user, activity.actor.clone(), &mut **db).await;
create_user(&user, activity.actor.clone(), &mut **db).await;
create_follow(&activity, &mut **db).await;
let follower = ap::User::from_actor_id(&activity.actor, &mut **db).await;
let followed = ap::User::from_username(&followed_account, &mut **db).await;
let outbox = ap::Outbox::for_user(followed.clone(), http);
let activity = ap::Activity {
id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()),
ty: ap::ActivityType::Accept,
object: activity.id,
..Default::default()
};
let req = ap::OutgoingActivity {
signed_by: format!(
"https://ferri.amy.mov/users/{}#main-key",
followed.username()
),
req: activity,
to: follower.actor().clone(),
};
req.save(&mut **db).await;
outbox.post(req).await;
}
async fn handle_create_activity(activity: activity::CreateActivity,http: &HttpClient, mut db: Connection<Db>) {
assert!(&activity.object.ty == "Note");
let user = http
.get(&activity.actor)
.activity()
.send()
.await
.unwrap()
.json::<Person>()
.await
.unwrap();
create_actor(&user, activity.actor.clone(), &mut **db).await;
create_user(&user, activity.actor.clone(), &mut **db).await;
let user = ap::User::from_actor_id(&activity.actor, &mut **db).await;
let post_id = Uuid::new_v4();
let uri = format!(
"https://ferri.amy.mov/users/{}/posts/{}",
user.username(),
post_id
);
let id = user.id();
let now = Local::now().to_rfc3339();
let content = activity.object.content.clone();
sqlx::query!(r#"
INSERT INTO post (id, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4)
"#, uri, id, content, now)
.execute(&mut **db)
.await.unwrap();
}
#[post("/users/<user>/inbox", data = "<body>")]
pub async fn inbox(db: Connection<Db>, http: &State<HttpClient>, user: String, body: String) {
let min = serde_json::from_str::<activity::MinimalActivity>(&body).unwrap();
match min.ty.as_str() {
"Delete" => {
let activity = serde_json::from_str::<activity::DeleteActivity>(&body).unwrap();
handle_delete_activity(activity);
}
"Follow" => {
let activity = serde_json::from_str::<activity::FollowActivity>(&body).unwrap();
handle_follow_activity(user, activity, http.inner(), db).await;
},
"Create" => {
let activity = serde_json::from_str::<activity::CreateActivity>(&body).unwrap();
handle_create_activity(activity, http.inner(), db).await;
},
unknown => {
eprintln!("WARN: Unknown activity '{}' - {}", unknown, body);
}
}
dbg!(min);
println!("Body in inbox: {}", body);
}

View file

@ -6,6 +6,7 @@ pub mod oauth;
pub mod api;
pub mod well_known;
pub mod custom;
pub mod inbox;
fn activity_type() -> ContentType {
ContentType(MediaType::new("application", "activity+json"))

View file

@ -1,14 +1,12 @@
use main::ap;
use rocket::{State, get, http::ContentType, post, serde::json::Json};
use rocket::{get, http::ContentType, serde::json::Json};
use rocket_db_pools::Connection;
use crate::{
Db,
http::HttpClient,
types::{OrderedCollection, Person, UserKey, activity, content},
types::{OrderedCollection, Person, UserKey, content},
};
use rocket::serde::json::serde_json;
use super::activity_type;
@ -22,91 +20,6 @@ pub async fn inbox(user: String) -> Json<OrderedCollection> {
})
}
#[post("/users/<user>/inbox", data = "<body>")]
pub async fn post_inbox(
mut db: Connection<Db>,
http: &State<HttpClient>,
user: String,
body: String,
) {
let min = serde_json::from_str::<activity::MinimalActivity>(&body).unwrap();
match min.ty.as_str() {
"Delete" => {
let activity = serde_json::from_str::<activity::DeleteActivity>(&body);
dbg!(activity.unwrap());
}
"Follow" => {
let activity = serde_json::from_str::<activity::FollowActivity>(&body).unwrap();
dbg!(&activity);
let user = http
.get(&activity.actor)
.activity()
.send()
.await
.unwrap()
.json::<Person>()
.await
.unwrap();
sqlx::query!(
r#"
INSERT INTO actor (id, inbox, outbox)
VALUES ( ?1, ?2, ?3 )
ON CONFLICT(id) DO NOTHING;
"#,
activity.actor,
user.inbox,
user.outbox
)
.execute(&mut **db)
.await
.unwrap();
sqlx::query!(
r#"
INSERT INTO follow (id, follower_id, followed_id)
VALUES ( ?1, ?2, ?3 )
ON CONFLICT(id) DO NOTHING;
"#,
activity.id,
activity.actor,
activity.object
)
.execute(&mut **db)
.await
.unwrap();
let accept = activity::AcceptActivity {
ty: "Accept".to_string(),
actor: "https://ferri.amy.mov/users/amy".to_string(),
object: activity.id,
};
let key_id = "https://ferri.amy.mov/users/amy#main-key";
let accept_res = http
.post(user.inbox)
.json(&accept)
.sign(key_id)
.activity()
.send()
.await
.unwrap()
.text()
.await
.unwrap();
dbg!(accept_res);
}
unknown => {
eprintln!("WARN: Unknown activity '{}' - {}", unknown, body);
}
}
dbg!(min);
println!("Body in inbox: {}", body);
}
#[get("/users/<user>/outbox")]
pub async fn outbox(user: String) -> Json<OrderedCollection> {
dbg!(&user);

View file

@ -1,20 +1,14 @@
use main::ap::{http};
use main::ap::http;
use rocket::{
build, get, http::ContentType, request::{FromRequest, Outcome}, routes, serde::{
json::Json, Deserialize, Serialize
}, Build, Request, Rocket
build, get, http::ContentType, request::{FromRequest, Outcome}, routes, Build, Request, Rocket
};
use endpoints::{api::{self, timeline}, oauth, well_known, custom, user, inbox};
use rocket_db_pools::{sqlx, Database};
mod cors;
mod types;
mod endpoints;
use endpoints::{api::{self, user::CredentialAcount}, oauth, well_known, custom, user};
use rocket_db_pools::sqlx;
use rocket_db_pools::Database;
#[derive(Database)]
#[database("sqlite_ferri")]
pub struct Db(sqlx::SqlitePool);
@ -54,88 +48,18 @@ impl<'a> FromRequest<'a> for AuthenticatedUser {
}
}
pub type TimelineAccount = CredentialAcount;
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
struct TimelineStatus {
id: String,
created_at: String,
in_reply_to_id: Option<String>,
in_reply_to_account_id: Option<String>,
content: String,
visibility: String,
spoiler_text: String,
sensitive: bool,
uri: String,
url: String,
replies_count: i64,
reblogs_count: i64,
favourites_count: i64,
favourited: bool,
reblogged: bool,
muted: bool,
bookmarked: bool,
media_attachments: Vec<()>,
account: TimelineAccount,
}
#[get("/timelines/home?<limit>")]
async fn home_timeline(limit: i64) -> Json<Vec<TimelineStatus>> {
Json(vec![TimelineStatus {
id: "1".to_string(),
created_at: "2025-04-10T22:12:09Z".to_string(),
in_reply_to_id: None,
in_reply_to_account_id: None,
content: "My first post".to_string(),
visibility: "public".to_string(),
spoiler_text: "".to_string(),
sensitive: false,
uri: "https://ferri.amy.mov/users/amy/posts/1".to_string(),
url: "https://ferri.amy.mov/users/amy/posts/1".to_string(),
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
muted: false,
bookmarked: false,
media_attachments: vec![],
account: CredentialAcount {
id: "https://ferri.amy.mov/users/amy".to_string(),
username: "amy".to_string(),
acct: "amy@ferri.amy.mov".to_string(),
display_name: "amy".to_string(),
locked: false,
bot: false,
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: "https://ferri.amy.mov/@amy".to_string(),
avatar: "https://i.sstatic.net/l60Hf.png".to_string(),
avatar_static: "https://i.sstatic.net/l60Hf.png".to_string(),
header: "https://i.sstatic.net/l60Hf.png".to_string(),
header_static: "https://i.sstatic.net/l60Hf.png".to_string(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
last_status_at: "2025-04-10T22:14:34Z".to_string(),
},
}])
}
pub fn launch() -> Rocket<Build> {
let http_client = http::HttpClient::new();
build()
.manage(http_client)
.attach(Db::init())
.attach(cors::CORS)
.mount("/assets", rocket::fs::FileServer::from("./assets"))
.mount(
"/",
routes![
custom::test,
user::inbox,
user::post_inbox,
user::outbox,
user::user,
user::followers,
@ -147,6 +71,7 @@ pub fn launch() -> Rocket<Build> {
activity_endpoint,
well_known::webfinger,
well_known::host_meta,
inbox::inbox,
user_profile,
],
)
@ -160,7 +85,7 @@ pub fn launch() -> Rocket<Build> {
api::preferences::preferences,
api::user::verify_credentials,
custom::finger_account,
home_timeline
timeline::home
],
)
}

View file

@ -2,19 +2,6 @@ use rocket::serde::{Deserialize, Serialize};
use crate::types::content::Post;
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct Activity {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
pub summary: String,
pub actor: String,
pub object: String,
pub published: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct MinimalActivity {
@ -45,9 +32,9 @@ pub struct CreateActivity {
pub actor: String,
pub to: Vec<String>,
pub cc: Vec<String>,
#[serde(rename = "published")]
pub ts: String,
pub summary: String,
}
#[derive(Serialize, Deserialize, Debug)]

View file

@ -1,6 +1,6 @@
use rocket::serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(crate = "rocket::serde")]
pub struct Post {
// FIXME: This is because Masto sends an array but we don't care

View file

@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS follow
(
-- Activity ID
id TEXT PRIMARY KEY NOT NULL,
-- Activity ID
id TEXT PRIMARY KEY NOT NULL,
follower_id TEXT NOT NULL,
followed_id TEXT NOT NULL,
FOREIGN KEY(follower_id) REFERENCES actor(id),

View file

@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS user
-- UUID
id TEXT PRIMARY KEY NOT NULL,
username TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
FOREIGN KEY(actor_id) REFERENCES actor(id)

View file

@ -1,9 +1,10 @@
CREATE TABLE IF NOT EXISTS post
(
-- Uri
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id)
);

View file

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS activity
(
-- UUID
id TEXT PRIMARY KEY NOT NULL,
ty TEXT NOT NULL,
actor_id TEXT NOT NULL,
FOREIGN KEY(actor_id) REFERENCES actor(id)
);