fix: pins and cascading delete
This commit is contained in:
parent
37215ae0fa
commit
c9dd562145
9 changed files with 109 additions and 60 deletions
|
@ -9,8 +9,8 @@ DROP MATERIALIZED VIEW IF EXISTS global_timeline;
|
||||||
DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id_and_user_id;
|
DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id_and_user_id;
|
||||||
DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id;
|
DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id;
|
||||||
DROP MATERIALIZED VIEW IF EXISTS note_by_user_id;
|
DROP MATERIALIZED VIEW IF EXISTS note_by_user_id;
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS note_by_id;
|
||||||
DROP INDEX IF EXISTS note_by_reply_id;
|
DROP INDEX IF EXISTS note_by_reply_id;
|
||||||
DROP INDEX IF EXISTS note_by_id;
|
|
||||||
DROP INDEX IF EXISTS note_by_uri;
|
DROP INDEX IF EXISTS note_by_uri;
|
||||||
DROP INDEX IF EXISTS note_by_url;
|
DROP INDEX IF EXISTS note_by_url;
|
||||||
DROP TABLE IF EXISTS note;
|
DROP TABLE IF EXISTS note;
|
||||||
|
|
|
@ -74,9 +74,19 @@ CREATE TABLE IF NOT EXISTS note ( -- Store all posts
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS note_by_uri ON note ("uri");
|
CREATE INDEX IF NOT EXISTS note_by_uri ON note ("uri");
|
||||||
CREATE INDEX IF NOT EXISTS note_by_url ON note ("url");
|
CREATE INDEX IF NOT EXISTS note_by_url ON note ("url");
|
||||||
CREATE INDEX IF NOT EXISTS note_by_id ON note ("id");
|
|
||||||
CREATE INDEX IF NOT EXISTS note_by_reply_id ON note ("replyId");
|
CREATE INDEX IF NOT EXISTS note_by_reply_id ON note ("replyId");
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS note_by_id AS
|
||||||
|
SELECT * FROM note
|
||||||
|
WHERE "id" IS NOT NULL
|
||||||
|
AND "createdAt" IS NOT NULL
|
||||||
|
AND "createdAtDate" IS NOT NULL
|
||||||
|
AND "userId" IS NOT NULL
|
||||||
|
AND "userHost" IS NOT NULL
|
||||||
|
AND "visibility" IS NOT NULL
|
||||||
|
PRIMARY KEY ("id", "createdAt", "createdAtDate", "userId", "userHost", "visibility")
|
||||||
|
WITH CLUSTERING ORDER BY ("createdAt" DESC);
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS note_by_user_id AS
|
CREATE MATERIALIZED VIEW IF NOT EXISTS note_by_user_id AS
|
||||||
SELECT * FROM note
|
SELECT * FROM note
|
||||||
WHERE "userId" IS NOT NULL
|
WHERE "userId" IS NOT NULL
|
||||||
|
|
|
@ -45,12 +45,13 @@ export const scyllaQueries = {
|
||||||
byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`,
|
byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`,
|
||||||
byUri: `SELECT * FROM note WHERE "uri" = ?`,
|
byUri: `SELECT * FROM note WHERE "uri" = ?`,
|
||||||
byUrl: `SELECT * FROM note WHERE "url" = ?`,
|
byUrl: `SELECT * FROM note WHERE "url" = ?`,
|
||||||
byId: `SELECT * FROM note WHERE "id" = ?`,
|
byId: `SELECT * FROM note_by_id WHERE "id" = ?`,
|
||||||
|
byIds: `SELECT * FROM note_by_id WHERE "id" IN ?`,
|
||||||
byUserId: `SELECT * FROM note_by_user_id WHERE "userId" = ?`,
|
byUserId: `SELECT * FROM note_by_user_id WHERE "userId" = ?`,
|
||||||
byRenoteId: `SELECT * FROM note_by_renote_id WHERE "renoteId" = ?`,
|
byRenoteId: `SELECT * FROM note_by_renote_id WHERE "renoteId" = ?`,
|
||||||
byReplyId: `SELECT * FROM note WHERE "replyId" = ?`
|
byReplyId: `SELECT * FROM note WHERE "replyId" = ?`
|
||||||
},
|
},
|
||||||
delete: `DELETE FROM note WHERE "createdAtDate" = ? AND "createdAt" = ? AND "userId" = ? AND "userHost" = ? AND "visibility" = ?`,
|
delete: `DELETE FROM note WHERE ("createdAtDate", "createdAt", "userId", "userHost", "visibility") IN ?`,
|
||||||
update: {
|
update: {
|
||||||
renoteCount: `UPDATE note SET
|
renoteCount: `UPDATE note SET
|
||||||
"renoteCount" = ?,
|
"renoteCount" = ?,
|
||||||
|
@ -113,7 +114,7 @@ export const scyllaQueries = {
|
||||||
byUserAndDate: `SELECT * FROM home_timeline WHERE "feedUserId" = ? AND "createdAtDate" = ?`,
|
byUserAndDate: `SELECT * FROM home_timeline WHERE "feedUserId" = ? AND "createdAtDate" = ?`,
|
||||||
byId: `SELECT * FROM home_timeline WHERE "id" = ?`,
|
byId: `SELECT * FROM home_timeline WHERE "id" = ?`,
|
||||||
},
|
},
|
||||||
delete: `DELETE FROM home_timeline WHERE "feedUserId" = ? AND "createdAtDate" = ? AND "createdAt" = ? AND "userId" = ?`,
|
delete: `DELETE FROM home_timeline WHERE ("feedUserId", "createdAtDate", "createdAt", "userId") IN ?`,
|
||||||
update: {
|
update: {
|
||||||
renoteCount: `UPDATE home_timeline SET
|
renoteCount: `UPDATE home_timeline SET
|
||||||
"renoteCount" = ?,
|
"renoteCount" = ?,
|
||||||
|
|
|
@ -40,7 +40,7 @@ import { Signin } from "@/models/entities/signin.js";
|
||||||
import { AuthSession } from "@/models/entities/auth-session.js";
|
import { AuthSession } from "@/models/entities/auth-session.js";
|
||||||
import { FollowRequest } from "@/models/entities/follow-request.js";
|
import { FollowRequest } from "@/models/entities/follow-request.js";
|
||||||
import { Emoji } from "@/models/entities/emoji.js";
|
import { Emoji } from "@/models/entities/emoji.js";
|
||||||
import { UserNotePining } from "@/models/entities/user-note-pining.js";
|
import { UserNotePining, UserNotePiningScylla } from "@/models/entities/user-note-pining.js";
|
||||||
import { Poll } from "@/models/entities/poll.js";
|
import { Poll } from "@/models/entities/poll.js";
|
||||||
import { UserKeypair } from "@/models/entities/user-keypair.js";
|
import { UserKeypair } from "@/models/entities/user-keypair.js";
|
||||||
import { UserPublickey } from "@/models/entities/user-publickey.js";
|
import { UserPublickey } from "@/models/entities/user-publickey.js";
|
||||||
|
@ -74,7 +74,6 @@ import { UserIp } from "@/models/entities/user-ip.js";
|
||||||
import { NoteEdit } from "@/models/entities/note-edit.js";
|
import { NoteEdit } from "@/models/entities/note-edit.js";
|
||||||
|
|
||||||
import { entities as charts } from "@/services/chart/entities.js";
|
import { entities as charts } from "@/services/chart/entities.js";
|
||||||
import { envOption } from "../env.js";
|
|
||||||
import { dbLogger } from "./logger.js";
|
import { dbLogger } from "./logger.js";
|
||||||
import { redisClient } from "./redis.js";
|
import { redisClient } from "./redis.js";
|
||||||
import { nativeInitDatabase } from "native-utils/built/index.js";
|
import { nativeInitDatabase } from "native-utils/built/index.js";
|
||||||
|
@ -131,7 +130,7 @@ export const entities = [
|
||||||
UserGroup,
|
UserGroup,
|
||||||
UserGroupJoining,
|
UserGroupJoining,
|
||||||
UserGroupInvitation,
|
UserGroupInvitation,
|
||||||
UserNotePining,
|
config.scylla ? UserNotePiningScylla : UserNotePining,
|
||||||
UserSecurityKey,
|
UserSecurityKey,
|
||||||
UsedUsername,
|
UsedUsername,
|
||||||
AttestationChallenge,
|
AttestationChallenge,
|
||||||
|
|
|
@ -10,9 +10,8 @@ import { Note } from "./note.js";
|
||||||
import { User } from "./user.js";
|
import { User } from "./user.js";
|
||||||
import { id } from "../id.js";
|
import { id } from "../id.js";
|
||||||
|
|
||||||
@Entity()
|
|
||||||
@Index(["userId", "noteId"], { unique: true })
|
@Index(["userId", "noteId"], { unique: true })
|
||||||
export class UserNotePining {
|
class UserNotePiningBase {
|
||||||
@PrimaryColumn(id())
|
@PrimaryColumn(id())
|
||||||
public id: string;
|
public id: string;
|
||||||
|
|
||||||
|
@ -33,10 +32,16 @@ export class UserNotePining {
|
||||||
|
|
||||||
@Column(id())
|
@Column(id())
|
||||||
public noteId: Note["id"];
|
public noteId: Note["id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class UserNotePining extends UserNotePiningBase {
|
||||||
@ManyToOne((type) => Note, {
|
@ManyToOne((type) => Note, {
|
||||||
onDelete: "CASCADE",
|
onDelete: "CASCADE",
|
||||||
})
|
})
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public note: Note | null;
|
public note: Note | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Entity({ name: "user_note_pining" })
|
||||||
|
export class UserNotePiningScylla extends UserNotePiningBase {}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { NoteRepository } from "./repositories/note.js";
|
||||||
import { DriveFileRepository } from "./repositories/drive-file.js";
|
import { DriveFileRepository } from "./repositories/drive-file.js";
|
||||||
import { DriveFolderRepository } from "./repositories/drive-folder.js";
|
import { DriveFolderRepository } from "./repositories/drive-folder.js";
|
||||||
import { AccessToken } from "./entities/access-token.js";
|
import { AccessToken } from "./entities/access-token.js";
|
||||||
import { UserNotePining } from "./entities/user-note-pining.js";
|
import { UserNotePining, UserNotePiningScylla } from "./entities/user-note-pining.js";
|
||||||
import { SigninRepository } from "./repositories/signin.js";
|
import { SigninRepository } from "./repositories/signin.js";
|
||||||
import { MessagingMessageRepository } from "./repositories/messaging-message.js";
|
import { MessagingMessageRepository } from "./repositories/messaging-message.js";
|
||||||
import { UserListRepository } from "./repositories/user-list.js";
|
import { UserListRepository } from "./repositories/user-list.js";
|
||||||
|
@ -67,6 +67,7 @@ import { InstanceRepository } from "./repositories/instance.js";
|
||||||
import { Webhook } from "./entities/webhook.js";
|
import { Webhook } from "./entities/webhook.js";
|
||||||
import { UserIp } from "./entities/user-ip.js";
|
import { UserIp } from "./entities/user-ip.js";
|
||||||
import { NoteEdit } from "./entities/note-edit.js";
|
import { NoteEdit } from "./entities/note-edit.js";
|
||||||
|
import config from "@/config/index.js";
|
||||||
|
|
||||||
export const Announcements = db.getRepository(Announcement);
|
export const Announcements = db.getRepository(Announcement);
|
||||||
export const AnnouncementReads = db.getRepository(AnnouncementRead);
|
export const AnnouncementReads = db.getRepository(AnnouncementRead);
|
||||||
|
@ -92,7 +93,7 @@ export const UserListJoinings = db.getRepository(UserListJoining);
|
||||||
export const UserGroups = UserGroupRepository;
|
export const UserGroups = UserGroupRepository;
|
||||||
export const UserGroupJoinings = db.getRepository(UserGroupJoining);
|
export const UserGroupJoinings = db.getRepository(UserGroupJoining);
|
||||||
export const UserGroupInvitations = UserGroupInvitationRepository;
|
export const UserGroupInvitations = UserGroupInvitationRepository;
|
||||||
export const UserNotePinings = db.getRepository(UserNotePining);
|
export const UserNotePinings = db.getRepository(config.scylla ? UserNotePiningScylla : UserNotePining);
|
||||||
export const UserIps = db.getRepository(UserIp);
|
export const UserIps = db.getRepository(UserIp);
|
||||||
export const UsedUsernames = db.getRepository(UsedUsername);
|
export const UsedUsernames = db.getRepository(UsedUsername);
|
||||||
export const Followings = FollowingRepository;
|
export const Followings = FollowingRepository;
|
||||||
|
|
|
@ -38,6 +38,9 @@ import {
|
||||||
} from "../index.js";
|
} from "../index.js";
|
||||||
import type { Instance } from "../entities/instance.js";
|
import type { Instance } from "../entities/instance.js";
|
||||||
import { userDenormalizedCache } from "@/services/user-cache.js";
|
import { userDenormalizedCache } from "@/services/user-cache.js";
|
||||||
|
import { parseScyllaNote, prepared, scyllaClient } from "@/db/scylla.js";
|
||||||
|
import type { UserNotePining } from "@/models/entities/user-note-pining.js";
|
||||||
|
import type { Note } from "@/models/entities/note.js";
|
||||||
|
|
||||||
const userInstanceCache = new Cache<Instance | null>(
|
const userInstanceCache = new Cache<Instance | null>(
|
||||||
"userInstance",
|
"userInstance",
|
||||||
|
@ -404,13 +407,29 @@ export const UserRepository = db.getRepository(User).extend({
|
||||||
meId && !isMe && opts.detail
|
meId && !isMe && opts.detail
|
||||||
? await this.getRelation(meId, user.id)
|
? await this.getRelation(meId, user.id)
|
||||||
: null;
|
: null;
|
||||||
const pins = opts.detail
|
|
||||||
? await UserNotePinings.createQueryBuilder("pin")
|
let pinnedNoteIds: UserNotePining["noteId"][] = [];
|
||||||
.where("pin.userId = :userId", { userId: user.id })
|
let pinnedNotes: Note[] = [];
|
||||||
.innerJoinAndSelect("pin.note", "note")
|
if (opts.detail) {
|
||||||
.orderBy("pin.id", "DESC")
|
pinnedNoteIds = await UserNotePinings.find({
|
||||||
.getMany()
|
select: ["noteId"],
|
||||||
: [];
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
}).then((notes) => notes.map(({ noteId }) => noteId));
|
||||||
|
|
||||||
|
if (scyllaClient) {
|
||||||
|
const result = await scyllaClient.execute(
|
||||||
|
prepared.note.select.byIds,
|
||||||
|
[pinnedNoteIds],
|
||||||
|
{ prepare: true },
|
||||||
|
);
|
||||||
|
pinnedNotes = result.rows.map(parseScyllaNote);
|
||||||
|
} else {
|
||||||
|
pinnedNotes = await Notes.findBy({ id: In(pinnedNoteIds) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const profile = opts.detail
|
const profile = opts.detail
|
||||||
? await UserProfiles.findOneByOrFail({ userId: user.id })
|
? await UserProfiles.findOneByOrFail({ userId: user.id })
|
||||||
: null;
|
: null;
|
||||||
|
@ -506,14 +525,11 @@ export const UserRepository = db.getRepository(User).extend({
|
||||||
followersCount: followersCount || 0,
|
followersCount: followersCount || 0,
|
||||||
followingCount: followingCount || 0,
|
followingCount: followingCount || 0,
|
||||||
notesCount: user.notesCount,
|
notesCount: user.notesCount,
|
||||||
pinnedNoteIds: pins.map((pin) => pin.noteId),
|
pinnedNoteIds,
|
||||||
pinnedNotes: Notes.packMany(
|
pinnedNotes: Notes.packMany(pinnedNotes, me, {
|
||||||
pins.map((pin) => pin.note!),
|
detail: true,
|
||||||
me,
|
scyllaNote: !!scyllaClient,
|
||||||
{
|
}),
|
||||||
detail: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
pinnedPageId: profile!.pinnedPageId,
|
pinnedPageId: profile!.pinnedPageId,
|
||||||
pinnedPage: profile!.pinnedPageId
|
pinnedPage: profile!.pinnedPageId
|
||||||
? Pages.pack(profile!.pinnedPageId, me)
|
? Pages.pack(profile!.pinnedPageId, me)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import define from "../../define.js";
|
||||||
import { getNote } from "../../common/getters.js";
|
import { getNote } from "../../common/getters.js";
|
||||||
import { ApiError } from "../../error.js";
|
import { ApiError } from "../../error.js";
|
||||||
import { SECOND, HOUR } from "@/const.js";
|
import { SECOND, HOUR } from "@/const.js";
|
||||||
|
import { userByIdCache } from "@/services/user-cache.js";
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ["notes"],
|
tags: ["notes"],
|
||||||
|
@ -52,6 +53,10 @@ export default define(meta, paramDef, async (ps, user) => {
|
||||||
throw new ApiError(meta.errors.accessDenied);
|
throw new ApiError(meta.errors.accessDenied);
|
||||||
}
|
}
|
||||||
|
|
||||||
// この操作を行うのが投稿者とは限らない(例えばモデレーター)ため
|
await deleteNote(
|
||||||
await deleteNote(await Users.findOneByOrFail({ id: note.userId }), note);
|
await userByIdCache.fetch(note.userId, () =>
|
||||||
|
Users.findOneByOrFail({ id: note.userId }),
|
||||||
|
),
|
||||||
|
note,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -28,6 +28,7 @@ import {
|
||||||
prepared,
|
prepared,
|
||||||
scyllaClient,
|
scyllaClient,
|
||||||
} from "@/db/scylla.js";
|
} from "@/db/scylla.js";
|
||||||
|
import { LocalFollowersCache } from "@/misc/cache.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a post
|
* Delete a post
|
||||||
|
@ -143,6 +144,9 @@ export default async function (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cascadingNotes =
|
||||||
|
scyllaClient || !quiet ? await findCascadingNotes(note) : [];
|
||||||
|
|
||||||
if (!quiet) {
|
if (!quiet) {
|
||||||
publishNoteStream(note.id, "deleted", {
|
publishNoteStream(note.id, "deleted", {
|
||||||
deletedAt: deletedAt,
|
deletedAt: deletedAt,
|
||||||
|
@ -192,10 +196,10 @@ export default async function (
|
||||||
}
|
}
|
||||||
|
|
||||||
// also deliever delete activity to cascaded notes
|
// also deliever delete activity to cascaded notes
|
||||||
const cascadingNotes = (await findCascadingNotes(note)).filter(
|
const cascadingLocalNotes = cascadingNotes.filter(
|
||||||
(note) => !note.localOnly,
|
(note) => note.userHost === null && !note.localOnly,
|
||||||
); // filter out local-only notes
|
); // filter out local-only notes
|
||||||
for (const cascadingNote of cascadingNotes) {
|
for (const cascadingNote of cascadingLocalNotes) {
|
||||||
if (!cascadingNote.user) continue;
|
if (!cascadingNote.user) continue;
|
||||||
if (!Users.isLocalUser(cascadingNote.user)) continue;
|
if (!Users.isLocalUser(cascadingNote.user)) continue;
|
||||||
const content = renderActivity(
|
const content = renderActivity(
|
||||||
|
@ -221,34 +225,42 @@ export default async function (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scyllaClient) {
|
if (scyllaClient) {
|
||||||
const date = new Date(note.createdAt.getTime());
|
const notesToDelete = [note, ...cascadingNotes];
|
||||||
await scyllaClient.execute(
|
|
||||||
prepared.note.delete,
|
|
||||||
[date, date, note.userId, note.userHost ?? "local", note.visibility],
|
|
||||||
{
|
|
||||||
prepare: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const homeTimelines = await scyllaClient
|
const noteDeleteParams = notesToDelete.map((n) => {
|
||||||
.execute(prepared.homeTimeline.select.byId, [note.id], { prepare: true })
|
const date = new Date(n.createdAt.getTime());
|
||||||
.then((result) => result.rows.map(parseHomeTimeline));
|
return [date, date, n.userId, n.userHost ?? "local", n.visibility];
|
||||||
for (const timeline of homeTimelines) {
|
});
|
||||||
// No need to wait
|
await scyllaClient.execute(prepared.note.delete, noteDeleteParams, {
|
||||||
scyllaClient.execute(prepared.homeTimeline.delete, [
|
prepare: true,
|
||||||
timeline.feedUserId,
|
});
|
||||||
timeline.createdAtDate,
|
|
||||||
timeline.createdAt,
|
const noteUserIds = new Set(notesToDelete.map((n) => n.userId));
|
||||||
timeline.userId,
|
const followers: string[] = [];
|
||||||
]);
|
for (const id of noteUserIds) {
|
||||||
|
const list = await LocalFollowersCache.init(id).then((cache) =>
|
||||||
|
cache.getAll(),
|
||||||
|
);
|
||||||
|
followers.push(...list);
|
||||||
}
|
}
|
||||||
|
const localFollowers = new Set(followers);
|
||||||
|
const homeDeleteParams = notesToDelete.map((n) => {
|
||||||
|
const tuples: [string, Date, Date, string][] = [];
|
||||||
|
for (const feedUserId of localFollowers) {
|
||||||
|
tuples.push([feedUserId, n.createdAt, n.createdAt, n.userId]);
|
||||||
|
}
|
||||||
|
return tuples;
|
||||||
|
});
|
||||||
|
await scyllaClient.execute(prepared.homeTimeline.delete, homeDeleteParams, {
|
||||||
|
prepare: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await Notes.delete({
|
||||||
|
id: note.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await Notes.delete({
|
|
||||||
id: note.id,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (meilisearch) {
|
if (meilisearch) {
|
||||||
await meilisearch.deleteNotes(note.id);
|
await meilisearch.deleteNotes(note.id);
|
||||||
}
|
}
|
||||||
|
@ -293,14 +305,14 @@ async function findCascadingNotes(note: Note) {
|
||||||
notes = await query.getMany();
|
notes = await query.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const reply of notes) {
|
for (const note of notes) {
|
||||||
cascadingNotes.push(reply);
|
cascadingNotes.push(note);
|
||||||
await recursive(reply.id);
|
await recursive(note.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await recursive(note.id);
|
await recursive(note.id);
|
||||||
|
|
||||||
return cascadingNotes.filter((note) => note.userHost === null); // filter out non-local users
|
return cascadingNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMentionedRemoteUsers(note: Note) {
|
async function getMentionedRemoteUsers(note: Note) {
|
||||||
|
|
Loading…
Reference in a new issue