add poll type and table

This commit is contained in:
Namekuji 2023-08-17 09:45:58 -04:00
parent bacb0a1ee0
commit 6d49a39273
No known key found for this signature in database
GPG key ID: 1D62332C07FBA532
10 changed files with 360 additions and 195 deletions

View file

@ -1,3 +1,4 @@
DROP TABLE IF EXISTS poll_vote;
DROP MATERIALIZED VIEW IF EXISTS reaction_by_id; DROP MATERIALIZED VIEW IF EXISTS reaction_by_id;
DROP MATERIALIZED VIEW IF EXISTS reaction_by_user_id; DROP MATERIALIZED VIEW IF EXISTS reaction_by_user_id;
DROP INDEX IF EXISTS reaction_by_id; DROP INDEX IF EXISTS reaction_by_id;
@ -15,6 +16,7 @@ DROP INDEX IF EXISTS note_by_reply_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;
DROP TYPE IF EXISTS poll;
DROP TYPE IF EXISTS emoji; DROP TYPE IF EXISTS emoji;
DROP TYPE IF EXISTS note_edit_history; DROP TYPE IF EXISTS note_edit_history;
DROP TYPE IF EXISTS drive_file; DROP TYPE IF EXISTS drive_file;

View file

@ -29,6 +29,12 @@ CREATE TYPE IF NOT EXISTS emoji (
"height" int, "height" int,
); );
CREATE TYPE IF NOT EXISTS poll (
"expiresAt" timestamp,
"multiple" boolean,
"choices" map<int, text>,
);
CREATE TABLE IF NOT EXISTS note ( -- Store all posts CREATE TABLE IF NOT EXISTS note ( -- Store all posts
"createdAtDate" date, -- For partitioning "createdAtDate" date, -- For partitioning
"createdAt" timestamp, "createdAt" timestamp,
@ -50,6 +56,7 @@ CREATE TABLE IF NOT EXISTS note ( -- Store all posts
"emojis" set<text>, "emojis" set<text>,
"tags" set<text>, "tags" set<text>,
"hasPoll" boolean, "hasPoll" boolean,
"poll" poll,
"threadId" ascii, "threadId" ascii,
"channelId" ascii, -- Channel "channelId" ascii, -- Channel
"userId" ascii, -- User "userId" ascii, -- User
@ -172,6 +179,7 @@ CREATE TABLE IF NOT EXISTS home_timeline (
"emojis" set<text>, "emojis" set<text>,
"tags" set<text>, "tags" set<text>,
"hasPoll" boolean, "hasPoll" boolean,
"poll" poll,
"threadId" ascii, "threadId" ascii,
"channelId" ascii, -- Channel "channelId" ascii, -- Channel
"userId" ascii, -- User "userId" ascii, -- User
@ -220,3 +228,11 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS reaction_by_id AS
AND "reaction" IS NOT NULL AND "reaction" IS NOT NULL
AND "userId" IS NOT NULL AND "userId" IS NOT NULL
PRIMARY KEY ("noteId", "reaction", "userId"); PRIMARY KEY ("noteId", "reaction", "userId");
CREATE TABLE IF NOT EXISTS poll_vote (
"noteId" ascii,
"userId" ascii,
"choice" set<int>,
"createdAt" timestamp,
PRIMARY KEY ("noteId", "userId")
);

View file

@ -21,6 +21,7 @@ export const scyllaQueries = {
"emojis", "emojis",
"tags", "tags",
"hasPoll", "hasPoll",
"poll",
"threadId", "threadId",
"channelId", "channelId",
"userId", "userId",
@ -40,7 +41,7 @@ export const scyllaQueries = {
"reactions", "reactions",
"noteEdit", "noteEdit",
"updatedAt" "updatedAt"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
select: { select: {
byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`, byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`,
byUri: `SELECT * FROM note WHERE "uri" = ?`, byUri: `SELECT * FROM note WHERE "uri" = ?`,
@ -91,6 +92,7 @@ export const scyllaQueries = {
"emojis", "emojis",
"tags", "tags",
"hasPoll", "hasPoll",
"poll",
"threadId", "threadId",
"channelId", "channelId",
"userId", "userId",
@ -110,7 +112,7 @@ export const scyllaQueries = {
"reactions", "reactions",
"noteEdit", "noteEdit",
"updatedAt" "updatedAt"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
select: { select: {
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" = ?`,
@ -153,4 +155,8 @@ export const scyllaQueries = {
}, },
delete: `DELETE FROM reaction WHERE "noteId" = ? AND "userId" = ?`, delete: `DELETE FROM reaction WHERE "noteId" = ? AND "userId" = ?`,
}, },
poll: {
select: `SELECT * FROM poll_vote WHERE "noteId" = ?`,
insert: `INSERT INTO poll_vote ("noteId", "userId", "choice", "createdAt") VALUES (?, ?, ?, ?)`,
},
}; };

View file

@ -111,6 +111,28 @@ export interface ScyllaNoteEditHistory {
updatedAt: Date; updatedAt: Date;
} }
export interface ScyllaPoll {
expiresAt: Date | null;
multiple: boolean;
choices: Map<number, string>,
}
export interface ScyllaPollVote {
noteId: string,
userId: string,
choice: Set<number>,
createdAt: Date,
}
export function parseScyllaPollVote(row: types.Row): ScyllaPollVote {
return {
noteId: row.get("noteId"),
userId: row.get("userId"),
choice: row.get("choice"),
createdAt: row.get("createdAt"),
}
}
export type ScyllaNote = Note & { export type ScyllaNote = Note & {
createdAtDate: Date; createdAtDate: Date;
files: ScyllaDriveFile[]; files: ScyllaDriveFile[];
@ -121,6 +143,7 @@ export type ScyllaNote = Note & {
renoteText: string | null; renoteText: string | null;
renoteCw: string | null; renoteCw: string | null;
renoteFiles: ScyllaDriveFile[]; renoteFiles: ScyllaDriveFile[];
poll: ScyllaPoll | null;
}; };
export function parseScyllaNote(row: types.Row): ScyllaNote { export function parseScyllaNote(row: types.Row): ScyllaNote {
@ -149,6 +172,7 @@ export function parseScyllaNote(row: types.Row): ScyllaNote {
emojis: row.get("emojis") ?? [], emojis: row.get("emojis") ?? [],
tags: row.get("tags") ?? [], tags: row.get("tags") ?? [],
hasPoll: row.get("hasPoll") ?? false, hasPoll: row.get("hasPoll") ?? false,
poll: row.get("poll") ?? null,
threadId: row.get("threadId") ?? null, threadId: row.get("threadId") ?? null,
channelId: row.get("channelId") ?? null, channelId: row.get("channelId") ?? null,
userId: row.get("userId"), userId: row.get("userId"),

View file

@ -53,6 +53,7 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js";
import { truncate } from "@/misc/truncate.js"; import { truncate } from "@/misc/truncate.js";
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js"; import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { parseScyllaNote, prepared, scyllaClient } from "@/db/scylla.js";
const logger = apLogger; const logger = apLogger;
@ -317,7 +318,37 @@ export async function createNote(
} }
// vote // vote
if (reply?.hasPoll) { if (reply?.hasPoll && note.name) {
if (scyllaClient) {
const result = await scyllaClient.execute(
prepared.note.select.byId,
[reply.id],
{ prepare: true },
);
if (result.rowLength === 0) {
throw new Error("reply target note not found");
}
const scyllaNote = parseScyllaNote(result.first());
if (!scyllaNote.hasPoll || !scyllaNote.poll) {
throw new Error("reply target does not have poll");
}
if (scyllaNote.poll.expiresAt && scyllaNote.poll.expiresAt < new Date()) {
logger.warn(
`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${scyllaNote.id}, choice=${note.name}`,
);
return null;
}
const entry = Array.from(scyllaNote.poll.choices.entries()).find(
([_, v]) => v === note.name,
);
if (entry) {
await vote(actor, scyllaNote, entry[0]);
}
return null;
}
const poll = await Polls.findOneByOrFail({ noteId: reply.id }); const poll = await Polls.findOneByOrFail({ noteId: reply.id });
const tryCreateVote = async ( const tryCreateVote = async (
@ -337,13 +368,11 @@ export async function createNote(
return null; return null;
}; };
if (note.name) {
return await tryCreateVote( return await tryCreateVote(
note.name, note.name,
poll.choices.findIndex((x) => x === note.name), poll.choices.findIndex((x) => x === note.name),
); );
} }
}
const emojis = await extractEmojis(note.tag || [], actor.host).catch((e) => { const emojis = await extractEmojis(note.tag || [], actor.host).catch((e) => {
logger.info(`extractEmojis: ${e}`); logger.info(`extractEmojis: ${e}`);

View file

@ -73,6 +73,7 @@ import {
parseScyllaNote, parseScyllaNote,
prepared, prepared,
scyllaClient, scyllaClient,
ScyllaPoll,
} from "@/db/scylla.js"; } from "@/db/scylla.js";
export const mutedWordsCache = new Cache< export const mutedWordsCache = new Cache<
@ -703,13 +704,13 @@ async function incRenoteCount(renote: Note) {
], ],
{ prepare: true }, { prepare: true },
); );
const homeTimelines = await scyllaClient scyllaClient.eachRow(
.execute(prepared.homeTimeline.select.byId, [renote.id], { prepared.homeTimeline.select.byId,
prepare: true, [renote.id],
}) { prepare: true },
.then((result) => result.rows.map(parseHomeTimeline)); (_, row) => {
// Do not issue BATCH because different home timelines involve different partitions if (scyllaClient) {
for (const timeline of homeTimelines) { const timeline = parseHomeTimeline(row);
scyllaClient.execute( scyllaClient.execute(
prepared.homeTimeline.update.renoteCount, prepared.homeTimeline.update.renoteCount,
[ [
@ -723,6 +724,8 @@ async function incRenoteCount(renote: Note) {
{ prepare: true }, { prepare: true },
); );
} }
},
);
} else { } else {
Notes.createQueryBuilder() Notes.createQueryBuilder()
.update() .update()
@ -806,7 +809,7 @@ async function insertNote(
); );
} }
// 投稿を作成 // Insert post to DB
try { try {
if (scyllaClient) { if (scyllaClient) {
const fileMapper = (file: DriveFile) => ({ const fileMapper = (file: DriveFile) => ({
@ -830,6 +833,24 @@ async function insertNote(
) )
: null; : null;
let poll: ScyllaPoll | null = null;
if (data.poll) {
insert.hasPoll = true;
let expiresAt: Date | null;
if (!data.poll.expiresAt || isNaN(data.poll.expiresAt.getTime())) {
expiresAt = null;
} else {
expiresAt = data.poll.expiresAt;
}
poll = {
expiresAt,
choices: new Map(data.poll.choices.map((v, i) => [i, v] as [number, string])),
multiple: data.poll.multiple,
};
}
const params = [ const params = [
insert.createdAt, insert.createdAt,
insert.createdAt, insert.createdAt,
@ -851,6 +872,7 @@ async function insertNote(
insert.emojis, insert.emojis,
insert.tags, insert.tags,
insert.hasPoll, insert.hasPoll,
poll,
insert.threadId, insert.threadId,
insert.channelId, insert.channelId,
insert.userId, insert.userId,
@ -895,15 +917,13 @@ async function insertNote(
}, },
); );
} }
} } else {
if (insert.hasPoll) { if (insert.hasPoll) {
// Start transaction // Start transaction
await db.transaction(async (transactionalEntityManager) => { await db.transaction(async (transactionalEntityManager) => {
if (!data.poll) throw new Error("Empty poll data"); if (!data.poll) throw new Error("Empty poll data");
if (!scyllaClient) {
await transactionalEntityManager.insert(Note, insert); await transactionalEntityManager.insert(Note, insert);
}
let expiresAt: Date | null; let expiresAt: Date | null;
if (!data.poll.expiresAt || isNaN(data.poll.expiresAt.getTime())) { if (!data.poll.expiresAt || isNaN(data.poll.expiresAt.getTime())) {
@ -925,9 +945,10 @@ async function insertNote(
await transactionalEntityManager.insert(Poll, poll); await transactionalEntityManager.insert(Poll, poll);
}); });
} else if (!scyllaClient) { } else {
await Notes.insert(insert); await Notes.insert(insert);
} }
}
if (scyllaClient) { if (scyllaClient) {
const result = await scyllaClient.execute( const result = await scyllaClient.execute(
@ -1074,13 +1095,13 @@ async function saveReply(reply: Note) {
], ],
{ prepare: true }, { prepare: true },
); );
const homeTimelines = await scyllaClient scyllaClient.eachRow(
.execute(prepared.homeTimeline.select.byId, [reply.id], { prepared.homeTimeline.select.byId,
prepare: true, [reply.id],
}) { prepare: true },
.then((result) => result.rows.map(parseHomeTimeline)); (_, row) => {
// Do not issue BATCH because different home timelines involve different partitions if (scyllaClient) {
for (const timeline of homeTimelines) { const timeline = parseHomeTimeline(row);
scyllaClient.execute( scyllaClient.execute(
prepared.homeTimeline.update.repliesCount, prepared.homeTimeline.update.repliesCount,
[ [
@ -1093,6 +1114,8 @@ async function saveReply(reply: Note) {
{ prepare: true }, { prepare: true },
); );
} }
},
);
} else { } else {
await Notes.increment({ id: reply.id }, "repliesCount", 1); await Notes.increment({ id: reply.id }, "repliesCount", 1);
} }

View file

@ -70,13 +70,15 @@ export default async function (
], ],
{ prepare: true }, { prepare: true },
); );
const homeTimelines = await scyllaClient scyllaClient.eachRow(
.execute(prepared.homeTimeline.select.byId, [renote.id], { prepared.homeTimeline.select.byId,
[renote.id],
{
prepare: true, prepare: true,
}) },
.then((result) => result.rows.map(parseHomeTimeline)); (_, row) => {
// Do not issue BATCH because different home timelines involve different partitions if (scyllaClient) {
for (const timeline of homeTimelines) { const timeline = parseHomeTimeline(row);
scyllaClient.execute( scyllaClient.execute(
prepared.homeTimeline.update.renoteCount, prepared.homeTimeline.update.renoteCount,
[ [
@ -90,6 +92,8 @@ export default async function (
{ prepare: true }, { prepare: true },
); );
} }
},
);
} }
} else { } else {
Notes.decrement({ id: note.renoteId }, "renoteCount", 1); Notes.decrement({ id: note.renoteId }, "renoteCount", 1);
@ -119,13 +123,15 @@ export default async function (
], ],
{ prepare: true }, { prepare: true },
); );
const homeTimelines = await scyllaClient scyllaClient.eachRow(
.execute(prepared.homeTimeline.select.byId, [reply.id], { prepared.homeTimeline.select.byId,
[reply.id],
{
prepare: true, prepare: true,
}) },
.then((result) => result.rows.map(parseHomeTimeline)); (_, row) => {
// Do not issue BATCH because different home timelines involve different partitions if (scyllaClient) {
for (const timeline of homeTimelines) { const timeline = parseHomeTimeline(row);
scyllaClient.execute( scyllaClient.execute(
prepared.homeTimeline.update.repliesCount, prepared.homeTimeline.update.repliesCount,
[ [
@ -138,6 +144,8 @@ export default async function (
{ prepare: true }, { prepare: true },
); );
} }
},
);
} }
} else { } else {
await Notes.decrement({ id: note.replyId }, "repliesCount", 1); await Notes.decrement({ id: note.replyId }, "repliesCount", 1);

View file

@ -1,20 +1,68 @@
import { publishNoteStream } from "@/services/stream.js"; import { publishNoteStream } from "@/services/stream.js";
import type { CacheableUser } from "@/models/entities/user.js"; import type { CacheableUser } from "@/models/entities/user.js";
import { User } from "@/models/entities/user.js";
import type { Note } from "@/models/entities/note.js"; import type { Note } from "@/models/entities/note.js";
import { PollVotes, NoteWatchings, Polls, Blockings } from "@/models/index.js"; import { PollVotes, NoteWatchings, Polls, Blockings } from "@/models/index.js";
import { Not } from "typeorm"; import { Not } from "typeorm";
import { genId } from "@/misc/gen-id.js"; import { genId } from "@/misc/gen-id.js";
import { createNotification } from "../../create-notification.js"; import { createNotification } from "../../create-notification.js";
import {
type ScyllaNote,
type ScyllaPollVote,
scyllaClient,
prepared,
parseScyllaPollVote,
} from "@/db/scylla.js";
import { UserBlockingCache } from "@/misc/cache.js";
export default async function ( export default async function (
user: CacheableUser, user: CacheableUser,
note: Note, note: Note | ScyllaNote,
choice: number, choice: number,
) { ) {
const poll = await Polls.findOneBy({ noteId: note.id }); if (scyllaClient) {
const scyllaNote = note as ScyllaNote;
if (!scyllaNote.hasPoll || !scyllaNote.poll) {
throw new Error("poll not found");
}
if (poll == null) throw new Error("poll not found"); if (!Array.from(scyllaNote.poll.choices.keys()).includes(choice)) {
throw new Error("invalid choice param");
}
if (scyllaNote.userId !== user.id) {
const isBlocking = await UserBlockingCache.init(scyllaNote.userId).then(
(cache) => cache.has(user.id),
);
if (isBlocking) {
throw new Error("blocked");
}
}
let newChoice: ScyllaPollVote["choice"] = new Set();
const result = await scyllaClient.execute(
`${prepared.poll.select} AND "userId" = ?`,
[scyllaNote.id, user.id],
{ prepare: true },
);
if (result.rowLength > 0) {
const vote = parseScyllaPollVote(result.first());
if (scyllaNote.poll.multiple && !vote.choice.has(choice)) {
newChoice = vote.choice.add(choice);
} else {
throw new Error("already voted");
}
} else {
newChoice.add(choice);
}
await scyllaClient.execute(
prepared.poll.insert,
[scyllaNote.id, user.id, newChoice, new Date()],
{ prepare: true },
);
} else {
const poll = await Polls.findOneBy({ noteId: note.id });
if (!poll) throw new Error("poll not found");
// Check whether is valid choice // Check whether is valid choice
if (poll.choices[choice] == null) throw new Error("invalid choice param"); if (poll.choices[choice] == null) throw new Error("invalid choice param");
@ -58,6 +106,7 @@ export default async function (
await Polls.query( await Polls.query(
`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`, `UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`,
); );
}
publishNoteStream(note.id, "pollVoted", { publishNoteStream(note.id, "pollVoted", {
choice: choice, choice: choice,

View file

@ -123,13 +123,15 @@ export default async (
], ],
{ prepare: true }, { prepare: true },
); );
const homeTimelines = await scyllaClient scyllaClient.eachRow(
.execute(prepared.homeTimeline.select.byId, [note.id], { prepared.homeTimeline.select.byId,
[note.id],
{
prepare: true, prepare: true,
}) },
.then((result) => result.rows.map(parseHomeTimeline)); (_, row) => {
// Do not issue BATCH because different home timelines involve different partitions if (scyllaClient) {
for (const timeline of homeTimelines) { const timeline = parseHomeTimeline(row);
scyllaClient.execute( scyllaClient.execute(
prepared.homeTimeline.update.reactions, prepared.homeTimeline.update.reactions,
[ [
@ -144,6 +146,8 @@ export default async (
{ prepare: true }, { prepare: true },
); );
} }
},
);
} else { } else {
const sql = `jsonb_set("reactions", '{${_reaction}}', (COALESCE("reactions"->>'${_reaction}', '0')::int + 1)::text::jsonb)`; const sql = `jsonb_set("reactions", '{${_reaction}}', (COALESCE("reactions"->>'${_reaction}', '0')::int + 1)::text::jsonb)`;
await Notes.createQueryBuilder() await Notes.createQueryBuilder()

View file

@ -79,13 +79,15 @@ export default async (
], ],
{ prepare: true }, { prepare: true },
); );
const homeTimelines = await scyllaClient scyllaClient.eachRow(
.execute(prepared.homeTimeline.select.byId, [note.id], { prepared.homeTimeline.select.byId,
[note.id],
{
prepare: true, prepare: true,
}) },
.then((result) => result.rows.map(parseHomeTimeline)); (_, row) => {
// Do not issue BATCH because different home timelines involve different partitions if (scyllaClient) {
for (const timeline of homeTimelines) { const timeline = parseHomeTimeline(row);
scyllaClient.execute( scyllaClient.execute(
prepared.homeTimeline.update.reactions, prepared.homeTimeline.update.reactions,
[ [
@ -100,6 +102,8 @@ export default async (
{ prepare: true }, { prepare: true },
); );
} }
},
);
} else { } else {
const sql = `jsonb_set("reactions", '{${reaction.reaction}}', (COALESCE("reactions"->>'${reaction.reaction}', '0')::int - 1)::text::jsonb)`; const sql = `jsonb_set("reactions", '{${reaction.reaction}}', (COALESCE("reactions"->>'${reaction.reaction}', '0')::int - 1)::text::jsonb)`;
await Notes.createQueryBuilder() await Notes.createQueryBuilder()