refactor(federation): 🔥 Refactor Note federation and creation code

This commit is contained in:
Jesse Wierzbinski 2025-04-08 18:13:30 +02:00
parent 54b2dfb78d
commit f79b0bc999
No known key found for this signature in database
19 changed files with 243 additions and 354 deletions

View file

@ -45,7 +45,7 @@ export default apiRoute((app) =>
async (context) => {
const otherUser = context.get("user");
if (otherUser.isLocal()) {
if (otherUser.local) {
throw new ApiError(400, "Cannot refetch a local user");
}

View file

@ -71,7 +71,7 @@ export default apiRoute((app) =>
);
// Check if accepting remote follow
if (account.isRemote()) {
if (account.remote) {
// Federate follow accept
await user.acceptFollowRequest(account);
}

View file

@ -72,7 +72,7 @@ export default apiRoute((app) =>
);
// Check if rejecting remote follow
if (account.isRemote()) {
if (account.remote) {
// Federate follow reject
await user.rejectFollowRequest(account);
}

View file

@ -5,6 +5,7 @@ import {
jsonOrForm,
withNoteParam,
} from "@/api";
import { sanitizedHtmlStrip } from "@/sanitization";
import {
Attachment as AttachmentSchema,
PollOption,
@ -13,12 +14,13 @@ import {
zBoolean,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db";
import { Emoji, Media } from "@versia/kit/db";
import * as VersiaEntities from "@versia/sdk/entities";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml, parseTextMentions } from "~/classes/functions/status";
import { config } from "~/config.ts";
const schema = z
@ -226,22 +228,50 @@ export default apiRoute((app) => {
);
}
const newNote = await note.updateFromData({
author: user,
content: statusText
const sanitizedSpoilerText = spoiler_text
? await sanitizedHtmlStrip(spoiler_text)
: undefined;
const content = statusText
? new VersiaEntities.TextContentFormat({
[content_type]: {
content: statusText,
remote: false,
},
})
: undefined;
const parsedMentions = statusText
? await parseTextMentions(statusText, user)
: [];
const parsedEmojis = statusText
? await Emoji.parseFromText(statusText)
: [];
await note.update({
spoilerText: sanitizedSpoilerText,
sensitive,
content: content
? await contentToHtml(content, parsedMentions)
: undefined,
isSensitive: sensitive,
spoilerText: spoiler_text,
mediaAttachments: foundAttachments,
});
return context.json(await newNote.toApi(user), 200);
// Emojis, mentions, and attachments are stored in a different table, so update them there too
await note.updateEmojis(parsedEmojis);
await note.updateMentions(parsedMentions);
await note.updateAttachments(foundAttachments);
await note.reload();
// Send notifications for mentioned local users
for (const mentioned of parsedMentions) {
if (mentioned.local) {
await mentioned.notify("mention", user, note);
}
}
return context.json(await note.toApi(user), 200);
},
);
});

View file

@ -83,7 +83,7 @@ export default apiRoute((app) =>
throw new Error("Failed to reblog");
}
if (note.author.isLocal() && user.isLocal()) {
if (note.author.local && user.local) {
await note.author.notify("reblog", user, newReblog);
}

View file

@ -1,4 +1,5 @@
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { sanitizedHtmlStrip } from "@/sanitization";
import {
Attachment as AttachmentSchema,
PollOption,
@ -7,12 +8,14 @@ import {
zBoolean,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media, Note } from "@versia/kit/db";
import { Emoji, Media, Note } from "@versia/kit/db";
import * as VersiaEntities from "@versia/sdk/entities";
import { randomUUIDv7 } from "bun";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml, parseTextMentions } from "~/classes/functions/status";
import { config } from "~/config.ts";
const schema = z
@ -175,27 +178,59 @@ export default apiRoute((app) =>
);
}
const newNote = await Note.fromData({
author: user,
content: new VersiaEntities.TextContentFormat({
const sanitizedSpoilerText = spoiler_text
? await sanitizedHtmlStrip(spoiler_text)
: undefined;
const content = status
? new VersiaEntities.TextContentFormat({
[content_type]: {
content: status ?? "",
content: status,
remote: false,
},
}),
})
: undefined;
const parsedMentions = status
? await parseTextMentions(status, user)
: [];
const parsedEmojis = status
? await Emoji.parseFromText(status)
: [];
const newNote = await Note.insert({
id: randomUUIDv7(),
authorId: user.id,
visibility,
isSensitive: sensitive ?? false,
spoilerText: spoiler_text ?? "",
mediaAttachments: foundAttachments,
content: content
? await contentToHtml(content, parsedMentions)
: undefined,
sensitive,
spoilerText: sanitizedSpoilerText,
replyId: in_reply_to_id ?? undefined,
quoteId: quote_id ?? undefined,
application: application ?? undefined,
quotingId: quote_id ?? undefined,
applicationId: application?.id,
});
// Emojis, mentions, and attachments are stored in a different table, so update them there too
await newNote.updateEmojis(parsedEmojis);
await newNote.updateMentions(parsedMentions);
await newNote.updateAttachments(foundAttachments);
await newNote.reload();
if (!local_only) {
await newNote.federateToUsers();
}
// Send notifications for mentioned local users
for (const mentioned of parsedMentions) {
if (mentioned.local) {
await mentioned.notify("mention", user, newNote);
}
}
return context.json(await newNote.toApi(user), 200);
},
),

View file

@ -59,7 +59,7 @@ export default apiRoute((app) =>
const liker = await User.fromId(like.data.likerId);
if (!liker || liker.isRemote()) {
if (!liker || liker.remote) {
throw ApiError.accountNotFound();
}

View file

@ -53,10 +53,7 @@ export default apiRoute((app) =>
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.isRemote()
) {
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
throw ApiError.noteNotFound();
}

View file

@ -63,10 +63,7 @@ export default apiRoute((app) =>
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.isRemote()
) {
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
throw ApiError.noteNotFound();
}

View file

@ -61,10 +61,7 @@ export default apiRoute((app) =>
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.isRemote()
) {
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
throw ApiError.noteNotFound();
}

View file

@ -53,7 +53,7 @@ export default apiRoute((app) =>
throw ApiError.accountNotFound();
}
if (user.isRemote()) {
if (user.remote) {
throw new ApiError(403, "User is not on this instance");
}

View file

@ -70,7 +70,7 @@ export default apiRoute((app) =>
throw new ApiError(404, "User not found");
}
if (author.isRemote()) {
if (author.remote) {
throw new ApiError(403, "User is not on this instance");
}

View file

@ -1,9 +1,7 @@
import { idValidator } from "@/api";
import { mergeAndDeduplicate } from "@/lib.ts";
import { sanitizedHtmlStrip } from "@/sanitization";
import { sentry } from "@/sentry";
import { getLogger } from "@logtape/logtape";
import type { Status, Status as StatusSchema } from "@versia/client/schemas";
import type { Status } from "@versia/client/schemas";
import { Instance, db } from "@versia/kit/db";
import {
EmojiToNote,
@ -28,11 +26,7 @@ import {
import { htmlToText } from "html-to-text";
import { createRegExp, exactly, global } from "magic-regexp";
import type { z } from "zod";
import {
contentToHtml,
findManyNotes,
parseTextMentions,
} from "~/classes/functions/status";
import { contentToHtml, findManyNotes } from "~/classes/functions/status";
import { config } from "~/config.ts";
import type { NonTextContentFormatSchema } from "~/packages/federation/schemas/contentformat.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
@ -306,179 +300,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
);
}
public isRemote(): boolean {
return this.author.isRemote();
public get remote(): boolean {
return this.author.remote;
}
/**
* Update a note from remote federated servers
* @returns The updated note
*/
public async updateFromRemote(): Promise<Note> {
if (!this.isRemote()) {
throw new Error("Cannot refetch a local note (it is not remote)");
}
const note = await User.federationRequester.fetchEntity(
this.getUri(),
VersiaEntities.Note,
);
const updated = await Note.fromVersia(note);
if (!updated) {
throw new Error("Note not found after update");
}
this.data = updated.data;
return this;
}
/**
* Create a new note from user input
* @param data - The data to create the note from
* @returns The created note
*/
public static async fromData(data: {
author: User;
content: VersiaEntities.TextContentFormat;
visibility: z.infer<typeof StatusSchema.shape.visibility>;
isSensitive: boolean;
spoilerText: string;
emojis?: Emoji[];
uri?: URL;
mentions?: User[];
/** List of IDs of database Attachment objects */
mediaAttachments?: Media[];
replyId?: string;
quoteId?: string;
application?: Application;
}): Promise<Note> {
const plaintextContent =
data.content.data["text/plain"]?.content ??
Object.entries(data.content.data)[0][1].content;
const parsedMentions = mergeAndDeduplicate(
data.mentions ?? [],
await parseTextMentions(plaintextContent, data.author),
);
const parsedEmojis = mergeAndDeduplicate(
data.emojis ?? [],
await Emoji.parseFromText(plaintextContent),
);
const htmlContent = await contentToHtml(data.content, parsedMentions);
const newNote = await Note.insert({
id: randomUUIDv7(),
authorId: data.author.id,
content: htmlContent,
contentSource:
data.content.data["text/plain"]?.content ||
data.content.data["text/markdown"]?.content ||
Object.entries(data.content.data)[0][1].content ||
"",
contentType: "text/html",
visibility: data.visibility,
sensitive: data.isSensitive,
spoilerText: await sanitizedHtmlStrip(data.spoilerText),
uri: data.uri?.href || null,
replyId: data.replyId ?? null,
quotingId: data.quoteId ?? null,
applicationId: data.application?.id ?? null,
});
// Connect emojis
await newNote.updateEmojis(parsedEmojis);
// Connect mentions
await newNote.updateMentions(parsedMentions);
// Set attachment parents
await newNote.updateAttachments(data.mediaAttachments ?? []);
// Send notifications for mentioned local users
for (const mention of parsedMentions) {
if (mention.isLocal()) {
await mention.notify("mention", data.author, newNote);
}
}
await newNote.reload(data.author.id);
return newNote;
}
/**
* Update a note from user input
* @param data - The data to update the note from
* @returns The updated note
*/
public async updateFromData(data: {
author: User;
content?: VersiaEntities.TextContentFormat;
visibility?: z.infer<typeof StatusSchema.shape.visibility>;
isSensitive?: boolean;
spoilerText?: string;
emojis?: Emoji[];
uri?: URL;
mentions?: User[];
mediaAttachments?: Media[];
replyId?: string;
quoteId?: string;
application?: Application;
}): Promise<Note> {
const plaintextContent = data.content
? (data.content.data["text/plain"]?.content ??
Object.entries(data.content.data)[0][1].content)
: undefined;
const parsedMentions = mergeAndDeduplicate(
data.mentions ?? [],
plaintextContent
? await parseTextMentions(plaintextContent, data.author)
: [],
);
const parsedEmojis = mergeAndDeduplicate(
data.emojis ?? [],
plaintextContent ? await Emoji.parseFromText(plaintextContent) : [],
);
const htmlContent = data.content
? await contentToHtml(data.content, parsedMentions)
: undefined;
await this.update({
content: htmlContent,
contentSource: data.content
? data.content.data["text/plain"]?.content ||
data.content.data["text/markdown"]?.content ||
Object.entries(data.content.data)[0][1].content ||
""
: undefined,
contentType: "text/html",
visibility: data.visibility,
sensitive: data.isSensitive,
spoilerText: data.spoilerText,
uri: data.uri?.href,
replyId: data.replyId,
quotingId: data.quoteId,
applicationId: data.application?.id,
});
// Connect emojis
await this.updateEmojis(parsedEmojis);
// Connect mentions
await this.updateMentions(parsedMentions);
// Set attachment parents
await this.updateAttachments(data.mediaAttachments ?? []);
await this.reload(data.author.id);
return this;
public get local(): boolean {
return this.author.local;
}
/**
@ -558,7 +385,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*/
public static async resolve(uri: URL): Promise<Note | null> {
// Check if note not already in database
const foundNote = await Note.fromSql(eq(Notes.uri, uri.toString()));
const foundNote = await Note.fromSql(eq(Notes.uri, uri.href));
if (foundNote) {
return foundNote;
@ -577,118 +404,124 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return await Note.fromId(uuid[0]);
}
return Note.fromVersia(uri);
}
/**
* Tries to fetch a Versia Note from the given URL.
*
* @param url The URL to fetch the note from
*/
public static async fromVersia(url: URL): Promise<Note>;
/**
* Takes a Versia Note representation, and serializes it to the database.
*
* If the note already exists, it will update it.
* @param versiaNote
*/
public static async fromVersia(
versiaNote: VersiaEntities.Note,
): Promise<Note>;
public static async fromVersia(
versiaNote: VersiaEntities.Note | URL,
): Promise<Note> {
if (versiaNote instanceof URL) {
// No bridge support for notes yet
const note = await User.federationRequester.fetchEntity(
uri,
versiaNote,
VersiaEntities.Note,
);
return Note.fromVersia(note);
}
/**
* Turns a Versia Note into a database note (saved)
* @param note Versia Note
* @param author Author of the note
* @param instance Instance of the note
* @returns The saved note
*/
public static async fromVersia(note: VersiaEntities.Note): Promise<Note> {
const emojis: Emoji[] = [];
const logger = getLogger(["federation", "resolvers"]);
const {
author: authorUrl,
created_at,
uri,
extensions,
group,
is_sensitive,
mentions: noteMentions,
quotes,
replies_to,
subject,
} = versiaNote.data;
const instance = await Instance.resolve(authorUrl);
const author = await User.resolve(authorUrl);
const author = await User.resolve(note.data.author);
if (!author) {
throw new Error("Invalid object author");
throw new Error("Entity author could not be resolved");
}
const instance = await Instance.resolve(note.data.uri);
const existingNote = await Note.fromSql(eq(Notes.uri, uri.href));
for (const emoji of note.data.extensions?.["pub.versia:custom_emojis"]
?.emojis ?? []) {
const resolvedEmoji = await Emoji.fetchFromRemote(
emoji,
instance,
).catch((e) => {
logger.error`${e}`;
sentry?.captureException(e);
return null;
});
const note =
existingNote ??
(await Note.insert({
id: randomUUIDv7(),
authorId: author.id,
visibility: "public",
uri: uri.href,
createdAt: new Date(created_at).toISOString(),
}));
if (resolvedEmoji) {
emojis.push(resolvedEmoji);
}
}
const attachments: Media[] = [];
for (const attachment of note.attachments) {
const resolvedAttachment = await Media.fromVersia(attachment).catch(
(e) => {
logger.error`${e}`;
sentry?.captureException(e);
return null;
},
const attachments = await Promise.all(
versiaNote.attachments.map((a) => Media.fromVersia(a)),
);
if (resolvedAttachment) {
attachments.push(resolvedAttachment);
}
}
const emojis = await Promise.all(
extensions?.["pub.versia:custom_emojis"]?.emojis.map((emoji) =>
Emoji.fetchFromRemote(emoji, instance),
) ?? [],
);
let visibility = note.data.group
? ["public", "followers"].includes(note.data.group as string)
? (note.data.group as "public" | "private")
: ("url" as const)
: ("direct" as const);
if (visibility === "url") {
// TODO: Implement groups
visibility = "direct";
}
const newData = {
author,
content:
note.content ??
new VersiaEntities.TextContentFormat({
"text/plain": {
content: "",
remote: false,
},
}),
visibility,
isSensitive: note.data.is_sensitive ?? false,
spoilerText: note.data.subject ?? "",
emojis,
uri: note.data.uri,
mentions: (
const mentions = (
await Promise.all(
(note.data.mentions ?? []).map(
async (mention) => await User.resolve(mention),
),
noteMentions?.map((mention) => User.resolve(mention)) ?? [],
)
).filter((mention) => mention !== null),
mediaAttachments: attachments,
replyId: note.data.replies_to
? (await Note.resolve(note.data.replies_to))?.data.id
).filter((m) => m !== null);
// TODO: Implement groups
const visibility = !group || group instanceof URL ? "direct" : group;
const reply = replies_to ? await Note.resolve(replies_to) : null;
const quote = quotes ? await Note.resolve(quotes) : null;
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
await note.update({
content: versiaNote.content
? await contentToHtml(versiaNote.content, mentions)
: undefined,
quoteId: note.data.quotes
? (await Note.resolve(note.data.quotes))?.data.id
contentSource: versiaNote.content
? versiaNote.content.data["text/plain"]?.content ||
versiaNote.content.data["text/markdown"]?.content
: undefined,
};
contentType: "text/html",
visibility: visibility === "followers" ? "private" : visibility,
sensitive: is_sensitive ?? false,
spoilerText: spoiler,
replyId: reply?.id,
quotingId: quote?.id,
});
// Check if new note already exists
const foundNote = await Note.fromSql(eq(Notes.uri, note.data.uri.href));
// Emojis, mentions, and attachments are stored in a different table, so update them there too
await note.updateEmojis(emojis);
await note.updateMentions(mentions);
await note.updateAttachments(attachments);
// If it exists, simply update it
if (foundNote) {
await foundNote.updateFromData(newData);
await note.reload(author.id);
return foundNote;
// Send notifications for mentioned local users
for (const mentioned of mentions) {
if (mentioned.local) {
await mentioned.notify("mention", author, note);
}
}
// Else, create a new note
return await Note.fromData(newData);
return note;
}
public async delete(ids?: string[]): Promise<void> {

View file

@ -165,7 +165,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
);
}
public isLocal(): boolean {
public get local(): boolean {
return this.data.author.instanceId === null;
}
@ -174,7 +174,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
}
public toVersia(): VersiaEntities.Reaction {
if (!this.isLocal()) {
if (!this.local) {
throw new Error("Cannot convert a non-local reaction to Versia");
}
@ -212,7 +212,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
author: User,
note: Note,
): Promise<Reaction> {
if (author.isLocal()) {
if (author.local) {
throw new Error("Cannot process a reaction from a local user");
}

View file

@ -148,12 +148,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return this.data.id;
}
public isLocal(): boolean {
public get local(): boolean {
return this.data.instanceId === null;
}
public isRemote(): boolean {
return !this.isLocal();
public get remote(): boolean {
return !this.local;
}
public get uri(): URL {
@ -196,14 +196,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
);
await foundRelationship.update({
following: otherUser.isRemote() ? false : !otherUser.data.isLocked,
requested: otherUser.isRemote() ? true : otherUser.data.isLocked,
following: otherUser.remote ? false : !otherUser.data.isLocked,
requested: otherUser.remote ? true : otherUser.data.isLocked,
showingReblogs: options?.reblogs,
notifying: options?.notify,
languages: options?.languages,
});
if (otherUser.isRemote()) {
if (otherUser.remote) {
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: {
type: "Follow",
@ -229,7 +229,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
followee: User,
relationship: Relationship,
): Promise<void> {
if (followee.isRemote()) {
if (followee.remote) {
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: this.unfollowToVersia(followee).toJSON(),
recipientId: followee.id,
@ -254,11 +254,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
public async acceptFollowRequest(follower: User): Promise<void> {
if (!follower.isRemote()) {
if (!follower.remote) {
throw new Error("Follower must be a remote user");
}
if (this.isRemote()) {
if (this.remote) {
throw new Error("Followee must be a local user");
}
@ -278,11 +278,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
public async rejectFollowRequest(follower: User): Promise<void> {
if (!follower.isRemote()) {
if (!follower.remote) {
throw new Error("Follower must be a remote user");
}
if (this.isRemote()) {
if (this.remote) {
throw new Error("Followee must be a local user");
}
@ -497,10 +497,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
uri: uri?.href,
});
if (this.isLocal() && note.author.isLocal()) {
if (this.local && note.author.local) {
// Notify the user that their post has been favourited
await note.author.notify("favourite", this, note);
} else if (this.isLocal() && note.author.isRemote()) {
} else if (this.local && note.author.remote) {
// Federate the like
this.federateToFollowers(newLike.toVersia());
}
@ -526,10 +526,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
await likeToDelete.delete();
if (this.isLocal() && note.author.isLocal()) {
if (this.local && note.author.local) {
// Remove any eventual notifications for this like
await likeToDelete.clearRelatedNotifications();
} else if (this.isLocal() && note.author.isRemote()) {
} else if (this.local && note.author.remote) {
// User is local, federate the delete
this.federateToFollowers(likeToDelete.unlikeToVersia(this));
}
@ -630,7 +630,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* Takes a Versia User representation, and serializes it to the database.
*
* If the user already exists, it will update it.
* @param user
* @param versiaUser
*/
public static async fromVersia(
versiaUser: VersiaEntities.User,
@ -895,7 +895,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
public getAcct(): string {
return this.isLocal()
return this.local
? this.data.username
: `${this.data.username}@${this.data.instance?.baseUrl}`;
}
@ -921,7 +921,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
// If something important is updated, federate it
if (
this.isLocal() &&
this.local &&
(newUser.username ||
newUser.displayName ||
newUser.note ||
@ -1090,7 +1090,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
public toVersia(): VersiaEntities.User {
if (this.isRemote()) {
if (this.remote) {
throw new Error("Cannot convert remote user to Versia format");
}

View file

@ -301,7 +301,7 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => {
const linkTemplate = (displayText: string): string =>
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
if (mention.isRemote()) {
if (mention.remote) {
return finalText.replaceAll(
`@${username}@${instance?.baseUrl}`,
linkTemplate(`@${username}@${instance?.baseUrl}`),

View file

@ -112,7 +112,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
return;
}
if (sender?.isLocal()) {
if (sender?.local) {
throw new Error(
"Cannot process federation requests from local users",
);

View file

@ -21,7 +21,7 @@ export const refetchUserCommand = defineCommand(
throw new Error(`User ${chalk.gray(handle)} not found.`);
}
if (user.isLocal()) {
if (user.local) {
throw new Error(
"This user is local and as such cannot be refetched.",
);

View file

@ -458,7 +458,7 @@ export const Notes = pgTable("Notes", {
onDelete: "cascade",
onUpdate: "cascade",
}),
sensitive: boolean("sensitive").notNull(),
sensitive: boolean("sensitive").notNull().default(false),
spoilerText: text("spoiler_text").default("").notNull(),
applicationId: uuid("applicationId").references(() => Applications.id, {
onDelete: "set null",