diff --git a/packages/backend/migration/1754137937997-DeletedNote.js b/packages/backend/migration/1754137937997-DeletedNote.js new file mode 100644 index 0000000000..107458c665 --- /dev/null +++ b/packages/backend/migration/1754137937997-DeletedNote.js @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class DeletedNote1754137937997 { + name = 'DeletedNote1754137937997' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "deleted_note" ("id" character varying(32) NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE, "replyId" character varying(32), "renoteId" character varying(32), "userId" character varying(32) NOT NULL, "localOnly" boolean NOT NULL DEFAULT false, "uri" character varying(512), "url" character varying(512), "channelId" character varying(32), CONSTRAINT "PK_1cb67148b7b707a03c63b2165fc" PRIMARY KEY ("id")); COMMENT ON COLUMN "deleted_note"."replyId" IS 'The ID of reply target.'; COMMENT ON COLUMN "deleted_note"."renoteId" IS 'The ID of renote target.'; COMMENT ON COLUMN "deleted_note"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "deleted_note"."uri" IS 'The URI of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."url" IS 'The human readable url of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."channelId" IS 'The ID of source channel.'`); + await queryRunner.query(`CREATE INDEX "IDX_12797cfa4c15d03d0dd649bc4b" ON "deleted_note" ("replyId") `); + await queryRunner.query(`CREATE INDEX "IDX_b6a4a8f31a98ddc5e07995c840" ON "deleted_note" ("renoteId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_95c76b088692f600b7a5352a4b" ON "deleted_note" ("uri") `); + await queryRunner.query(`CREATE INDEX "IDX_cd2da11aa690f8e854e68058ce" ON "deleted_note" ("channelId") `); + await queryRunner.query(`CREATE INDEX "IDX_31ca16e5929958668bf1d9a2d5" ON "deleted_note" ("userId", "id" DESC) `); + await queryRunner.query(`ALTER TABLE "deleted_note" ADD CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "deleted_note" ADD CONSTRAINT "FK_cd2da11aa690f8e854e68058cef" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "deleted_note" DROP CONSTRAINT "FK_cd2da11aa690f8e854e68058cef"`); + await queryRunner.query(`ALTER TABLE "deleted_note" DROP CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59"`); + await queryRunner.query(`DROP INDEX "public"."IDX_31ca16e5929958668bf1d9a2d5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cd2da11aa690f8e854e68058ce"`); + await queryRunner.query(`DROP INDEX "public"."IDX_95c76b088692f600b7a5352a4b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b6a4a8f31a98ddc5e07995c840"`); + await queryRunner.query(`DROP INDEX "public"."IDX_12797cfa4c15d03d0dd649bc4b"`); + await queryRunner.query(`DROP TABLE "deleted_note"`); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index c915133453..579f996feb 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -90,5 +90,6 @@ export const DI = { bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), noteDraftsRepository: Symbol('noteDraftsRepository'), + deletedNotesRepository: Symbol('deletedNotesRepository'), //#endregion }; diff --git a/packages/backend/src/models/DeletedNote.ts b/packages/backend/src/models/DeletedNote.ts new file mode 100644 index 0000000000..fad5ef783d --- /dev/null +++ b/packages/backend/src/models/DeletedNote.ts @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { MiChannel } from '@/models/Channel.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiNote } from './Note.js'; + +/** + * This model represents a deleted note in the Misskey system. + * Deleted note can be one of: + * - A note that was deleted by the user. + * - A remote note that is old and has been removed from the + * In both cases, we want to keep little information about the note to allow users to + * - see the reply / renote relationships of the note (if the note is in the reply / renote chain) + * - see the original url of the note if the note was remote + */ + +@Index(['userId', 'id']) // Note: this index is ("userId", "id" DESC) in production, but not in test. +@Entity('deleted_note') +export class MiDeletedNote { + // The id of the note must be same as the original note's id. + @PrimaryColumn(id()) + public id: string; + + // For remote notes that are deleted locally but not deleted on the remote server, this will be null. + // For actually deleted notes, this will be the time when the note was deleted. + @Column('timestamp with time zone', { + nullable: true, + }) + public deletedAt: Date | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of reply target.', + }) + public replyId: MiNote['id'] | null; + + @ManyToOne(type => MiNote, { + createForeignKeyConstraints: false, + }) + @JoinColumn() + public reply: MiNote | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of renote target.', + }) + public renoteId: MiNote['id'] | null; + + @ManyToOne(type => MiNote, { + createForeignKeyConstraints: false, + }) + @JoinColumn() + public renote: MiNote | null; + + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of a note. it will be null when the note is local.', + }) + public uri: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The human readable url of a note. it will be null when the note is local.', + }) + public url: string | null; + + @Index() // for cascading + @Column({ + ...id(), + nullable: true, + comment: 'The ID of source channel.', + }) + public channelId: MiChannel['id'] | null; + + @ManyToOne(type => MiChannel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public channel: MiChannel | null; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 146dbbc3b8..5acf334ac4 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -43,6 +43,7 @@ import { MiNoteReaction, MiNoteThreadMuting, MiNoteDraft, + MiDeletedNote, MiPage, MiPageLike, MiPasswordResetRequest, @@ -147,6 +148,12 @@ const $noteDraftsRepository: Provider = { inject: [DI.db], }; +const $deletedNotesRepository: Provider = { + provide: DI.deletedNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiDeletedNote).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $pollsRepository: Provider = { provide: DI.pollsRepository, useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), @@ -550,6 +557,7 @@ const $reversiGamesRepository: Provider = { $noteThreadMutingsRepository, $noteReactionsRepository, $noteDraftsRepository, + $deletedNotesRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -627,6 +635,7 @@ const $reversiGamesRepository: Provider = { $noteThreadMutingsRepository, $noteReactionsRepository, $noteDraftsRepository, + $deletedNotesRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 84b5cbed0a..ee54f389e9 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -40,6 +40,7 @@ import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; import { MiClip } from '@/models/Clip.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiClipNote } from '@/models/ClipNote.js'; +import { MiDeletedNote } from '@/models/DeletedNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; @@ -175,6 +176,7 @@ export { MiClip, MiClipNote, MiClipFavorite, + MiDeletedNote, MiDriveFile, MiDriveFolder, MiEmoji, @@ -254,6 +256,7 @@ export type ChannelFavoritesRepository = Repository & MiRepos export type ClipsRepository = Repository & MiRepository; export type ClipNotesRepository = Repository & MiRepository; export type ClipFavoritesRepository = Repository & MiRepository; +export type DeletedNotesRepository = Repository & MiRepository; export type DriveFilesRepository = Repository & MiRepository; export type DriveFoldersRepository = Repository & MiRepository; export type EmojisRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index f6cbbbe64c..a7e4f645bd 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -46,6 +46,7 @@ import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; import { MiNoteDraft } from '@/models/NoteDraft.js'; +import { MiDeletedNote } from '@/models/DeletedNote.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; @@ -212,6 +213,7 @@ export const entities = [ MiNoteReaction, MiNoteThreadMuting, MiNoteDraft, + MiDeletedNote, MiPage, MiPageLike, MiGalleryPost,