mirror of
https://github.com/SrIzan10/helium.git
synced 2026-06-05 16:46:51 +00:00
feat: move database to postgres
This commit is contained in:
5
app/lib/db/index.ts
Normal file
5
app/lib/db/index.ts
Normal 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
23
app/lib/db/schema.ts
Normal 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(),
|
||||
});
|
||||
@@ -133,7 +133,7 @@ watch(codeRef, (newCode) => {
|
||||
<app-code-input />
|
||||
|
||||
<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 }}
|
||||
</div>
|
||||
<video
|
||||
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal 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!,
|
||||
},
|
||||
});
|
||||
21
drizzle/0000_pale_maximus.sql
Normal file
21
drizzle/0000_pale_maximus.sql
Normal 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;
|
||||
157
drizzle/meta/0000_snapshot.json
Normal file
157
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1765910486192,
|
||||
"tag": "0000_pale_maximus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
10
package.json
10
package.json
@@ -8,15 +8,19 @@
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"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": {
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"@pinia/nuxt": "0.11.2",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"better-auth": "^1.4.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ioredis": "^5.8.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"lucide-vue-next": "^0.548.0",
|
||||
"nuxt": "^4.2.0",
|
||||
"pinia": "^3.0.3",
|
||||
@@ -34,8 +38,10 @@
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@types/node": "^24.9.2",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"nuxi": "^3.29.3",
|
||||
"nuxt-cron": "^1.8.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
1075
pnpm-lock.yaml
generated
1075
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,29 @@
|
||||
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';
|
||||
|
||||
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');
|
||||
const now = Date.now();
|
||||
const inactivePeerIds: string[] = [];
|
||||
// Find inactive peers (not seen in last 30 seconds)
|
||||
const inactivePeers = await db.query.peers.findMany({
|
||||
where: lt(schema.peers.lastSeen, thirtySecondsAgo),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
// ident inactive peers
|
||||
for (const [peerId, timestamp] of Object.entries(activePeers)) {
|
||||
if (now - parseInt(timestamp) > 30000) {
|
||||
inactivePeerIds.push(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (inactivePeerIds.length === 0) {
|
||||
await client.quit();
|
||||
if (inactivePeers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inactivePeerIds = inactivePeers.map((p: { id: string }) => p.id);
|
||||
IS_DEV && console.log('[cron] cleaning up inactive peers:', inactivePeerIds);
|
||||
|
||||
// delete rooms where broadcaster inactive
|
||||
const roomKeys = await client.keys('room:*');
|
||||
for (const key of roomKeys) {
|
||||
const roomData = await client.get(key);
|
||||
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}`);
|
||||
}
|
||||
// Delete inactive peers (cascade will automatically delete their rooms and viewer entries)
|
||||
for (const peerId of inactivePeerIds) {
|
||||
await db.delete(schema.peers).where(eq(schema.peers.id, peerId));
|
||||
IS_DEV && console.log(`[cron] deleted peer ${peerId}`);
|
||||
}
|
||||
|
||||
// remove inactive peers
|
||||
await client.hdel('peers', ...inactivePeerIds);
|
||||
await client.quit();
|
||||
})
|
||||
@@ -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
|
||||
type Peer = Parameters<NonNullable<Parameters<typeof defineWebSocketHandler>[0]['message']>>[0];
|
||||
|
||||
const client = new Redis(process.env.REDIS_URL!);
|
||||
const activePeers = new Map<string, Peer>();
|
||||
|
||||
async function getRoom(roomId: string) {
|
||||
const data = await client.get(`room:${roomId}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
async function addPeer(peerId: string) {
|
||||
await db.insert(schema.peers).values({ id: peerId }).onConflictDoUpdate({
|
||||
target: schema.peers.id,
|
||||
set: { lastSeen: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRoom(roomId: string, data: { broadcaster: string, viewers: string[] }) {
|
||||
await client.set(`room:${roomId}`, JSON.stringify(data));
|
||||
async function removePeer(peerId: string) {
|
||||
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) {
|
||||
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() {
|
||||
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({
|
||||
async open(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);
|
||||
},
|
||||
|
||||
async message(peer, message) {
|
||||
await client.hset('peers', peer.id, Date.now().toString());
|
||||
await updatePeerLastSeen(peer.id);
|
||||
|
||||
// TODO: proper typing
|
||||
const msg = message.json() as any;
|
||||
@@ -43,14 +101,13 @@ export default defineWebSocketHandler({
|
||||
}
|
||||
if (msg.event === 'create-room') {
|
||||
const roomId = generateRoomId();
|
||||
await saveRoom(roomId, { broadcaster: peer.id, viewers: [] });
|
||||
await createRoom(roomId, peer.id);
|
||||
peer.send(JSON.stringify({ event: 'room-created', roomId }));
|
||||
}
|
||||
if (msg.event === 'join-room') {
|
||||
const room = await getRoom(msg.roomId);
|
||||
if (room) {
|
||||
room.viewers.push(peer.id);
|
||||
await saveRoom(msg.roomId, room);
|
||||
await addViewerToRoom(msg.roomId, peer.id);
|
||||
peer.send(JSON.stringify({ event: 'joined', roomId: msg.roomId }));
|
||||
const broadcasterPeer = activePeers.get(room.broadcaster);
|
||||
if (broadcasterPeer) {
|
||||
@@ -95,11 +152,10 @@ export default defineWebSocketHandler({
|
||||
async close(peer, event) {
|
||||
console.log("[ws] close", peer.id, event);
|
||||
activePeers.delete(peer.id);
|
||||
await client.hdel('peers', peer.id);
|
||||
await removePeer(peer.id);
|
||||
|
||||
const roomKeys = await getAllRoomIds();
|
||||
for (const key of roomKeys) {
|
||||
const roomId = key.replace('room:', '');
|
||||
const roomIds = await getAllRoomIds();
|
||||
for (const roomId of roomIds) {
|
||||
const room = await getRoom(roomId);
|
||||
|
||||
if (!room) continue;
|
||||
@@ -116,8 +172,7 @@ export default defineWebSocketHandler({
|
||||
} else {
|
||||
const viewerIndex = room.viewers.indexOf(peer.id);
|
||||
if (viewerIndex !== -1) {
|
||||
room.viewers.splice(viewerIndex, 1);
|
||||
await saveRoom(roomId, room);
|
||||
await removeViewerFromRoom(roomId, peer.id);
|
||||
const broadcasterPeer = activePeers.get(room.broadcaster);
|
||||
if (broadcasterPeer) {
|
||||
broadcasterPeer.send(JSON.stringify({ event: 'viewer-left', viewerId: peer.id }));
|
||||
|
||||
Reference in New Issue
Block a user