feat: move database to postgres

This commit is contained in:
2025-12-20 21:21:10 +00:00
parent 350f88446e
commit 3b03b32046
11 changed files with 1345 additions and 114 deletions

5
app/lib/db/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema';
export const db = drizzle(process.env.DATABASE_URL!, { schema });

23
app/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,23 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const peers = pgTable('peers', {
id: text('id').primaryKey(),
lastSeen: timestamp('last_seen').notNull().defaultNow(),
});
export const rooms = pgTable('rooms', {
id: text('id').primaryKey(),
broadcaster: text('broadcaster').notNull().references(() => peers.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const roomViewers = pgTable('room_viewers', {
id: uuid('id').primaryKey().defaultRandom(),
roomId: text('room_id')
.notNull()
.references(() => rooms.id, { onDelete: 'cascade' }),
viewerId: text('viewer_id')
.notNull()
.references(() => peers.id, { onDelete: 'cascade' }),
joinedAt: timestamp('joined_at').notNull().defaultNow(),
});

View File

@@ -133,7 +133,7 @@ watch(codeRef, (newCode) => {
<app-code-input /> <app-code-input />
<div class="video relative w-full max-w-1/2 aspect-video"> <div class="video relative w-full max-w-1/2 aspect-video">
<div v-if="!isConnected" class="absolute inset-0 bg-black flex items-center justify-center z-10"> <div v-if="!isConnected" class="absolute inset-0 bg-black flex items-center justify-center z-10 text-white">
{{ viewerStore.connectionStatus }} {{ viewerStore.connectionStatus }}
</div> </div>
<video <video

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './app/lib/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View File

@@ -0,0 +1,21 @@
CREATE TABLE "peers" (
"id" text PRIMARY KEY NOT NULL,
"last_seen" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "room_viewers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"room_id" text NOT NULL,
"viewer_id" text NOT NULL,
"joined_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "rooms" (
"id" text PRIMARY KEY NOT NULL,
"broadcaster" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "room_viewers" ADD CONSTRAINT "room_viewers_room_id_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "room_viewers" ADD CONSTRAINT "room_viewers_viewer_id_peers_id_fk" FOREIGN KEY ("viewer_id") REFERENCES "public"."peers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rooms" ADD CONSTRAINT "rooms_broadcaster_peers_id_fk" FOREIGN KEY ("broadcaster") REFERENCES "public"."peers"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,157 @@
{
"id": "bede7229-4dec-4922-b74e-3b2d146e8f85",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.peers": {
"name": "peers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.room_viewers": {
"name": "room_viewers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"viewer_id": {
"name": "viewer_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"joined_at": {
"name": "joined_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"room_viewers_room_id_rooms_id_fk": {
"name": "room_viewers_room_id_rooms_id_fk",
"tableFrom": "room_viewers",
"tableTo": "rooms",
"columnsFrom": [
"room_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"room_viewers_viewer_id_peers_id_fk": {
"name": "room_viewers_viewer_id_peers_id_fk",
"tableFrom": "room_viewers",
"tableTo": "peers",
"columnsFrom": [
"viewer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.rooms": {
"name": "rooms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"broadcaster": {
"name": "broadcaster",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"rooms_broadcaster_peers_id_fk": {
"name": "rooms_broadcaster_peers_id_fk",
"tableFrom": "rooms",
"tableTo": "peers",
"columnsFrom": [
"broadcaster"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1765910486192,
"tag": "0000_pale_maximus",
"breakpoints": true
}
]
}

View File

@@ -8,15 +8,19 @@
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"ui:add": "pnpm dlx shadcn-vue@latest add" "ui:add": "pnpm dlx shadcn-vue@latest add",
"db:migrate": "drizzle-kit generate && drizzle-kit migrate"
}, },
"dependencies": { "dependencies": {
"@neondatabase/serverless": "^1.0.2",
"@pinia/nuxt": "0.11.2", "@pinia/nuxt": "0.11.2",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.0.0",
"better-auth": "^1.4.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ioredis": "^5.8.2", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"lucide-vue-next": "^0.548.0", "lucide-vue-next": "^0.548.0",
"nuxt": "^4.2.0", "nuxt": "^4.2.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
@@ -34,8 +38,10 @@
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/color-mode": "^3.5.2",
"@types/node": "^24.9.2", "@types/node": "^24.9.2",
"drizzle-kit": "^0.31.8",
"nuxi": "^3.29.3", "nuxi": "^3.29.3",
"nuxt-cron": "^1.8.0", "nuxt-cron": "^1.8.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

1075
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,29 @@
import { defineCronHandler } from '#nuxt/cron' import { defineCronHandler } from '#nuxt/cron'
import Redis from 'ioredis'; import { db } from '../../app/lib/db';
import { lt, eq } from 'drizzle-orm';
import * as schema from '../../app/lib/db/schema';
const IS_DEV = process.env.NODE_ENV === 'development'; const IS_DEV = process.env.NODE_ENV === 'development';
export default defineCronHandler(() => '*/5 * * * * *', async () => { export default defineCronHandler(() => '*/5 * * * * *', async () => {
const client = new Redis(process.env.REDIS_URL!); const thirtySecondsAgo = new Date(Date.now() - 30000);
const activePeers = await client.hgetall('peers'); // Find inactive peers (not seen in last 30 seconds)
const now = Date.now(); const inactivePeers = await db.query.peers.findMany({
const inactivePeerIds: string[] = []; where: lt(schema.peers.lastSeen, thirtySecondsAgo),
columns: { id: true },
});
// ident inactive peers if (inactivePeers.length === 0) {
for (const [peerId, timestamp] of Object.entries(activePeers)) {
if (now - parseInt(timestamp) > 30000) {
inactivePeerIds.push(peerId);
}
}
if (inactivePeerIds.length === 0) {
await client.quit();
return; return;
} }
const inactivePeerIds = inactivePeers.map((p: { id: string }) => p.id);
IS_DEV && console.log('[cron] cleaning up inactive peers:', inactivePeerIds); IS_DEV && console.log('[cron] cleaning up inactive peers:', inactivePeerIds);
// delete rooms where broadcaster inactive // Delete inactive peers (cascade will automatically delete their rooms and viewer entries)
const roomKeys = await client.keys('room:*'); for (const peerId of inactivePeerIds) {
for (const key of roomKeys) { await db.delete(schema.peers).where(eq(schema.peers.id, peerId));
const roomData = await client.get(key); IS_DEV && console.log(`[cron] deleted peer ${peerId}`);
if (!roomData) continue;
const room = JSON.parse(roomData);
if (inactivePeerIds.includes(room.broadcaster)) {
await client.del(key);
IS_DEV && console.log(`[cron] deleted room ${key}`);
}
} }
// remove inactive peers
await client.hdel('peers', ...inactivePeerIds);
await client.quit();
}) })

View File

@@ -1,37 +1,95 @@
import Redis from "ioredis"; import { db } from '~/lib/db';
import { eq, and } from 'drizzle-orm';
import * as schema from '~/lib/db/schema';
// thanks nitropack smh // thanks nitropack smh
type Peer = Parameters<NonNullable<Parameters<typeof defineWebSocketHandler>[0]['message']>>[0]; type Peer = Parameters<NonNullable<Parameters<typeof defineWebSocketHandler>[0]['message']>>[0];
const client = new Redis(process.env.REDIS_URL!); async function addPeer(peerId: string) {
const activePeers = new Map<string, Peer>(); await db.insert(schema.peers).values({ id: peerId }).onConflictDoUpdate({
target: schema.peers.id,
async function getRoom(roomId: string) { set: { lastSeen: new Date() },
const data = await client.get(`room:${roomId}`); });
return data ? JSON.parse(data) : null;
} }
async function saveRoom(roomId: string, data: { broadcaster: string, viewers: string[] }) { async function removePeer(peerId: string) {
await client.set(`room:${roomId}`, JSON.stringify(data)); await db.delete(schema.peers).where(eq(schema.peers.id, peerId));
}
async function updatePeerLastSeen(peerId: string) {
await db
.update(schema.peers)
.set({ lastSeen: new Date() })
.where(eq(schema.peers.id, peerId));
}
async function createRoom(roomId: string, broadcasterId: string) {
await db.insert(schema.rooms).values({
id: roomId,
broadcaster: broadcasterId,
});
}
async function getRoom(roomId: string) {
const room = await db.query.rooms.findFirst({
where: eq(schema.rooms.id, roomId),
with: {
viewers: {
columns: { viewerId: true },
},
},
});
if (!room) return null;
return {
id: room.id,
broadcaster: room.broadcaster,
// typescript is a classic
viewers: ((room.viewers ?? []) as { viewerId: string }[]).map(v => v.viewerId),
};
} }
async function deleteRoom(roomId: string) { async function deleteRoom(roomId: string) {
await client.del(`room:${roomId}`); await db.delete(schema.rooms).where(eq(schema.rooms.id, roomId));
}
async function addViewerToRoom(roomId: string, viewerId: string) {
await db.insert(schema.roomViewers).values({
roomId,
viewerId,
});
}
async function removeViewerFromRoom(roomId: string, viewerId: string) {
await db
.delete(schema.roomViewers)
.where(
and(
eq(schema.roomViewers.roomId, roomId),
eq(schema.roomViewers.viewerId, viewerId)
)
);
} }
async function getAllRoomIds() { async function getAllRoomIds() {
return await client.keys('room:*'); const rooms = await db.query.rooms.findMany({
columns: { id: true },
});
return rooms.map(r => r.id);
} }
const activePeers = new Map<string, Peer>();
export default defineWebSocketHandler({ export default defineWebSocketHandler({
async open(peer) { async open(peer) {
activePeers.set(peer.id, peer); activePeers.set(peer.id, peer);
await client.hset('peers', peer.id, Date.now().toString()); await addPeer(peer.id);
console.log('[ws] peer connected', peer.id); console.log('[ws] peer connected', peer.id);
}, },
async message(peer, message) { async message(peer, message) {
await client.hset('peers', peer.id, Date.now().toString()); await updatePeerLastSeen(peer.id);
// TODO: proper typing // TODO: proper typing
const msg = message.json() as any; const msg = message.json() as any;
@@ -43,14 +101,13 @@ export default defineWebSocketHandler({
} }
if (msg.event === 'create-room') { if (msg.event === 'create-room') {
const roomId = generateRoomId(); const roomId = generateRoomId();
await saveRoom(roomId, { broadcaster: peer.id, viewers: [] }); await createRoom(roomId, peer.id);
peer.send(JSON.stringify({ event: 'room-created', roomId })); peer.send(JSON.stringify({ event: 'room-created', roomId }));
} }
if (msg.event === 'join-room') { if (msg.event === 'join-room') {
const room = await getRoom(msg.roomId); const room = await getRoom(msg.roomId);
if (room) { if (room) {
room.viewers.push(peer.id); await addViewerToRoom(msg.roomId, peer.id);
await saveRoom(msg.roomId, room);
peer.send(JSON.stringify({ event: 'joined', roomId: msg.roomId })); peer.send(JSON.stringify({ event: 'joined', roomId: msg.roomId }));
const broadcasterPeer = activePeers.get(room.broadcaster); const broadcasterPeer = activePeers.get(room.broadcaster);
if (broadcasterPeer) { if (broadcasterPeer) {
@@ -95,11 +152,10 @@ export default defineWebSocketHandler({
async close(peer, event) { async close(peer, event) {
console.log("[ws] close", peer.id, event); console.log("[ws] close", peer.id, event);
activePeers.delete(peer.id); activePeers.delete(peer.id);
await client.hdel('peers', peer.id); await removePeer(peer.id);
const roomKeys = await getAllRoomIds(); const roomIds = await getAllRoomIds();
for (const key of roomKeys) { for (const roomId of roomIds) {
const roomId = key.replace('room:', '');
const room = await getRoom(roomId); const room = await getRoom(roomId);
if (!room) continue; if (!room) continue;
@@ -116,8 +172,7 @@ export default defineWebSocketHandler({
} else { } else {
const viewerIndex = room.viewers.indexOf(peer.id); const viewerIndex = room.viewers.indexOf(peer.id);
if (viewerIndex !== -1) { if (viewerIndex !== -1) {
room.viewers.splice(viewerIndex, 1); await removeViewerFromRoom(roomId, peer.id);
await saveRoom(roomId, room);
const broadcasterPeer = activePeers.get(room.broadcaster); const broadcasterPeer = activePeers.get(room.broadcaster);
if (broadcasterPeer) { if (broadcasterPeer) {
broadcasterPeer.send(JSON.stringify({ event: 'viewer-left', viewerId: peer.id })); broadcasterPeer.send(JSON.stringify({ event: 'viewer-left', viewerId: peer.id }));