HUGE rewrite to use Prisma instead of TypeORM (not finished yet)

This commit is contained in:
Jesse Wierzbinski 2023-11-10 16:36:06 -10:00
parent a1c0164e9d
commit 5eed8374cd
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
53 changed files with 1452 additions and 1967 deletions

View file

@ -1,5 +1,6 @@
import { DataSource } from "typeorm";
import { getConfig } from "../utils/config";
import { PrismaClient } from "@prisma/client";
const config = getConfig();
@ -14,4 +15,8 @@ const AppDataSource = new DataSource({
entities: [process.cwd() + "/database/entities/*.ts"],
});
export { AppDataSource };
const client = new PrismaClient({
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,
});
export { AppDataSource, client };

View file

@ -1,76 +1,42 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { APIApplication } from "~types/entities/application";
import { Token } from "./Token";
import { Application } from "@prisma/client";
import { client } from "~database/datasource";
/**
* Represents an application that can authenticate with the API.
*/
@Entity({
name: "applications",
})
export class Application extends BaseEntity {
/** The unique identifier for this application. */
@PrimaryGeneratedColumn("uuid")
id!: string;
/** The name of this application. */
@Column("varchar")
name!: string;
/**
* Retrieves the application associated with the given access token.
* @param token The access token to retrieve the application for.
* @returns The application associated with the given access token, or null if no such application exists.
*/
export const getFromToken = async (
token: string
): Promise<Application | null> => {
const dbToken = await client.token.findFirst({
where: {
access_token: token,
},
include: {
application: true,
},
});
/** The website associated with this application, if any. */
@Column("varchar", {
nullable: true,
})
website!: string | null;
return dbToken?.application || null;
};
/** The VAPID key associated with this application, if any. */
@Column("varchar", {
nullable: true,
})
vapid_key!: string | null;
/** The client ID associated with this application. */
@Column("varchar")
client_id!: string;
/** The secret associated with this application. */
@Column("varchar")
secret!: string;
/** The scopes associated with this application. */
@Column("varchar")
scopes = "read";
/** The redirect URIs associated with this application. */
@Column("varchar")
redirect_uris = "urn:ietf:wg:oauth:2.0:oob";
/**
* Retrieves the application associated with the given access token.
* @param token The access token to retrieve the application for.
* @returns The application associated with the given access token, or null if no such application exists.
*/
static async getFromToken(token: string): Promise<Application | null> {
const dbToken = await Token.findOne({
where: {
access_token: token,
},
relations: ["application"],
});
return dbToken?.application || null;
}
/**
* Converts this application to an API application.
* @returns The API application representation of this application.
*/
/**
* Converts this application to an API application.
* @returns The API application representation of this application.
*/
export const applicationToAPI = async (
app: Application
// eslint-disable-next-line @typescript-eslint/require-await
async toAPI(): Promise<APIApplication> {
return {
name: this.name,
website: this.website,
vapid_key: this.vapid_key,
};
}
}
): Promise<APIApplication> => {
return {
name: app.name,
website: app.website,
vapid_key: app.vapid_key,
};
};

View file

