typing
This commit is contained in:
parent
7eb2e8637a
commit
14343ed611
5 changed files with 292 additions and 137 deletions
48
apps/db/migrations/51_typing.up.sql
Normal file
48
apps/db/migrations/51_typing.up.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS public.typing_status
|
||||||
|
(
|
||||||
|
chatid uuid NOT NULL,
|
||||||
|
userid uuid NOT NULL,
|
||||||
|
username text NOT NULL,
|
||||||
|
last_ping timestamp without time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'typing_status_pkey'
|
||||||
|
AND conrelid = 'public.typing_status'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.typing_status
|
||||||
|
ADD CONSTRAINT typing_status_pkey PRIMARY KEY (chatid,userid);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'typing_user'
|
||||||
|
AND conrelid = 'public.typing_status'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.typing_status
|
||||||
|
ADD CONSTRAINT typing_user
|
||||||
|
FOREIGN KEY (userid) REFERENCES public.users (id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'typing_chat'
|
||||||
|
AND conrelid = 'public.typing_status'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.typing_status
|
||||||
|
ADD CONSTRAINT typing_chat
|
||||||
|
FOREIGN KEY (chatid) REFERENCES public.chats (chatid)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS typing_status_chat_lastping_idx
|
||||||
|
ON typing_status (chatid, last_ping DESC);
|
||||||
42
apps/infoalloggi/src/schemas/public/TypingStatus.ts
Normal file
42
apps/infoalloggi/src/schemas/public/TypingStatus.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { chatsChatid, type ChatsChatid } from './Chats';
|
||||||
|
import { usersId, type UsersId } from './Users';
|
||||||
|
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** Represents the table public.typing_status */
|
||||||
|
export default interface TypingStatusTable {
|
||||||
|
chatid: ColumnType<ChatsChatid, ChatsChatid, ChatsChatid>;
|
||||||
|
|
||||||
|
userid: ColumnType<UsersId, UsersId, UsersId>;
|
||||||
|
|
||||||
|
username: ColumnType<string, string, string>;
|
||||||
|
|
||||||
|
last_ping: ColumnType<Date, Date | string | undefined, Date | string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypingStatus = Selectable<TypingStatusTable>;
|
||||||
|
|
||||||
|
export type NewTypingStatus = Insertable<TypingStatusTable>;
|
||||||
|
|
||||||
|
export type TypingStatusUpdate = Updateable<TypingStatusTable>;
|
||||||
|
|
||||||
|
export const TypingStatusSchema = z.object({
|
||||||
|
chatid: chatsChatid,
|
||||||
|
userid: usersId,
|
||||||
|
username: z.string(),
|
||||||
|
last_ping: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NewTypingStatusSchema = z.object({
|
||||||
|
chatid: chatsChatid,
|
||||||
|
userid: usersId,
|
||||||
|
username: z.string(),
|
||||||
|
last_ping: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TypingStatusUpdateSchema = z.object({
|
||||||
|
chatid: chatsChatid.optional(),
|
||||||
|
userid: usersId.optional(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
last_ping: z.union([z.date(), z.string()]).pipe(z.coerce.date()).optional(),
|
||||||
|
});
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "~/server/api/trpc";
|
} from "~/server/api/trpc";
|
||||||
import { db } from "~/server/db";
|
import { db, pgPool } from "~/server/db";
|
||||||
import { add } from "~/server/services/chat.service";
|
import { add } from "~/server/services/chat.service";
|
||||||
import {
|
import {
|
||||||
editMessage,
|
editMessage,
|
||||||
|
|
@ -29,15 +29,18 @@ import {
|
||||||
SidebarUpdateSchema,
|
SidebarUpdateSchema,
|
||||||
TypingDataSchema,
|
TypingDataSchema,
|
||||||
} from "~/server/utils/zod_utils";
|
} from "~/server/utils/zod_utils";
|
||||||
import { getKeydbClient, getSubClient } from "~/utils/keydb";
|
|
||||||
import { withUserColorStr } from "~/utils/kysely-helper";
|
import { withUserColorStr } from "~/utils/kysely-helper";
|
||||||
|
import {
|
||||||
const getChatChannel = (chatId: string) => `chat:${chatId}:messages`;
|
clearTyping,
|
||||||
const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`;
|
getChatChannel,
|
||||||
const getEventsChannel = (chatId: string) => `chat:${chatId}:events`;
|
getEventsChannel,
|
||||||
const getTypingKey = (chatId: string) => `typing:${chatId}`;
|
getTypingChannel,
|
||||||
|
getTypingList,
|
||||||
const SIDEBAR_CHANNEL = "sidebar_updates";
|
pgPublish,
|
||||||
|
pruneStaleTyping,
|
||||||
|
SIDEBAR_CHANNEL,
|
||||||
|
setTyping,
|
||||||
|
} from "../../services/pubsub.service";
|
||||||
|
|
||||||
type SidebarUpdate = z.infer<typeof SidebarUpdateSchema>;
|
type SidebarUpdate = z.infer<typeof SidebarUpdateSchema>;
|
||||||
export type Message = z.infer<typeof MessageDataSchema>;
|
export type Message = z.infer<typeof MessageDataSchema>;
|
||||||
|
|
@ -80,13 +83,10 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (didUpdate) {
|
if (didUpdate) {
|
||||||
const kdb = getKeydbClient();
|
await pgPublish(
|
||||||
if (kdb) {
|
getEventsChannel(input.chatId),
|
||||||
await kdb.publish(
|
JSON.stringify({ eventType: "read_receipt", data: null }),
|
||||||
getEventsChannel(input.chatId),
|
);
|
||||||
JSON.stringify({ eventType: "read_receipt", data: null }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.targetMsgId) {
|
if (input.targetMsgId) {
|
||||||
|
|
@ -338,30 +338,15 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
time: newMessage.time.toISOString(),
|
time: newMessage.time.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const kdb = getKeydbClient();
|
|
||||||
const msgChannel = getChatChannel(input.chatId);
|
const msgChannel = getChatChannel(input.chatId);
|
||||||
const typingChannel = getTypingChannel(input.chatId);
|
|
||||||
const typingKey = getTypingKey(input.chatId);
|
|
||||||
if (kdb) {
|
|
||||||
await kdb.publish(msgChannel, JSON.stringify(payload));
|
|
||||||
const member = JSON.stringify({
|
|
||||||
userId: input.sender,
|
|
||||||
username: userInfos.sender_nome,
|
|
||||||
});
|
|
||||||
await kdb.zrem(typingKey, member);
|
|
||||||
const typingList = await kdb.zrange(typingKey, 0, -1);
|
|
||||||
|
|
||||||
await kdb.publish(
|
await pgPublish(msgChannel, JSON.stringify(payload));
|
||||||
typingChannel,
|
const sidebarPayload: SidebarUpdate = {
|
||||||
JSON.stringify(typingList.map((m) => JSON.parse(m))),
|
chatId: input.chatId,
|
||||||
);
|
lastMessage: input.message,
|
||||||
const sidebarPayload: SidebarUpdate = {
|
senderName: userInfos.sender_nome,
|
||||||
chatId: input.chatId,
|
};
|
||||||
lastMessage: input.message,
|
await pgPublish(SIDEBAR_CHANNEL, JSON.stringify(sidebarPayload));
|
||||||
senderName: userInfos.sender_nome,
|
|
||||||
};
|
|
||||||
await kdb.publish(SIDEBAR_CHANNEL, JSON.stringify(sidebarPayload));
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}),
|
}),
|
||||||
|
|
@ -374,13 +359,10 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
.where("messageid", "=", input.messageId)
|
.where("messageid", "=", input.messageId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
const kdb = getKeydbClient();
|
await pgPublish(
|
||||||
if (kdb) {
|
getEventsChannel(input.chatId),
|
||||||
await kdb.publish(
|
JSON.stringify({ eventType: "delete", data: input.messageId }),
|
||||||
getEventsChannel(input.chatId),
|
);
|
||||||
JSON.stringify({ eventType: "delete", data: input.messageId }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete message:", err);
|
console.error("Failed to delete message:", err);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
@ -406,16 +388,13 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const kdb = getKeydbClient();
|
await pgPublish(
|
||||||
if (kdb) {
|
getEventsChannel(input.chatId),
|
||||||
await kdb.publish(
|
JSON.stringify({
|
||||||
getEventsChannel(input.chatId),
|
eventType: "edit",
|
||||||
JSON.stringify({
|
data: { messageId: input.messageId, message: input.message },
|
||||||
eventType: "edit",
|
}),
|
||||||
data: { messageId: input.messageId, message: input.message },
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to edit message:", err);
|
console.error("Failed to edit message:", err);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
@ -434,20 +413,20 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
)
|
)
|
||||||
.subscription(async function* ({ input, signal }) {
|
.subscription(async function* ({ input, signal }) {
|
||||||
if (!input.chatId) return;
|
if (!input.chatId) return;
|
||||||
const sub = getSubClient();
|
|
||||||
if (!sub) return; // Build-time safety
|
const client = await pgPool.connect();
|
||||||
|
|
||||||
const channel = getEventsChannel(input.chatId);
|
const channel = getEventsChannel(input.chatId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sub.subscribe(channel);
|
await client.query(`LISTEN "${channel}"`);
|
||||||
const iterable = on(sub, "message");
|
const iterable = on(client, "notification");
|
||||||
|
|
||||||
for await (const [chan, message] of iterable) {
|
for await (const [{ channel: chan, payload }] of iterable) {
|
||||||
if (chan === channel) {
|
if (chan === channel) {
|
||||||
if (signal?.aborted) break; // Extra safety check
|
if (signal?.aborted) break; // Extra safety check
|
||||||
const parsed = MessageUpdateEventSchema.safeParse(
|
const parsed = MessageUpdateEventSchema.safeParse(
|
||||||
JSON.parse(message),
|
JSON.parse(payload),
|
||||||
);
|
);
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
yield parsed.data;
|
yield parsed.data;
|
||||||
|
|
@ -460,8 +439,8 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await sub.unsubscribe(channel);
|
await client.query(`UNLISTEN "${channel}"`);
|
||||||
await sub.quit();
|
client.release();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
onSidebarUpdate: publicProcedure
|
onSidebarUpdate: publicProcedure
|
||||||
|
|
@ -472,13 +451,14 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscription(async function* () {
|
.subscription(async function* () {
|
||||||
const sub = getSubClient();
|
const client = await pgPool.connect();
|
||||||
if (!sub) return;
|
|
||||||
try {
|
try {
|
||||||
await sub.subscribe(SIDEBAR_CHANNEL);
|
await client.query(`LISTEN "${SIDEBAR_CHANNEL}"`);
|
||||||
const iterable = on(sub, "message");
|
|
||||||
for await (const [_chan, message] of iterable) {
|
const iterable = on(client, "notification");
|
||||||
const parsed = SidebarUpdateSchema.safeParse(JSON.parse(message));
|
|
||||||
|
for await (const [{ payload }] of iterable) {
|
||||||
|
const parsed = SidebarUpdateSchema.safeParse(JSON.parse(payload));
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
yield parsed.data;
|
yield parsed.data;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -486,8 +466,8 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await sub.unsubscribe(SIDEBAR_CHANNEL);
|
await client.query(`UNLISTEN "${SIDEBAR_CHANNEL}"`);
|
||||||
await sub.quit();
|
client.release();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -501,30 +481,19 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const kdb = getKeydbClient();
|
const { chatId, userId, username, isTyping } = input;
|
||||||
if (!kdb) return;
|
|
||||||
|
|
||||||
const key = getTypingKey(input.chatId);
|
if (isTyping) {
|
||||||
const now = Date.now();
|
await setTyping(chatId, userId, username);
|
||||||
const member = JSON.stringify({
|
|
||||||
userId: input.userId,
|
|
||||||
username: input.username,
|
|
||||||
});
|
|
||||||
if (input.isTyping) {
|
|
||||||
await kdb.zadd(key, now, member);
|
|
||||||
} else {
|
} else {
|
||||||
await kdb.zrem(key, member);
|
await clearTyping(chatId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup: Remove users who haven't pinged in 6 seconds
|
await pruneStaleTyping(chatId, 6000);
|
||||||
await kdb.zremrangebyscore(key, "-inf", now - 6000);
|
|
||||||
|
|
||||||
const typingList = await kdb.zrange(key, 0, -1);
|
const typingList = await getTypingList(chatId);
|
||||||
|
|
||||||
await kdb.publish(
|
await pgPublish(getTypingChannel(chatId), JSON.stringify(typingList));
|
||||||
getTypingChannel(input.chatId),
|
|
||||||
JSON.stringify(typingList.map((m) => JSON.parse(m))),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
@ -543,15 +512,13 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
)
|
)
|
||||||
.subscription(async function* ({ input, signal }) {
|
.subscription(async function* ({ input, signal }) {
|
||||||
if (!input.chatId) return;
|
if (!input.chatId) return;
|
||||||
const sub = getSubClient();
|
const client = await pgPool.connect();
|
||||||
if (!sub) return; // Build-time safety
|
|
||||||
|
|
||||||
const channel = getChatChannel(input.chatId);
|
const msgChan = getChatChannel(input.chatId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ON CONNECT
|
await client.query(`LISTEN "${msgChan}"`);
|
||||||
await sub.subscribe(channel);
|
const iterable = on(client, "notification");
|
||||||
const iterable = on(sub, "message");
|
|
||||||
|
|
||||||
let lastMessageId: MessagesMessageid | null =
|
let lastMessageId: MessagesMessageid | null =
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -638,10 +605,10 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
yield* maybeYield(msg);
|
yield* maybeYield(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (const [chan, message] of iterable) {
|
for await (const [{ channel, payload }] of iterable) {
|
||||||
if (chan === channel) {
|
if (channel === msgChan) {
|
||||||
if (signal?.aborted) break; // Extra safety check
|
if (signal?.aborted) break;
|
||||||
const parsed = MessageDataSchema.safeParse(JSON.parse(message));
|
const parsed = MessageDataSchema.safeParse(JSON.parse(payload));
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
yield* maybeYield(parsed.data);
|
yield* maybeYield(parsed.data);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -650,10 +617,11 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await sub.unsubscribe(channel);
|
await client.query(`UNLISTEN "${msgChan}"`);
|
||||||
await sub.quit();
|
client.release();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
onTyping: publicProcedure
|
onTyping: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -669,32 +637,28 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscription(async function* ({ input, signal }) {
|
.subscription(async function* ({ input, signal }) {
|
||||||
if (!input.chatId) return;
|
const { chatId, userId } = input;
|
||||||
const sub = getSubClient();
|
if (!chatId) return;
|
||||||
const kdb = getKeydbClient();
|
|
||||||
if (!sub || !kdb) return; // Build-time safety
|
|
||||||
|
|
||||||
const channel = getTypingChannel(input.chatId);
|
const client = await pgPool.connect();
|
||||||
const typingKey = getTypingKey(input.chatId);
|
|
||||||
|
const channel = getTypingChannel(chatId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ON CONNECT
|
await client.query(`LISTEN "${channel}"`);
|
||||||
await sub.subscribe(channel);
|
const iterable = on(client, "notification");
|
||||||
const iterable = on(sub, "message");
|
|
||||||
|
|
||||||
const current = await kdb.zrange(typingKey, 0, -1);
|
const current = await getTypingList(chatId);
|
||||||
|
|
||||||
const parsed = TypingDataSchema.safeParse(
|
const parsed = TypingDataSchema.safeParse(current);
|
||||||
current.map((m) => JSON.parse(m)),
|
|
||||||
);
|
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
yield parsed.data;
|
yield parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (const [chan, message] of iterable) {
|
for await (const [{ channel: chan, payload }] of iterable) {
|
||||||
if (chan === channel) {
|
if (chan === channel) {
|
||||||
if (signal?.aborted) break; // Extra safety check
|
if (signal?.aborted) break; // Extra safety check
|
||||||
const parsed = TypingDataSchema.safeParse(JSON.parse(message));
|
const parsed = TypingDataSchema.safeParse(JSON.parse(payload));
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
yield parsed.data;
|
yield parsed.data;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -703,20 +667,13 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// ON DISCONNECT (Tab closed, refreshed, or network dropped)
|
await clearTyping(chatId, userId);
|
||||||
const member = JSON.stringify({
|
|
||||||
userId: input.userId,
|
|
||||||
username: input.username,
|
|
||||||
});
|
|
||||||
await kdb.zrem(typingKey, member);
|
|
||||||
const remainingT = await kdb.zrange(typingKey, 0, -1);
|
|
||||||
await kdb.publish(
|
|
||||||
channel,
|
|
||||||
JSON.stringify(remainingT.map((m) => JSON.parse(m))),
|
|
||||||
);
|
|
||||||
|
|
||||||
await sub.unsubscribe(channel);
|
const remainingT = await getTypingList(chatId);
|
||||||
await sub.quit();
|
|
||||||
|
await pgPublish(channel, JSON.stringify(remainingT));
|
||||||
|
await client.query(`UNLISTEN "${channel}"`);
|
||||||
|
client.release();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
setRead: protectedProcedure
|
setRead: protectedProcedure
|
||||||
|
|
@ -729,13 +686,10 @@ export const chatSSERouter = createTRPCRouter({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (didUpdate) {
|
if (didUpdate) {
|
||||||
const kdb = getKeydbClient();
|
await pgPublish(
|
||||||
if (kdb) {
|
getEventsChannel(input.chatId),
|
||||||
await kdb.publish(
|
JSON.stringify({ eventType: "read_receipt", data: null }),
|
||||||
getEventsChannel(input.chatId),
|
);
|
||||||
JSON.stringify({ eventType: "read_receipt", data: null }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const pool = new Pool({
|
||||||
max: 10,
|
max: 10,
|
||||||
password: env.POSTGRES_PASSWORD,
|
password: env.POSTGRES_PASSWORD,
|
||||||
port: Number.parseInt(env.PGPORT),
|
port: Number.parseInt(env.PGPORT),
|
||||||
user: env.POSTGRES_USER,
|
user: env.POSTGRES_USER
|
||||||
});
|
});
|
||||||
|
|
||||||
pool.on("error", (err) => {
|
pool.on("error", (err) => {
|
||||||
|
|
@ -82,3 +82,5 @@ const _DbLogger = (event: LogEvent) => {
|
||||||
//console.error("\x1b[31merror:\x1b[0m", event.error);
|
//console.error("\x1b[31merror:\x1b[0m", event.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const pgPool = pool
|
||||||
109
apps/infoalloggi/src/server/services/pubsub.service.ts
Normal file
109
apps/infoalloggi/src/server/services/pubsub.service.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
import type { ChatsChatid } from "@/src/schemas/public/Chats";
|
||||||
|
import type { UsersId } from "@/src/schemas/public/Users";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
export const getChatChannel = (chatId: string) => `chat:${chatId}:messages`;
|
||||||
|
export const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`;
|
||||||
|
export const getEventsChannel = (chatId: string) => `chat:${chatId}:events`;
|
||||||
|
|
||||||
|
export const SIDEBAR_CHANNEL = "sidebar_updates";
|
||||||
|
export async function pgPublish(channel: string, payload: string) {
|
||||||
|
try {
|
||||||
|
await sql`SELECT pg_notify(${channel}, ${payload})`.execute(db);
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `pgPub Error: ${(err as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//pgListen
|
||||||
|
/*
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`LISTEN "${msgChan}"`);
|
||||||
|
|
||||||
|
const iterable = on(client, "notification");
|
||||||
|
|
||||||
|
for await (const [{ channel, payload }] of iterable) {
|
||||||
|
console.log("Received:", channel, payload);
|
||||||
|
const message = JSON.parse(payload) as { title?: string };
|
||||||
|
|
||||||
|
if (channel === msgChan) {
|
||||||
|
if (signal?.aborted) break; // Extra safety check
|
||||||
|
yield message.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.query(`UNLISTEN "${msgChan}"`);
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function setTyping(
|
||||||
|
chatId: ChatsChatid,
|
||||||
|
userId: UsersId,
|
||||||
|
username: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insertInto("typing_status")
|
||||||
|
.values({
|
||||||
|
chatid: chatId,
|
||||||
|
userid: userId,
|
||||||
|
username,
|
||||||
|
last_ping: new Date(),
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(["chatid", "userid"])
|
||||||
|
.doUpdateSet({ last_ping: new Date(), username }),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTyping(
|
||||||
|
chatId: ChatsChatid,
|
||||||
|
userId: UsersId,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.deleteFrom("typing_status")
|
||||||
|
.where("chatid", "=", chatId)
|
||||||
|
.where("userid", "=", userId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove rows that haven’t pinged in the last `maxAgeMs` milliseconds.
|
||||||
|
*/
|
||||||
|
export async function pruneStaleTyping(
|
||||||
|
chatId: ChatsChatid,
|
||||||
|
maxAgeMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const cutoff = new Date(Date.now() - maxAgeMs);
|
||||||
|
await db
|
||||||
|
.deleteFrom("typing_status")
|
||||||
|
.where("chatid", "=", chatId)
|
||||||
|
.where("last_ping", "<", cutoff)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current typing users for a chat, ordered by most recent ping.
|
||||||
|
* The return shape mirrors what the Redis sorted‑set previously emitted.
|
||||||
|
*/
|
||||||
|
export async function getTypingList(
|
||||||
|
chatId: ChatsChatid,
|
||||||
|
): Promise<Array<{ userId: UsersId; username: string }>> {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom("typing_status")
|
||||||
|
.select(["userid as userId", "username"])
|
||||||
|
.where("chatid", "=", chatId)
|
||||||
|
.orderBy("last_ping", "desc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue