docs: 📝 More work on JSDoc

This commit is contained in:
Jesse Wierzbinski 2024-06-12 22:52:03 -10:00
parent 527137f279
commit c3271ba264
No known key found for this signature in database
8 changed files with 205 additions and 52 deletions

View file

@ -48,6 +48,16 @@
] ]
} }
} }
},
"nursery": {
"noDuplicateElseIf": "warn",
"noDuplicateJsonKeys": "warn",
"noEvolvingTypes": "warn",
"noYodaExpression": "warn",
"useConsistentBuiltinInstantiation": "warn",
"useErrorMessage": "warn",
"useImportExtensions": "off",
"useThrowNewError": "warn"
} }
}, },
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"] "ignore": ["node_modules", "dist", "glitch", "glitch-dev"]

View file

@ -69,7 +69,7 @@ export default class UserCreate extends BaseCommand<typeof UserCreate> {
this.exit(1); this.exit(1);
} }
let password = null; let password: string | null = null;
if (flags["set-password"]) { if (flags["set-password"]) {
const password1 = await input({ const password1 = await input({

View file

@ -1,21 +1,60 @@
import type { InferModelFromColumns, InferSelectModel } from "drizzle-orm"; import type { InferModelFromColumns, InferSelectModel } from "drizzle-orm";
import type { PgTableWithColumns } from "drizzle-orm/pg-core"; import type { PgTableWithColumns } from "drizzle-orm/pg-core";
/**
* BaseInterface is an abstract class that provides a common interface for all models.
* It includes methods for saving, deleting, updating, and reloading data.
*
* @template Table - The type of the table with columns.
* @template Columns - The type of the columns inferred from the table.
*/
export abstract class BaseInterface< export abstract class BaseInterface<
// biome-ignore lint/suspicious/noExplicitAny: This is just an extended interface // biome-ignore lint/suspicious/noExplicitAny: This is just an extended interface
Table extends PgTableWithColumns<any>, Table extends PgTableWithColumns<any>,
Columns = InferModelFromColumns<Table["_"]["columns"]>, Columns = InferModelFromColumns<Table["_"]["columns"]>,
> { > {
/**
* Constructs a new instance of the BaseInterface.
*
* @param data - The data for the model.
*/
constructor(public data: Columns) {} constructor(public data: Columns) {}
/**
* Saves the current state of the model to the database.
*
* @returns A promise that resolves with the saved model.
*/
public abstract save(): Promise<Columns>; public abstract save(): Promise<Columns>;
/**
* Deletes the model from the database.
*
* @param ids - The ids of the models to delete.
* @returns A promise that resolves when the deletion is complete.
*/
public abstract delete(ids: string[]): Promise<void>; public abstract delete(ids: string[]): Promise<void>;
/**
* Deletes the model from the database.
*
* @returns A promise that resolves when the deletion is complete.
*/
public abstract delete(): Promise<void>; public abstract delete(): Promise<void>;
/**
* Updates the model with new data.
*
* @param newData - The new data for the model.
* @returns A promise that resolves with the updated model.
*/
public abstract update( public abstract update(
newData: Partial<InferSelectModel<Table>>, newData: Partial<InferSelectModel<Table>>,
): Promise<Columns>; ): Promise<Columns>;
/**
* Reloads the model from the database.
*
* @returns A promise that resolves when the reloading is complete.
*/
public abstract reload(): Promise<void>; public abstract reload(): Promise<void>;
} }

View file

@ -40,7 +40,7 @@ import {
} from "~/drizzle/schema"; } from "~/drizzle/schema";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import type { Attachment as apiAttachment } from "~/types/mastodon/attachment"; import type { Attachment as apiAttachment } from "~/types/mastodon/attachment";
import type { Status as apiStatus } from "~/types/mastodon/status"; import type { Status as APIStatus } from "~/types/mastodon/status";
import { Attachment } from "./attachment"; import { Attachment } from "./attachment";
import { BaseInterface } from "./base"; import { BaseInterface } from "./base";
import { Emoji } from "./emoji"; import { Emoji } from "./emoji";
@ -64,6 +64,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
this.data = reloaded.data; this.data = reloaded.data;
} }
/**
* Insert a new note into the database
* @param data - The data to insert
* @param userRequestingNoteId - The ID of the user requesting the note (used to check visibility of the note)
* @returns The inserted note
*/
public static async insert( public static async insert(
data: InferInsertModel<typeof Notes>, data: InferInsertModel<typeof Notes>,
userRequestingNoteId?: string, userRequestingNoteId?: string,
@ -79,6 +85,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return note; return note;
} }
/**
* Fetch a note from the database by its ID
* @param id - The ID of the note to fetch
* @param userRequestingNoteId - The ID of the user requesting the note (used to check visibility of the note)
* @returns The fetched note
*/
static async fromId( static async fromId(
id: string | null, id: string | null,
userRequestingNoteId?: string, userRequestingNoteId?: string,
@ -93,7 +105,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
userRequestingNoteId, userRequestingNoteId,
); );
} }
/**
* Fetch multiple notes from the database by their IDs
* @param ids - The IDs of the notes to fetch
* @param userRequestingNoteId - The ID of the user requesting the note (used to check visibility of the note)
* @returns The fetched notes
*/
static async fromIds( static async fromIds(
ids: string[], ids: string[],
userRequestingNoteId?: string, userRequestingNoteId?: string,
@ -107,11 +124,18 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
); );
} }
/**
* Fetch a note from the database by a SQL query
* @param sql - The SQL query to fetch the note with
* @param orderBy - The SQL query to order the results by
* @param userId - The ID of the user requesting the note (used to check visibility of the note)
* @returns The fetched note
*/
static async fromSql( static async fromSql(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Notes.id), orderBy: SQL<unknown> | undefined = desc(Notes.id),
userId?: string, userId?: string,
) { ): Promise<Note | null> {
const found = await findManyNotes( const found = await findManyNotes(
{ {
where: sql, where: sql,
@ -127,13 +151,22 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return new Note(found[0]); return new Note(found[0]);
} }
/**
* Fetch multiple notes from the database by a SQL query
* @param sql - The SQL query to fetch the notes with
* @param orderBy - The SQL query to order the results by
* @param limit - The maximum number of notes to fetch
* @param offset - The number of notes to skip
* @param userId - The ID of the user requesting the note (used to check visibility of the note)
* @returns - The fetched notes
*/
static async manyFromSql( static async manyFromSql(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Notes.id), orderBy: SQL<unknown> | undefined = desc(Notes.id),
limit?: number, limit?: number,
offset?: number, offset?: number,
userId?: string, userId?: string,
) { ): Promise<Note[]> {
const found = await findManyNotes( const found = await findManyNotes(
{ {
where: sql, where: sql,
@ -151,7 +184,15 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return this.data.id; return this.data.id;
} }
async getUsersToFederateTo() { /**
* Fetch the users that should be federated to for this note
*
* This includes:
* - Users mentioned in the note
* - Users that can see the note
* @returns The users that should be federated to
*/
async getUsersToFederateTo(): Promise<User[]> {
// Mentioned users // Mentioned users
const mentionedUsers = const mentionedUsers =
this.data.mentions.length > 0 this.data.mentions.length > 0
@ -194,15 +235,15 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return deduplicatedUsersById; return deduplicatedUsersById;
} }
isNull() {
return this.data === null;
}
get author() { get author() {
return new User(this.data.author); return new User(this.data.author);
} }
static async getCount() { /**
* Get the number of notes in the database (excluding remote notes)
* @returns The number of notes in the database
*/
static async getCount(): Promise<number> {
return ( return (
await db await db
.select({ .select({
@ -215,7 +256,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
)[0].count; )[0].count;
} }
async getReplyChildren(userId?: string) { /**
* Get the children of this note (replies)
* @param userId - The ID of the user requesting the note (used to check visibility of the note)
* @returns The children of this note
*/
private async getReplyChildren(userId?: string): Promise<Note[]> {
return await Note.manyFromSql( return await Note.manyFromSql(
eq(Notes.replyId, this.data.id), eq(Notes.replyId, this.data.id),
undefined, undefined,
@ -229,7 +275,11 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return this.author.isRemote(); return this.author.isRemote();
} }
async updateFromRemote() { /**
* Update a note from remote federated servers
* @returns The updated note
*/
async updateFromRemote(): Promise<Note> {
if (!this.isRemote()) { if (!this.isRemote()) {
throw new Error("Cannot refetch a local note (it is not remote)"); throw new Error("Cannot refetch a local note (it is not remote)");
} }
@ -245,10 +295,15 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return this; return this;
} }
/**
* Create a new note from user input
* @param data - The data to create the note from
* @returns The created note
*/
static async fromData(data: { static async fromData(data: {
author: User; author: User;
content: typeof EntityValidator.$ContentFormat; content: typeof EntityValidator.$ContentFormat;
visibility: apiStatus["visibility"]; visibility: APIStatus["visibility"];
isSensitive: boolean; isSensitive: boolean;
spoilerText: string; spoilerText: string;
emojis?: Emoji[]; emojis?: Emoji[];
@ -330,10 +385,15 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return newNote; return newNote;
} }
/**
* Update a note from user input
* @param data - The data to update the note from
* @returns The updated note
*/
async updateFromData(data: { async updateFromData(data: {
author?: User; author?: User;
content?: typeof EntityValidator.$ContentFormat; content?: typeof EntityValidator.$ContentFormat;
visibility?: apiStatus["visibility"]; visibility?: APIStatus["visibility"];
isSensitive?: boolean; isSensitive?: boolean;
spoilerText?: string; spoilerText?: string;
emojis?: Emoji[]; emojis?: Emoji[];
@ -405,6 +465,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return this; return this;
} }
/**
* Updates the emojis associated with this note in the database
*
* Deletes all existing emojis associated with this note, then replaces them with the provided emojis.
* @param emojis - The emojis to associate with this note
*/
public async recalculateDatabaseEmojis(emojis: Emoji[]): Promise<void> { public async recalculateDatabaseEmojis(emojis: Emoji[]): Promise<void> {
// Fuse and deduplicate // Fuse and deduplicate
const fusedEmojis = emojis.filter( const fusedEmojis = emojis.filter(
@ -428,6 +494,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
} }
} }
/**
* Updates the mentions associated with this note in the database
*
* Deletes all existing mentions associated with this note, then replaces them with the provided mentions.
* @param mentions - The mentions to associate with this note
*/
public async recalculateDatabaseMentions(mentions: User[]): Promise<void> { public async recalculateDatabaseMentions(mentions: User[]): Promise<void> {
// Connect mentions // Connect mentions
await db await db
@ -445,6 +517,12 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
} }
} }
/**
* Updates the attachments associated with this note in the database
*
* Deletes all existing attachments associated with this note, then replaces them with the provided attachments.
* @param mediaAttachments - The IDs of the attachments to associate with this note
*/
public async recalculateDatabaseAttachments( public async recalculateDatabaseAttachments(
mediaAttachments: string[], mediaAttachments: string[],
): Promise<void> { ): Promise<void> {
@ -466,19 +544,21 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
} }
} }
static async resolve( /**
uri?: string, * Resolve a note from a URI
providedNote?: typeof EntityValidator.$Note, * @param uri - The URI of the note to resolve
): Promise<Note | null> { * @returns The resolved note
*/
static async resolve(uri: string): Promise<Note | null> {
// Check if note not already in database // Check if note not already in database
const foundNote = uri && (await Note.fromSql(eq(Notes.uri, uri))); const foundNote = await Note.fromSql(eq(Notes.uri, uri));
if (foundNote) { if (foundNote) {
return foundNote; return foundNote;
} }
// Check if URI is of a local note // Check if URI is of a local note
if (uri?.startsWith(config.http.base_url)) { if (uri.startsWith(config.http.base_url)) {
const uuid = uri.match(idValidator); const uuid = uri.match(idValidator);
if (!uuid?.[0]) { if (!uuid?.[0]) {
@ -490,18 +570,16 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return await Note.fromId(uuid[0]); return await Note.fromId(uuid[0]);
} }
return await Note.saveFromRemote(uri, providedNote); return await Note.saveFromRemote(uri);
} }
static async saveFromRemote( /**
uri?: string, * Save a note from a remote server
providedNote?: typeof EntityValidator.$Note, * @param uri - The URI of the note to save
): Promise<Note | null> { * @returns The saved note, or null if the note could not be fetched
if (!(uri || providedNote)) { */
throw new Error("No URI or note provided"); static async saveFromRemote(uri: string): Promise<Note | null> {
} let note: typeof EntityValidator.$Note | null = null;
let note = providedNote || null;
if (uri) { if (uri) {
if (!URL.canParse(uri)) { if (!URL.canParse(uri)) {
@ -531,11 +609,17 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
return await Note.fromLysand(note, author); return await Note.fromLysand(note, author);
} }
/**
* Turns a Lysand Note into a database note (saved)
* @param note Lysand Note
* @param author Author of the note
* @returns The saved note
*/
static async fromLysand( static async fromLysand(
note: typeof EntityValidator.$Note, note: typeof EntityValidator.$Note,
author: User, author: User,
): Promise<Note> { ): Promise<Note> {
const emojis = []; const emojis: Emoji[] = [];
for (const emoji of note.extensions?.["org.lysand:custom_emojis"] for (const emoji of note.extensions?.["org.lysand:custom_emojis"]
?.emojis ?? []) { ?.emojis ?? []) {
@ -555,7 +639,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
} }
} }
const attachments = []; const attachments: Attachment[] = [];
for (const attachment of note.attachments ?? []) { for (const attachment of note.attachments ?? []) {
const resolvedAttachment = await Attachment.fromLysand( const resolvedAttachment = await Attachment.fromLysand(
@ -581,7 +665,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
content: "", content: "",
}, },
}, },
visibility: note.visibility as apiStatus["visibility"], visibility: note.visibility as APIStatus["visibility"],
isSensitive: note.is_sensitive ?? false, isSensitive: note.is_sensitive ?? false,
spoilerText: note.subject ?? "", spoilerText: note.subject ?? "",
emojis, emojis,
@ -644,7 +728,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
* @param user The user to check. * @param user The user to check.
* @returns Whether this status is viewable by the user. * @returns Whether this status is viewable by the user.
*/ */
async isViewableByUser(user: User | null) { async isViewableByUser(user: User | null): Promise<boolean> {
if (this.author.id === user?.id) { if (this.author.id === user?.id) {
return true; return true;
} }
@ -656,22 +740,28 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
} }
if (this.data.visibility === "private") { if (this.data.visibility === "private") {
return user return user
? await db.query.Relationships.findFirst({ ? !!(await db.query.Relationships.findFirst({
where: (relationship, { and, eq }) => where: (relationship, { and, eq }) =>
and( and(
eq(relationship.ownerId, user?.id), eq(relationship.ownerId, user?.id),
eq(relationship.subjectId, Notes.authorId), eq(relationship.subjectId, Notes.authorId),
eq(relationship.following, true), eq(relationship.following, true),
), ),
}) }))
: false; : false;
} }
return ( return (
user && this.data.mentions.find((mention) => mention.id === user.id) !!user &&
!!this.data.mentions.find((mention) => mention.id === user.id)
); );
} }
async toApi(userFetching?: User | null): Promise<apiStatus> { /**
* Convert a note to the Mastodon API format
* @param userFetching - The user fetching the note (used to check if the note is favourite and such)
* @returns The note in the Mastodon API format
*/
async toApi(userFetching?: User | null): Promise<APIStatus> {
const data = this.data; const data = this.data;
// Convert mentions of local users from @username@host to @username // Convert mentions of local users from @username@host to @username
@ -749,7 +839,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
spoiler_text: data.spoilerText, spoiler_text: data.spoilerText,
tags: [], tags: [],
uri: data.uri || this.getUri(), uri: data.uri || this.getUri(),
visibility: data.visibility as apiStatus["visibility"], visibility: data.visibility as APIStatus["visibility"],
url: data.uri || this.getMastoUri(), url: data.uri || this.getMastoUri(),
bookmarked: false, bookmarked: false,
// @ts-expect-error Glitch-SOC extension // @ts-expect-error Glitch-SOC extension
@ -762,24 +852,32 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}; };
} }
getUri() { getUri(): string {
return localObjectUri(this.data.id); return localObjectUri(this.data.id);
} }
static getUri(id?: string | null) { static getUri(id?: string | null): string | null {
if (!id) { if (!id) {
return null; return null;
} }
return localObjectUri(id); return localObjectUri(id);
} }
getMastoUri() { /**
* Get the frontend URI of this note
* @returns The frontend URI of this note
*/
getMastoUri(): string {
return new URL( return new URL(
`/@${this.author.data.username}/${this.id}`, `/@${this.author.data.username}/${this.id}`,
config.http.base_url, config.http.base_url,
).toString(); ).toString();
} }
/**
* Convert a note to the Lysand format
* @returns The note in the Lysand format
*/
toLysand(): typeof EntityValidator.$Note { toLysand(): typeof EntityValidator.$Note {
const status = this.data; const status = this.data;
return { return {
@ -822,8 +920,11 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
/** /**
* Return all the ancestors of this post, * Return all the ancestors of this post,
* i.e. all the posts that this post is a reply to
* @param fetcher - The user fetching the ancestors
* @returns The ancestors of this post
*/ */
async getAncestors(fetcher: User | null) { async getAncestors(fetcher: User | null): Promise<Note[]> {
const ancestors: Note[] = []; const ancestors: Note[] = [];
let currentStatus: Note = this; let currentStatus: Note = this;
@ -854,8 +955,11 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
/** /**
* Return all the descendants of this post (recursive) * Return all the descendants of this post (recursive)
* Temporary implementation, will be replaced with a recursive SQL query when I get to it * Temporary implementation, will be replaced with a recursive SQL query when I get to it
* @param fetcher - The user fetching the descendants
* @param depth - The depth of the recursion (internal)
* @returns The descendants of this post
*/ */
async getDescendants(fetcher: User | null, depth = 0) { async getDescendants(fetcher: User | null, depth = 0): Promise<Note[]> {
const descendants: Note[] = []; const descendants: Note[] = [];
for (const child of await this.getReplyChildren(fetcher?.id)) { for (const child of await this.getReplyChildren(fetcher?.id)) {
descendants.push(child); descendants.push(child);

View file

@ -66,7 +66,7 @@ export class Timeline<Type extends Note | User> {
url: string, url: string,
limit: number, limit: number,
): Promise<string> { ): Promise<string> {
const linkHeader = []; const linkHeader: string[] = [];
const urlWithoutQuery = new URL( const urlWithoutQuery = new URL(
new URL(url).pathname, new URL(url).pathname,
config.http.base_url, config.http.base_url,
@ -103,7 +103,7 @@ export class Timeline<Type extends Note | User> {
urlWithoutQuery: string, urlWithoutQuery: string,
limit: number, limit: number,
): Promise<string[]> { ): Promise<string[]> {
const linkHeader = []; const linkHeader: string[] = [];
const objectBefore = await Note.fromSql(gt(Notes.id, notes[0].data.id)); const objectBefore = await Note.fromSql(gt(Notes.id, notes[0].data.id));
if (objectBefore) { if (objectBefore) {
@ -131,7 +131,7 @@ export class Timeline<Type extends Note | User> {
urlWithoutQuery: string, urlWithoutQuery: string,
limit: number, limit: number,
): Promise<string[]> { ): Promise<string[]> {
const linkHeader = []; const linkHeader: string[] = [];
const objectBefore = await User.fromSql(gt(Users.id, users[0].id)); const objectBefore = await User.fromSql(gt(Users.id, users[0].id));
if (objectBefore) { if (objectBefore) {

View file

@ -7,7 +7,7 @@ import {
MediaBackendType, MediaBackendType,
MediaHasher, MediaHasher,
S3MediaBackend, S3MediaBackend,
} from ".."; } from "../index";
import { MediaConverter } from "../media-converter"; import { MediaConverter } from "../media-converter";
type DeepPartial<T> = { type DeepPartial<T> = {

View file

@ -204,9 +204,9 @@ export default (app: Hono) =>
return errorResponse("Author not found", 404); return errorResponse("Author not found", 404);
} }
const newStatus = await Note.resolve( const newStatus = await Note.fromLysand(
undefined,
note, note,
account,
).catch((e) => { ).catch((e) => {
dualLogger.logError( dualLogger.logError(
LogLevel.Error, LogLevel.Error,

View file

@ -25,7 +25,7 @@ export async function fetchTimeline<T extends UserType | Status | Notification>(
const objects = (await model(args, userId)) as T[]; const objects = (await model(args, userId)) as T[];
// Constuct HTTP Link header (next and prev) only if there are more statuses // Constuct HTTP Link header (next and prev) only if there are more statuses
const linkHeader = []; const linkHeader: string[] = [];
const urlWithoutQuery = new URL( const urlWithoutQuery = new URL(
new URL(req.url).pathname, new URL(req.url).pathname,
config.http.base_url, config.http.base_url,