@ -1,137 +1,78 @@
import {
BaseEntity,
Column,
Entity,
IsNull,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { APIEmoji } from "~types/entities/emoji";
import { Instance } from "./Instance";
import { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis";
import { client } from "~database/datasource";
import { Emoji } from "@prisma/client";
/**
* Represents an emoji entity in the database.
*/
@Entity({
name: "emojis",
})
export class Emoji extends BaseEntity {
/**
* The unique identifier for the emoji.
*/
@PrimaryGeneratedColumn("uuid")
id!: string;
/**
* The shortcode for the emoji.
*/
@Column("varchar")
shortcode!: string;
/**
* The instance that the emoji is from.
* If is null, the emoji is from the server's instance
*/
@ManyToOne(() => Instance, {
nullable: true,
})
instance!: Instance | null;
/**
* The URL for the emoji.
*/
@Column("varchar")
url!: string;
/**
* The alt text for the emoji.
*/
@Column("varchar", {
nullable: true,
})
alt!: string | null;
/**
* The content type of the emoji.
*/
@Column("varchar")
content_type!: string;
/**
* Whether the emoji is visible in the picker.
*/
@Column("boolean")
visible_in_picker!: boolean;
/**
* Used for parsing emojis from local text
* @param text The text to parse
* @returns An array of emojis
*/
static async parseEmojis(text: string): Promise<Emoji[]> {
const regex = /:[a-zA-Z0-9_]+:/g;
const matches = text.match(regex);
if (!matches) return [];
return (
await Promise.all(
matches.map(match =>
Emoji.findOne({
where: {
shortcode: match.slice(1, -1),
instance: IsNull(),
},
relations: ["instance"],
})
)
)
).filter(emoji => emoji !== null) as Emoji[];
}
static async addIfNotExists(emoji: LysandEmoji) {
const existingEmoji = await Emoji.findOne({
where: {
shortcode: emoji.name,
instance: IsNull(),
/**
* Used for parsing emojis from local text
* @param text The text to parse
* @returns An array of emojis
*/
export const parseEmojis = async (text: string): Promise<Emoji[]> => {
const regex = /:[a-zA-Z0-9_]+:/g;
const matches = text.match(regex);
if (!matches) return [];
return await client.emoji.findMany({
where: {
shortcode: {
in: matches.map(match => match.replace(/:/g, "")),
},
});
if (existingEmoji) return existingEmoji;
const newEmoji = new Emoji();
newEmoji.shortcode = emoji.name;
// TODO: Content types
newEmoji.url = emoji.url[0].content;
newEmoji.alt = emoji.alt || null;
newEmoji.content_type = emoji.url[0].content_type;
newEmoji.visible_in_picker = true;
await newEmoji.save();
return newEmoji;
}
},
include: {
instance: true,
},
});
};
/**
* Converts the emoji to an APIEmoji object.
* @returns The APIEmoji object.
*/
// eslint-disable-next-line @typescript-eslint/require-await
async toAPI(): Promise<APIEmoji> {
return {
shortcode: this.shortcode,
static_url: this.url, // TODO: Add static version
url: this.url,
visible_in_picker: this.visible_in_picker,
category: undefined,
};
}
export const addEmojiIfNotExists = async (emoji: LysandEmoji) => {
const existingEmoji = await client.emoji.findFirst({
where: {
shortcode: emoji.name,
instance: null,
},
});
toLysand(): LysandEmoji {
return {
name: this.shortcode,
url: [
{
content: this.url,
content_type: this.content_type,
},
],
alt: this.alt || undefined,
};
}
}
if (existingEmoji) return existingEmoji;
return await client.emoji.create({
data: {
shortcode: emoji.name,
url: emoji.url[0].content,
alt: emoji.alt || null,
content_type: emoji.url[0].content_type,
visible_in_picker: true,
},
});
};
/**
* Converts the emoji to an APIEmoji object.
* @returns The APIEmoji object.
*/
// eslint-disable-next-line @typescript-eslint/require-await
export const emojiToAPI = async (emoji: Emoji): Promise<APIEmoji> => {
return {
shortcode: emoji.shortcode,
static_url: emoji.url, // TODO: Add static version
url: emoji.url,
visible_in_picker: emoji.visible_in_picker,
category: undefined,
};
};
export const emojiToLysand = (emoji: Emoji): LysandEmoji => {
return {
name: emoji.shortcode,
url: [
{
content: emoji.url,
content_type: emoji.content_type,
},
],
alt: emoji.alt || undefined,
};
};

View file

@ -1,90 +1,49 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { ContentFormat, ServerMetadata } from "~types/lysand/Object";
import { Instance } from "@prisma/client";
import { client } from "~database/datasource";
import { ServerMetadata } from "~types/lysand/Object";
/**
* Represents an instance in the database.
*/
@Entity({
name: "instances",
})
export class Instance extends BaseEntity {
/**
* The unique identifier of the instance.
*/
@PrimaryGeneratedColumn("uuid")
id!: string;
/**
* The base URL of the instance.
* Must not have the https:// or http:// prefix.
*/
@Column("varchar")
base_url!: string;
/**
* Adds an instance to the database if it doesn't already exist.
* @param url
* @returns Either the database instance if it already exists, or a newly created instance.
*/
export const addInstanceIfNotExists = async (
url: string
): Promise<Instance> => {
const origin = new URL(url).origin;
const hostname = new URL(url).hostname;
/**
* The name of the instance.
*/
@Column("varchar")
name!: string;
const found = await client.instance.findFirst({
where: {
base_url: hostname,
},
});
/**
* The description of the instance.
*/
@Column("varchar")
version!: string;
if (found) return found;
/**
* The logo of the instance.
*/
@Column("jsonb")
logo?: ContentFormat[];
// Fetch the instance configuration
const metadata = (await fetch(`${origin}/.well-known/lysand`).then(res =>
res.json()
)) as Partial<ServerMetadata>;
/**
* The banner of the instance.
*/
banner?: ContentFormat[];
/**
* Adds an instance to the database if it doesn't already exist.
* @param url
* @returns Either the database instance if it already exists, or a newly created instance.
*/
static async addIfNotExists(url: string): Promise<Instance> {
const origin = new URL(url).origin;
const hostname = new URL(url).hostname;
const found = await Instance.findOne({
where: {
base_url: hostname,
},
});
if (found) return found;
const instance = new Instance();
instance.base_url = hostname;
// Fetch the instance configuration
const metadata = (await fetch(`${origin}/.well-known/lysand`).then(
res => res.json()
)) as Partial<ServerMetadata>;
if (metadata.type !== "ServerMetadata") {
throw new Error("Invalid instance metadata");
}
if (!(metadata.name && metadata.version)) {
throw new Error("Invalid instance metadata");
}
instance.name = metadata.name;
instance.version = metadata.version;
instance.logo = metadata.logo;
instance.banner = metadata.banner;
await instance.save();
return instance;
if (metadata.type !== "ServerMetadata") {
throw new Error("Invalid instance metadata");
}
}
if (!(metadata.name && metadata.version)) {
throw new Error("Invalid instance metadata");
}
return await client.instance.create({
data: {
base_url: hostname,
name: metadata.name,
version: metadata.version,
logo: metadata.logo as any,
},
});
};

View file

@ -1,45 +1,19 @@
import {
BaseEntity,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./User";
import { Status } from "./Status";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Like as LysandLike } from "~types/lysand/Object";
import { getConfig } from "@config";
import { Like } from "@prisma/client";
/**
* Represents a Like entity in the database.
*/
@Entity({
name: "likes",
})
export class Like extends BaseEntity {
/** The unique identifier of the Like. */
@PrimaryGeneratedColumn("uuid")
id!: string;
/** The User who liked the Status. */
@ManyToOne(() => User)
liker!: User;
/** The Status that was liked. */
@ManyToOne(() => Status)
liked!: Status;
@CreateDateColumn()
created_at!: Date;
toLysand(): LysandLike {
return {
id: this.id,
author: this.liker.uri,
type: "Like",
created_at: new Date(this.created_at).toISOString(),
object: this.liked.toLysand().uri,
uri: `${getConfig().http.base_url}/actions/${this.id}`,
};
}
}
export const toLysand = (like: Like): LysandLike => {
return {
id: like.id,
author: (like as any).liker?.uri,
type: "Like",
created_at: new Date(like.createdAt).toISOString(),
object: (like as any).liked?.uri,
uri: `${getConfig().http.base_url}/actions/${like.id}`,
};
};

View file

@ -1,147 +1,87 @@
import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { LysandObject } from "@prisma/client";
import { client } from "~database/datasource";
import { LysandObjectType } from "~types/lysand/Object";
/**
* Represents a Lysand object in the database.
*/
@Entity({
name: "objects",
})
export class LysandObject extends BaseEntity {
/**
* The unique identifier for the object. If local, same as `remote_id`
*/
@PrimaryGeneratedColumn("uuid")
id!: string;
/**
* UUID of the object across the network. If the object is local, same as `id`
*/
remote_id!: string;
export const createFromObject = async (object: LysandObjectType) => {
const foundObject = await client.lysandObject.findFirst({
where: { remote_id: object.id },
include: {
author: true,
},
});
/**
* Any valid Lysand type, such as `Note`, `Like`, `Follow`, etc.
*/
@Column("varchar")
type!: string;
/**
* Remote URI for the object
* Example: `https://example.com/publications/ef235cc6-d68c-4756-b0df-4e6623c4d51c`
*/
@Column("varchar")
uri!: string;
@Column("timestamp")
created_at!: Date;
/**
* References an Actor object
*/
@ManyToOne(() => LysandObject, object => object.uri, {
nullable: true,
})
author!: LysandObject | null;
@Column("jsonb")
extra_data!: Omit<
Omit<Omit<Omit<LysandObjectType, "created_at">, "id">, "uri">,
"type"
>;
@Column("jsonb")
extensions!: Record<string, any>;
static new(type: string, uri: string): LysandObject {
const object = new LysandObject();
object.type = type;
object.uri = uri;
object.created_at = new Date();
return object;
if (foundObject) {
return foundObject;
}
static async createFromObject(object: LysandObjectType) {
let newObject: LysandObject;
const author = await client.lysandObject.findFirst({
where: { uri: (object as any).author },
});
const foundObject = await LysandObject.findOne({
where: { remote_id: object.id },
relations: ["author"],
});
return await client.lysandObject.create({
data: {
authorId: author?.id,
created_at: new Date(object.created_at),
extensions: object.extensions || {},
remote_id: object.id,
type: object.type,
uri: object.uri,
// Rest of data (remove id, author, created_at, extensions, type, uri)
extra_data: Object.fromEntries(
Object.entries(object).filter(
([key]) =>
![
"id",
"author",
"created_at",
"extensions",
"type",
"uri",
].includes(key)
)
),
},
});
};
if (foundObject) {
newObject = foundObject;
} else {
newObject = new LysandObject();
}
export const toLysand = (lyObject: LysandObject): LysandObjectType => {
return {
id: lyObject.remote_id || lyObject.id,
created_at: new Date(lyObject.created_at).toISOString(),
type: lyObject.type,
uri: lyObject.uri,
// @ts-expect-error This works, I promise
...lyObject.extra_data,
extensions: lyObject.extensions,
};
};
const author = await LysandObject.findOne({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
where: { uri: (object as any).author },
});
export const isPublication = (lyObject: LysandObject): boolean => {
return lyObject.type === "Note" || lyObject.type === "Patch";
};
newObject.author = author;
newObject.created_at = new Date(object.created_at);
newObject.extensions = object.extensions || {};
newObject.remote_id = object.id;
newObject.type = object.type;
newObject.uri = object.uri;
// Rest of data (remove id, author, created_at, extensions, type, uri)
newObject.extra_data = Object.fromEntries(
Object.entries(object).filter(
([key]) =>
![
"id",
"author",
"created_at",
"extensions",
"type",
"uri",
].includes(key)
)
);
export const isAction = (lyObject: LysandObject): boolean => {
return [
"Like",
"Follow",
"Dislike",
"FollowAccept",
"FollowReject",
"Undo",
"Announce",
].includes(lyObject.type);
};
await newObject.save();
return newObject;
}
export const isActor = (lyObject: LysandObject): boolean => {
return lyObject.type === "User";
};
toLysand(): LysandObjectType {
return {
id: this.remote_id || this.id,
created_at: new Date(this.created_at).toISOString(),
type: this.type,
uri: this.uri,
...this.extra_data,
extensions: this.extensions,
};
}
isPublication(): boolean {
return this.type === "Note" || this.type === "Patch";
}
isAction(): boolean {
return [
"Like",
"Follow",
"Dislike",
"FollowAccept",
"FollowReject",
"Undo",
"Announce",
].includes(this.type);
}
isActor(): boolean {
return this.type === "User";
}
isExtension(): boolean {
return this.type === "Extension";
}
}
export const isExtension = (lyObject: LysandObject): boolean => {
return lyObject.type === "Extension";
};

View file

@ -1,144 +1,63 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
import { User } from "./User";
import { Relationship, User } from "@prisma/client";
import { APIRelationship } from "~types/entities/relationship";
import { client } from "~database/datasource";
/**
* Stores Mastodon API relationships
*/
@Entity({
name: "relationships",
})
export class Relationship extends BaseEntity {
/** The unique identifier for the relationship. */
@PrimaryGeneratedColumn("uuid")
id!: string;
/** The user who owns the relationship. */
@ManyToOne(() => User, user => user.relationships)
owner!: User;
/**
* Creates a new relationship between two users.
* @param owner The user who owns the relationship.
* @param other The user who is the subject of the relationship.
* @returns The newly created relationship.
*/
export const createNew = async (
owner: User,
other: User
): Promise<Relationship> => {
return await client.relationship.create({
data: {
ownerId: owner.id,
subjectId: other.id,
languages: [],
following: false,
showingReblogs: false,
notifying: false,
followedBy: false,
blocking: false,
blockedBy: false,
muting: false,
mutingNotifications: false,
requested: false,
domainBlocking: false,
endorsed: false,
note: "",
},
});
};
/** The user who is the subject of the relationship. */
@ManyToOne(() => User)
subject!: User;
/** Whether the owner is following the subject. */
@Column("boolean")
following!: boolean;
/** Whether the owner is showing reblogs from the subject. */
@Column("boolean")
showing_reblogs!: boolean;
/** Whether the owner is receiving notifications from the subject. */
@Column("boolean")
notifying!: boolean;
/** Whether the owner is followed by the subject. */
@Column("boolean")
followed_by!: boolean;
/** Whether the owner is blocking the subject. */
@Column("boolean")
blocking!: boolean;
/** Whether the owner is blocked by the subject. */
@Column("boolean")
blocked_by!: boolean;
/** Whether the owner is muting the subject. */
@Column("boolean")
muting!: boolean;
/** Whether the owner is muting notifications from the subject. */
@Column("boolean")
muting_notifications!: boolean;
/** Whether the owner has requested to follow the subject. */
@Column("boolean")
requested!: boolean;
/** Whether the owner is blocking the subject's domain. */
@Column("boolean")
domain_blocking!: boolean;
/** Whether the owner has endorsed the subject. */
@Column("boolean")
endorsed!: boolean;
/** The languages the owner has specified for the subject. */
@Column("jsonb")
languages!: string[];
/** A note the owner has added for the subject. */
@Column("varchar")
note!: string;
/** The date the relationship was created. */
@CreateDateColumn()
created_at!: Date;
/** The date the relationship was last updated. */
@UpdateDateColumn()
updated_at!: Date;
/**
* Creates a new relationship between two users.
* @param owner The user who owns the relationship.
* @param other The user who is the subject of the relationship.
* @returns The newly created relationship.
*/
static async createNew(owner: User, other: User): Promise<Relationship> {
const newRela = new Relationship();
newRela.owner = owner;
newRela.subject = other;
newRela.languages = [];
newRela.following = false;
newRela.showing_reblogs = false;
newRela.notifying = false;
newRela.followed_by = false;
newRela.blocking = false;
newRela.blocked_by = false;
newRela.muting = false;
newRela.muting_notifications = false;
newRela.requested = false;
newRela.domain_blocking = false;
newRela.endorsed = false;
newRela.note = "";
await newRela.save();
return newRela;
}
/**
* Converts the relationship to an API-friendly format.
* @returns The API-friendly relationship.
*/
// eslint-disable-next-line @typescript-eslint/require-await
async toAPI(): Promise<APIRelationship> {
return {
blocked_by: this.blocked_by,
blocking: this.blocking,
domain_blocking: this.domain_blocking,
endorsed: this.endorsed,
followed_by: this.followed_by,
following: this.following,
id: this.subject.id,
muting: this.muting,
muting_notifications: this.muting_notifications,
notifying: this.notifying,
requested: this.requested,
showing_reblogs: this.showing_reblogs,
languages: this.languages,
note: this.note,
};
}
}
/**
* Converts the relationship to an API-friendly format.
* @returns The API-friendly relationship.
*/
// eslint-disable-next-line @typescript-eslint/require-await
export const toAPI = async (rel: Relationship): Promise<APIRelationship> => {
return {
blocked_by: rel.blockedBy,
blocking: rel.blocking,
domain_blocking: rel.domainBlocking,
endorsed: rel.endorsed,
followed_by: rel.followedBy,
following: rel.following,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
id: (rel as any).subject.id,
muting: rel.muting,
muting_notifications: rel.mutingNotifications,
notifying: rel.notifying,
requested: rel.requested,
showing_reblogs: rel.showingReblogs,
languages: rel.languages,
note: rel.note,
};
};

File diff suppressed because it is too large Load diff

View file

@ -1,54 +1,3 @@
import {
Entity,
BaseEntity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
} from "typeorm";
import { User } from "./User";
import { Application } from "./Application";
/**
* Represents an access token for a user or application.
*/
@Entity({
name: "tokens",
})
export class Token extends BaseEntity {
/** The unique identifier for the token. */
@PrimaryGeneratedColumn("uuid")
id!: string;
/** The type of token. */
@Column("varchar")
token_type: TokenType = TokenType.BEARER;
/** The scope of the token. */
@Column("varchar")
scope!: string;
/** The access token string. */
@Column("varchar")
access_token!: string;
/** The authorization code used to obtain the token. */
@Column("varchar")
code!: string;
/** The date and time the token was created. */
@CreateDateColumn()
created_at!: Date;
/** The user associated with the token. */
@ManyToOne(() => User, user => user.id)
user!: User;
/** The application associated with the token. */
@ManyToOne(() => Application, application => application.id)
application!: Application;
}
/**
* The type of token.
*/

File diff suppressed because it is too large Load diff