hippofish/packages/backend/src/db/scylla.ts

485 lines
12 KiB
TypeScript
Raw Normal View History

2023-07-19 16:35:47 +02:00
import config from "@/config/index.js";
2023-07-24 18:23:57 +02:00
import type { PopulatedEmoji } from "@/misc/populate-emojis.js";
2023-08-05 10:36:30 +02:00
import type { Channel } from "@/models/entities/channel.js";
2023-07-25 13:28:08 +02:00
import type { Note } from "@/models/entities/note.js";
2023-07-24 18:23:57 +02:00
import type { NoteReaction } from "@/models/entities/note-reaction.js";
2023-08-05 13:03:50 +02:00
import { Client, types, tracker } from "cassandra-driver";
2023-07-27 13:15:58 +02:00
import type { User } from "@/models/entities/user.js";
2023-08-05 21:52:13 +02:00
import {
ChannelFollowingsCache,
InstanceMutingsCache,
LocalFollowingsCache,
2023-08-06 06:40:55 +02:00
UserBlockedCache,
2023-08-05 21:52:13 +02:00
UserMutingsCache,
2023-08-05 23:42:45 +02:00
userWordMuteCache,
2023-08-05 21:52:13 +02:00
} from "@/misc/cache.js";
2023-08-05 11:42:45 +02:00
import { getTimestamp } from "@/misc/gen-id.js";
2023-08-05 13:03:50 +02:00
import Logger from "@/services/logger.js";
2023-08-05 23:42:45 +02:00
import { UserProfiles } from "@/models/index.js";
import { getWordHardMute } from "@/misc/check-word-mute";
2023-07-19 16:35:47 +02:00
function newClient(): Client | null {
if (!config.scylla) {
return null;
}
2023-08-05 13:03:50 +02:00
const requestTracker = new tracker.RequestLogger({
slowThreshold: 1000,
});
const client = new Client({
2023-07-19 16:35:47 +02:00
contactPoints: config.scylla.nodes,
2023-07-24 11:35:44 +02:00
localDataCenter: config.scylla.localDataCentre,
2023-07-19 16:35:47 +02:00
keyspace: config.scylla.keyspace,
2023-08-05 13:03:50 +02:00
requestTracker,
});
const logger = new Logger("scylla");
client.on("log", (level, loggerName, message, _furtherInfo) => {
const msg = `${loggerName} - ${message}`;
switch (level) {
case "info":
logger.info(msg);
break;
case "warning":
logger.warn(msg);
break;
case "error":
logger.error(msg);
break;
}
2023-07-19 16:35:47 +02:00
});
2023-08-05 13:03:50 +02:00
client.on("slow", (message) => {
logger.warn(message);
});
client.on("large", (message) => {
logger.warn(message);
});
return client;
2023-07-19 16:35:47 +02:00
}
export const scyllaClient = newClient();
export const prepared = {
2023-07-24 11:35:44 +02:00
note: {
2023-07-19 16:35:47 +02:00
insert: `INSERT INTO note (
2023-07-24 11:35:44 +02:00
"createdAtDate",
"createdAt",
"id",
"visibility",
"content",
"name",
"cw",
"localOnly",
"renoteCount",
"repliesCount",
"uri",
"url",
"score",
"files",
2023-07-25 13:28:08 +02:00
"visibleUserIds",
2023-07-24 11:35:44 +02:00
"mentions",
2023-07-31 03:41:45 +02:00
"mentionedRemoteUsers",
2023-07-24 11:35:44 +02:00
"emojis",
"tags",
"hasPoll",
"threadId",
"channelId",
"userId",
2023-07-27 03:58:38 +02:00
"userHost",
2023-07-24 11:35:44 +02:00
"replyId",
2023-07-27 03:58:38 +02:00
"replyUserId",
"replyUserHost",
2023-08-05 23:42:45 +02:00
"replyContent",
"replyCw",
"replyFiles",
2023-07-24 11:35:44 +02:00
"renoteId",
2023-07-27 03:58:38 +02:00
"renoteUserId",
"renoteUserHost",
2023-08-05 23:42:45 +02:00
"renoteContent",
"renoteCw",
"renoteFiles",
2023-07-24 11:35:44 +02:00
"reactions",
"noteEdit",
"updatedAt"
2023-07-19 16:35:47 +02:00
)
VALUES
2023-08-05 23:42:45 +02:00
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2023-07-19 16:35:47 +02:00
select: {
2023-07-30 23:35:34 +02:00
byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`,
2023-07-24 18:23:57 +02:00
byUri: `SELECT * FROM note WHERE "uri" IN ?`,
byUrl: `SELECT * FROM note WHERE "url" IN ?`,
2023-07-31 03:41:45 +02:00
byId: `SELECT * FROM note_by_id WHERE "id" IN ?`,
2023-07-24 18:23:57 +02:00
byUserId: `SELECT * FROM note_by_userid WHERE "userId" IN ?`,
2023-07-19 16:35:47 +02:00
},
2023-07-25 13:28:08 +02:00
delete: `DELETE FROM note WHERE "createdAtDate" = ? AND "createdAt" = ? AND "id" = ?`,
2023-07-24 11:35:44 +02:00
update: {
2023-07-25 13:28:08 +02:00
renoteCount: `UPDATE note SET
"renoteCount" = ?,
"score" = ?
WHERE "createdAtDate" = ? AND "createdAt" = ? AND "id" = ? IF EXISTS`,
2023-08-05 23:42:45 +02:00
repliesCount: `UPDATE note SET
"repliesCount" = ?,
WHERE "createdAtDate" = ? AND "createdAt" = ? AND "id" = ? IF EXISTS`,
2023-07-25 13:28:08 +02:00
reactions: `UPDATE note SET
"emojis" = ?,
"reactions" = ?,
"score" = ?
WHERE "createdAtDate" = ? AND "createdAt" = ? AND "id" = ? IF EXISTS`,
2023-07-24 13:52:35 +02:00
},
2023-07-19 16:35:47 +02:00
},
2023-07-24 11:35:44 +02:00
reaction: {
2023-07-24 13:52:35 +02:00
insert: `INSERT INTO reaction
("id", "noteId", "userId", "reaction", "emoji", "createdAt")
VALUES (?, ?, ?, ?, ?, ?)`,
2023-07-24 18:23:57 +02:00
select: {
byNoteId: `SELECT * FROM reaction WHERE "noteId" IN ?`,
byUserId: `SELECT * FROM reaction_by_userid WHERE "userId" IN ?`,
byNoteAndUser: `SELECT * FROM reaction WHERE "noteId" = ? AND "userId" = ?`,
byId: `SELECT * FROM reaction WHERE "id" IN ?`,
},
delete: `DELETE FROM reaction WHERE "noteId" = ? AND "userId" = ?`,
2023-07-24 11:35:44 +02:00
},
};
2023-07-24 04:29:38 +02:00
export interface ScyllaDriveFile {
id: string;
type: string;
createdAt: Date;
name: string;
comment: string | null;
blurhash: string | null;
url: string;
thumbnailUrl: string;
isSensitive: boolean;
isLink: boolean;
md5: string;
size: number;
2023-07-31 03:41:45 +02:00
width: number | null;
height: number | null;
2023-07-24 04:29:38 +02:00
}
2023-07-24 18:23:57 +02:00
2023-07-25 13:28:08 +02:00
export interface ScyllaNoteEditHistory {
content: string;
cw: string;
files: ScyllaDriveFile[];
updatedAt: Date;
}
2023-07-27 00:59:23 +02:00
export type ScyllaNote = Note & {
2023-07-25 13:28:08 +02:00
createdAtDate: Date;
files: ScyllaDriveFile[];
noteEdit: ScyllaNoteEditHistory[];
2023-08-05 23:42:45 +02:00
replyText: string | null;
replyCw: string | null;
replyFiles: ScyllaDriveFile[];
renoteText: string | null;
renoteCw: string | null;
renoteFiles: ScyllaDriveFile[];
2023-07-25 13:28:08 +02:00
};
export function parseScyllaNote(row: types.Row): ScyllaNote {
2023-07-31 03:41:45 +02:00
const files: ScyllaDriveFile[] = row.get("files") ?? [];
2023-07-25 13:28:08 +02:00
return {
createdAtDate: row.get("createdAtDate"),
createdAt: row.get("createdAt"),
id: row.get("id"),
visibility: row.get("visibility"),
2023-07-31 03:41:45 +02:00
text: row.get("content") ?? null,
name: row.get("name") ?? null,
cw: row.get("cw") ?? null,
2023-07-25 13:28:08 +02:00
localOnly: row.get("localOnly"),
renoteCount: row.get("renoteCount"),
repliesCount: row.get("repliesCount"),
2023-07-31 03:41:45 +02:00
uri: row.get("uri") ?? null,
url: row.get("url") ?? null,
2023-07-25 13:28:08 +02:00
score: row.get("score"),
files,
fileIds: files.map((file) => file.id),
2023-07-31 03:41:45 +02:00
attachedFileTypes: files.map((file) => file.type) ?? [],
visibleUserIds: row.get("visibleUserIds") ?? [],
mentions: row.get("mentions") ?? [],
emojis: row.get("emojis") ?? [],
tags: row.get("tags") ?? [],
hasPoll: row.get("hasPoll") ?? false,
threadId: row.get("threadId") ?? null,
channelId: row.get("channelId") ?? null,
2023-07-25 13:28:08 +02:00
userId: row.get("userId"),
2023-07-31 03:41:45 +02:00
userHost: row.get("userHost") ?? null,
replyId: row.get("replyId") ?? null,
replyUserId: row.get("replyUserId") ?? null,
replyUserHost: row.get("replyUserHost") ?? null,
2023-08-05 23:42:45 +02:00
replyText: row.get("replyContent") ?? null,
replyCw: row.get("replyCw") ?? null,
replyFiles: row.get("replyFiles") ?? [],
2023-07-31 03:41:45 +02:00
renoteId: row.get("renoteId") ?? null,
renoteUserId: row.get("renoteUserId") ?? null,
renoteUserHost: row.get("renoteUserHost") ?? null,
2023-08-05 23:42:45 +02:00
renoteText: row.get("renoteContent") ?? null,
renoteCw: row.get("renoteCw") ?? null,
renoteFiles: row.get("renoteFiles") ?? [],
2023-07-31 03:41:45 +02:00
reactions: row.get("reactions") ?? {},
noteEdit: row.get("noteEdit") ?? [],
updatedAt: row.get("updatedAt") ?? null,
mentionedRemoteUsers: row.get("mentionedRemoteUsers") ?? "[]",
2023-07-27 00:59:23 +02:00
/* unused postgres denormalization */
channel: null,
renote: null,
reply: null,
user: null,
};
2023-07-25 13:28:08 +02:00
}
export interface ScyllaNoteReaction extends NoteReaction {
emoji: PopulatedEmoji;
2023-07-24 18:23:57 +02:00
}
export function parseScyllaReaction(row: types.Row): ScyllaNoteReaction {
return {
id: row.get("id"),
noteId: row.get("noteId"),
userId: row.get("userId"),
reaction: row.get("reaction"),
createdAt: row.get("createdAt"),
emoji: row.get("emoji"),
2023-07-25 13:28:08 +02:00
};
2023-07-24 18:23:57 +02:00
}
2023-07-27 13:15:58 +02:00
2023-08-05 10:52:22 +02:00
export function prepareTimelineQuery(ps: {
untilId?: string;
untilDate?: number;
sinceId?: string;
sinceDate?: number;
}): { query: string; untilDate: Date; sinceDate: Date | null } {
2023-08-05 10:36:30 +02:00
const queryParts = [`${prepared.note.select.byDate} AND "createdAt" < ?`];
2023-08-05 10:52:22 +02:00
let until = new Date();
if (ps.untilId) {
until = new Date(getTimestamp(ps.untilId));
2023-08-05 10:36:30 +02:00
}
2023-08-05 10:52:22 +02:00
if (ps.untilDate && ps.untilDate < until.getTime()) {
until = new Date(ps.untilDate);
2023-08-05 10:36:30 +02:00
}
2023-08-05 10:52:22 +02:00
let since: Date | null = null;
if (ps.sinceId) {
since = new Date(getTimestamp(ps.sinceId));
2023-08-05 10:36:30 +02:00
}
2023-08-05 10:52:22 +02:00
if (ps.sinceDate && (!since || ps.sinceDate > since.getTime())) {
since = new Date(ps.sinceDate);
2023-08-05 10:36:30 +02:00
}
2023-08-05 10:52:22 +02:00
if (since !== null) {
2023-08-05 10:36:30 +02:00
queryParts.push(`AND "createdAt" > ?`);
}
queryParts.push("LIMIT 50"); // Hardcoded to issue a prepared query
const query = queryParts.join(" ");
return {
query,
2023-08-05 10:52:22 +02:00
untilDate: until,
sinceDate: since,
2023-08-05 10:36:30 +02:00
};
}
export async function execTimelineQuery(
ps: {
limit: number;
untilId?: string;
untilDate?: number;
sinceId?: string;
sinceDate?: number;
},
filter?: (_: ScyllaNote[]) => Promise<ScyllaNote[]>,
2023-08-05 11:42:45 +02:00
maxDays = 30,
2023-08-05 10:36:30 +02:00
): Promise<ScyllaNote[]> {
if (!scyllaClient) return [];
2023-08-05 10:52:22 +02:00
let { query, untilDate, sinceDate } = prepareTimelineQuery(ps);
2023-08-05 10:36:30 +02:00
2023-08-05 21:52:13 +02:00
let scannedEmptyPartitions = 0;
2023-08-05 10:36:30 +02:00
const foundNotes: ScyllaNote[] = [];
// Try to get posts of at most <maxDays> in the single request
2023-08-05 21:52:13 +02:00
while (foundNotes.length < ps.limit && scannedEmptyPartitions < maxDays) {
2023-08-05 10:36:30 +02:00
const params: (Date | string | string[])[] = [untilDate, untilDate];
if (sinceDate) {
params.push(sinceDate);
}
const result = await scyllaClient.execute(query, params, {
prepare: true,
});
if (result.rowLength === 0) {
// Reached the end of partition. Queries posts created one day before.
2023-08-05 21:52:13 +02:00
scannedEmptyPartitions++;
2023-08-05 10:36:30 +02:00
untilDate = new Date(
2023-08-05 11:42:45 +02:00
untilDate.getFullYear(),
untilDate.getMonth(),
untilDate.getDate() - 1,
2023-08-05 10:36:30 +02:00
23,
59,
59,
999,
);
if (sinceDate && untilDate < sinceDate) break;
continue;
}
2023-08-05 21:52:13 +02:00
scannedEmptyPartitions = 0;
2023-08-05 10:36:30 +02:00
const notes = result.rows.map(parseScyllaNote);
foundNotes.push(...(filter ? await filter(notes) : notes));
untilDate = notes[notes.length - 1].createdAt;
}
return foundNotes;
}
export async function filterVisibility(
notes: ScyllaNote[],
2023-07-27 13:15:58 +02:00
user: { id: User["id"] } | null,
2023-08-05 10:36:30 +02:00
followingIds?: User["id"][],
): Promise<ScyllaNote[]> {
let filtered = notes;
if (!user) {
filtered = filtered.filter((note) =>
["public", "home"].includes(note.visibility),
);
} else {
let followings: User["id"][];
if (followingIds) {
followings = followingIds;
} else {
const cache = await LocalFollowingsCache.init(user.id);
followings = await cache.getAll();
}
filtered = filtered.filter(
(note) =>
["public", "home"].includes(note.visibility) ||
note.userId === user.id ||
note.visibleUserIds.includes(user.id) ||
note.mentions.includes(user.id) ||
(note.visibility === "followers" &&
(followings.includes(note.userId) || note.replyUserId === user.id)),
);
2023-07-27 13:15:58 +02:00
}
2023-08-05 10:36:30 +02:00
return filtered;
2023-07-27 13:15:58 +02:00
}
2023-08-04 09:07:38 +02:00
export async function filterChannel(
notes: ScyllaNote[],
user: { id: User["id"] } | null,
2023-08-05 10:36:30 +02:00
followingIds?: Channel["id"][],
2023-08-04 09:07:38 +02:00
): Promise<ScyllaNote[]> {
2023-08-05 10:36:30 +02:00
let filtered = notes;
2023-08-04 09:07:38 +02:00
if (!user) {
2023-08-05 10:36:30 +02:00
filtered = filtered.filter((note) => !note.channelId);
2023-08-04 09:07:38 +02:00
} else {
2023-08-05 10:36:30 +02:00
const channelNotes = filtered.filter((note) => !!note.channelId);
2023-08-04 09:07:38 +02:00
if (channelNotes.length > 0) {
2023-08-05 10:36:30 +02:00
let followings: Channel["id"][];
if (followingIds) {
followings = followingIds;
} else {
const cache = await ChannelFollowingsCache.init(user.id);
followings = await cache.getAll();
}
filtered = filtered.filter(
(note) => !note.channelId || followings.includes(note.channelId),
2023-08-04 09:07:38 +02:00
);
}
}
2023-08-05 10:36:30 +02:00
return filtered;
2023-08-04 09:07:38 +02:00
}
2023-08-04 23:32:57 +02:00
export async function filterReply(
notes: ScyllaNote[],
withReplies: boolean,
user: { id: User["id"] } | null,
): Promise<ScyllaNote[]> {
2023-08-05 10:36:30 +02:00
let filtered = notes;
2023-08-04 23:32:57 +02:00
if (!user) {
2023-08-05 10:36:30 +02:00
filtered = filtered.filter(
2023-08-04 23:32:57 +02:00
(note) => !note.replyId || note.replyUserId === note.userId,
);
} else if (!withReplies) {
2023-08-05 10:36:30 +02:00
filtered = filtered.filter(
2023-08-04 23:32:57 +02:00
(note) =>
!note.replyId ||
note.replyUserId === user.id ||
note.userId === user.id ||
note.replyUserId === note.userId,
);
}
2023-08-05 10:36:30 +02:00
return filtered;
2023-08-04 23:32:57 +02:00
}
2023-08-05 21:52:13 +02:00
export async function filterMutedUser(
notes: ScyllaNote[],
user: { id: User["id"] },
exclude?: User,
2023-08-05 21:54:32 +02:00
): Promise<ScyllaNote[]> {
2023-08-05 21:52:13 +02:00
const userCache = await UserMutingsCache.init(user.id);
let mutedUserIds = await userCache.getAll();
if (exclude) {
mutedUserIds = mutedUserIds.filter((id) => id !== exclude.id);
}
const instanceCache = await InstanceMutingsCache.init(user.id);
const mutedInstances = await instanceCache.getAll();
return notes.filter(
(note) =>
!mutedUserIds.includes(note.userId) &&
!(note.replyUserId && mutedUserIds.includes(note.replyUserId)) &&
!(note.renoteUserId && mutedUserIds.includes(note.renoteUserId)) &&
!(note.userHost && mutedInstances.includes(note.userHost)) &&
!(note.replyUserHost && mutedInstances.includes(note.replyUserHost)) &&
!(note.renoteUserHost && mutedInstances.includes(note.renoteUserHost)),
);
}
2023-08-05 23:42:45 +02:00
export async function filterMutedNote(
notes: ScyllaNote[],
user: { id: User["id"] },
): Promise<ScyllaNote[]> {
const mutedWords = await userWordMuteCache.fetchMaybe(user.id, () =>
UserProfiles.findOne({
select: ["mutedWords"],
where: { userId: user.id },
}).then((profile) => profile?.mutedWords),
);
if (!mutedWords) {
return notes;
}
return notes.filter((note) => !getWordHardMute(note, user, mutedWords));
}
2023-08-06 06:40:55 +02:00
export async function filterBlockedUser(
notes: ScyllaNote[],
user: { id: User["id"] },
): Promise<ScyllaNote[]> {
const cache = await UserBlockedCache.init(user.id);
const blockerIds = await cache.getAll();
return notes.filter(
(note) =>
!blockerIds.includes(note.userId) &&
!(note.replyUserId && blockerIds.includes(note.replyUserId)) &&
!(note.renoteUserId && blockerIds.includes(note.renoteUserId)),
);
}