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

366 lines
9.3 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";
import { Client, types } from "cassandra-driver";
2023-07-27 13:15:58 +02:00
import type { User } from "@/models/entities/user.js";
2023-08-04 09:07:38 +02:00
import { ChannelFollowingsCache, LocalFollowingsCache } from "@/misc/cache.js";
2023-08-05 10:36:30 +02:00
import { getTimestamp } from "@/misc/gen-id";
2023-07-19 16:35:47 +02:00
function newClient(): Client | null {
if (!config.scylla) {
return null;
}
return new Client({
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,
});
}
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-07-24 11:35:44 +02:00
"renoteId",
2023-07-27 03:58:38 +02:00
"renoteUserId",
"renoteUserHost",
2023-07-24 11:35:44 +02:00
"reactions",
"noteEdit",
"updatedAt"
2023-07-19 16:35:47 +02:00
)
VALUES
2023-07-31 03:41: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`,
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[];
};
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,
renoteId: row.get("renoteId") ?? null,
renoteUserId: row.get("renoteUserId") ?? null,
renoteUserHost: row.get("renoteUserHost") ?? null,
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;
},
maxDays = 30,
filter?: (_: ScyllaNote[]) => Promise<ScyllaNote[]>,
): 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
let scannedPartitions = 0;
const foundNotes: ScyllaNote[] = [];
// Try to get posts of at most <maxDays> in the single request
while (foundNotes.length < ps.limit && scannedPartitions < maxDays) {
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.
scannedPartitions++;
untilDate = new Date(
untilDate.getUTCFullYear(),
untilDate.getUTCMonth(),
untilDate.getUTCDate() - 1,
23,
59,
59,
999,
);
if (sinceDate && untilDate < sinceDate) break;
continue;
}
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
}