format issues + socket

This commit is contained in:
Daniel Bulant 2026-04-21 22:01:53 +02:00
parent 7738645a69
commit f79a9893a1
No known key found for this signature in database
16 changed files with 1984 additions and 1654 deletions

6
api/AGENTS.md Normal file
View file

@ -0,0 +1,6 @@
Run biome and typescript checks after your changes:
```
bun x biome ci
bun x tsc --noEmit
```

View file

@ -1,11 +1,17 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("Missing required env var: DATABASE_URL");
}
export default defineConfig({ export default defineConfig({
out: "./drizzle", out: "./drizzle",
schema: "./src/db/schema.ts", schema: "./src/db/schema.ts",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL!, url: databaseUrl,
}, },
schemaFilter: ["public"], schemaFilter: ["public"],
}); });

View file

@ -5,7 +5,8 @@
"private": true, "private": true,
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"dev": "bun run --watch src/index.ts" "dev": "bun run --watch src/index.ts",
"typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View file

@ -1,12 +1,18 @@
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import Elysia from "elysia";
import { db } from "./db"; import { db } from "./db";
import Elysia, { status, type Context } from "elysia";
import * as schema from "./db/auth-schema"; import * as schema from "./db/auth-schema";
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
export const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID!; const requireEnv = (name: string): string => {
export const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!; const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
};
export const SPOTIFY_CLIENT_ID = requireEnv("SPOTIFY_CLIENT_ID");
export const SPOTIFY_CLIENT_SECRET = requireEnv("SPOTIFY_CLIENT_SECRET");
export const defaultSdk = SpotifyApi.withClientCredentials( export const defaultSdk = SpotifyApi.withClientCredentials(
SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_ID,

View file

@ -1,5 +1,5 @@
import { relations } from "drizzle-orm/_relations"; import { relations } from "drizzle-orm/_relations";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),

View file

@ -1,4 +1,10 @@
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { relations } from "./schema"; import { relations } from "./schema";
export const db = drizzle(process.env.DATABASE_URL!, { relations }); const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("Missing required env var: DATABASE_URL");
}
export const db = drizzle(databaseUrl, { relations });

View file

@ -13,6 +13,7 @@ import {
uuid, uuid,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { user } from "./auth-schema"; import { user } from "./auth-schema";
export * from "./auth-schema"; export * from "./auth-schema";
export const partyStatus = pgEnum("party_status", [ export const partyStatus = pgEnum("party_status", [

View file

@ -9,6 +9,8 @@ import type {
SimplifiedArtist, SimplifiedArtist,
Track, Track,
} from "@spotify/web-api-ts-sdk"; } from "@spotify/web-api-ts-sdk";
import { and, eq, inArray, sql } from "drizzle-orm";
import { defaultSdk } from "../auth";
import { db } from "."; import { db } from ".";
import { import {
album, album,
@ -20,8 +22,8 @@ import {
artistImage, artistImage,
followedArtist, followedArtist,
genre, genre,
playbackHistory,
platformImage, platformImage,
playbackHistory,
savedAlbum, savedAlbum,
savedTrack, savedTrack,
topArtist, topArtist,
@ -29,19 +31,23 @@ import {
track, track,
trackArtist, trackArtist,
} from "./schema"; } from "./schema";
import { and, eq, inArray, sql } from "drizzle-orm";
import { defaultSdk } from "../auth";
export const PLATFORM_SPOTIFY = "spotify" as const; export const PLATFORM_SPOTIFY = "spotify" as const;
type DbClient = typeof db; type DbClient = typeof db;
type DbTransaction = Parameters<typeof db.transaction>[0] extends ( type DbTransaction = Parameters<typeof db.transaction>[0] extends (
tx: infer T, tx: infer T,
) => Promise<any> ) => Promise<unknown>
? T ? T
: never; : never;
type DbLike = DbClient | DbTransaction; type DbLike = DbClient | DbTransaction;
const requireMapEntry = <K, V>(map: Map<K, V>, key: K, label: string): V => {
const value = map.get(key);
if (!value) throw new Error(`Missing ${label} for ${String(key)}`);
return value;
};
export async function upsertImages(images: Image[], dbClient: DbLike = db) { export async function upsertImages(images: Image[], dbClient: DbLike = db) {
await dbClient await dbClient
.insert(platformImage) .insert(platformImage)
@ -66,7 +72,7 @@ export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
await dbClient await dbClient
.insert(artist) .insert(artist)
.values( .values(
artists.map(({ id, name, images, genres, popularity, type }) => ({ artists.map(({ id, name, popularity, type }) => ({
platform: PLATFORM_SPOTIFY, platform: PLATFORM_SPOTIFY,
platform_id: id, platform_id: id,
name, name,
@ -135,7 +141,7 @@ async function lookupMissingArtists(
) { ) {
const missingArtistIds = await getMissingArtists(artistIds, dbClient); const missingArtistIds = await getMissingArtists(artistIds, dbClient);
if (missingArtistIds.length === 0) return []; if (missingArtistIds.length === 0) return [];
let missingArtists: Artist[] = []; const missingArtists: Artist[] = [];
for (let i = 0; i < missingArtistIds.length / 50; i++) { for (let i = 0; i < missingArtistIds.length / 50; i++) {
missingArtists.push( missingArtists.push(
...(await defaultSdk.artists.get( ...(await defaultSdk.artists.get(
@ -150,7 +156,9 @@ async function lookupMissingArtists(
function isFullArtistArray( function isFullArtistArray(
artists: Artist[] | SimplifiedArtist[], artists: Artist[] | SimplifiedArtist[],
): artists is Artist[] { ): artists is Artist[] {
return "images" in artists[0]!; const firstArtist = artists[0];
if (!firstArtist) return false;
return "images" in firstArtist;
} }
async function upsertMissingArtists( async function upsertMissingArtists(
@ -192,7 +200,11 @@ async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) {
.select({ id: album.id, platform_id: album.platform_id }) .select({ id: album.id, platform_id: album.platform_id })
.from(album) .from(album)
.where(inArray(album.platform_id, albumIds)); .where(inArray(album.platform_id, albumIds));
return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); return new Map(
rows
.filter((row) => row.platform_id)
.map((row) => [row.platform_id as string, row.id]),
);
} }
async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) { async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
@ -201,7 +213,11 @@ async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
.select({ id: track.id, platform_id: track.platform_id }) .select({ id: track.id, platform_id: track.platform_id })
.from(track) .from(track)
.where(inArray(track.platform_id, trackIds)); .where(inArray(track.platform_id, trackIds));
return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); return new Map(
rows
.filter((row) => row.platform_id)
.map((row) => [row.platform_id as string, row.id]),
);
} }
export async function upsertAlbums( export async function upsertAlbums(
@ -309,7 +325,7 @@ export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) {
.insert(track) .insert(track)
.values( .values(
tracks.map((spotifyTrack) => ({ tracks.map((spotifyTrack) => ({
albumId: albumIdMap.get(spotifyTrack.album.id)!, albumId: requireMapEntry(albumIdMap, spotifyTrack.album.id, "albumId"),
name: spotifyTrack.name, name: spotifyTrack.name,
platform: PLATFORM_SPOTIFY, platform: PLATFORM_SPOTIFY,
platform_id: spotifyTrack.id, platform_id: spotifyTrack.id,
@ -359,7 +375,7 @@ export async function upsertTopArtists(
.insert(topArtist) .insert(topArtist)
.values( .values(
artists.map((spotifyArtist, index) => ({ artists.map((spotifyArtist, index) => ({
artistId: artistIdMap.get(spotifyArtist.id)!, artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
position: index + 1, position: index + 1,
userId, userId,
timeline, timeline,
@ -384,7 +400,7 @@ export async function upsertTopTracks(
.insert(topTrack) .insert(topTrack)
.values( .values(
tracks.map((spotifyTrack, index) => ({ tracks.map((spotifyTrack, index) => ({
trackId: trackIdMap.get(spotifyTrack.id)!, trackId: requireMapEntry(trackIdMap, spotifyTrack.id, "trackId"),
position: index + 1, position: index + 1,
userId, userId,
timeline, timeline,
@ -409,7 +425,7 @@ export async function upsertSavedAlbums(
.insert(savedAlbum) .insert(savedAlbum)
.values( .values(
saved.map((item) => ({ saved.map((item) => ({
albumId: albumIdMap.get(item.album.id)!, albumId: requireMapEntry(albumIdMap, item.album.id, "albumId"),
userId, userId,
saved_at: new Date(item.added_at), saved_at: new Date(item.added_at),
})), })),
@ -433,7 +449,7 @@ export async function upsertSavedTracks(
.insert(savedTrack) .insert(savedTrack)
.values( .values(
saved.map((item) => ({ saved.map((item) => ({
trackId: trackIdMap.get(item.track.id)!, trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
userId, userId,
saved_at: new Date(item.added_at), saved_at: new Date(item.added_at),
})), })),
@ -456,7 +472,7 @@ export async function upsertFollowedArtists(
.insert(followedArtist) .insert(followedArtist)
.values( .values(
artists.map((spotifyArtist) => ({ artists.map((spotifyArtist) => ({
artistId: artistIdMap.get(spotifyArtist.id)!, artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
userId, userId,
})), })),
) )
@ -479,7 +495,7 @@ export async function upsertPlaybackHistory(
.insert(playbackHistory) .insert(playbackHistory)
.values( .values(
items.map((item) => ({ items.map((item) => ({
trackId: trackIdMap.get(item.track.id)!, trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
userId, userId,
played_at: new Date(item.played_at), played_at: new Date(item.played_at),
})), })),

View file

@ -1,11 +1,11 @@
import { Elysia, t } from "elysia"; import { DBOS } from "@dbos-inc/dbos-sdk";
import { Elysia } from "elysia";
import { betterAuthElysia } from "./auth"; import { betterAuthElysia } from "./auth";
import { syncApp } from "./routes/sync"; import { syncApp } from "./routes/sync";
import { DBOS } from "@dbos-inc/dbos-sdk";
import "./workflows/sync"; import "./workflows/sync";
import "./dbos.ts"; import "./dbos.ts";
import { statsApp } from "./routes/stats.ts";
import { partyApp } from "./routes/party"; import { partyApp } from "./routes/party";
import { statsApp } from "./routes/stats.ts";
const app = new Elysia() const app = new Elysia()
.use(betterAuthElysia) .use(betterAuthElysia)

85
api/src/party-sockets.ts Normal file
View file

@ -0,0 +1,85 @@
type PartySocketEvent = {
type: string;
[key: string]: unknown;
};
type WebSocketLike = {
send: (data: string) => void;
close?: (code?: number, reason?: string) => void;
};
const partySockets = new Map<string, Map<string, Set<WebSocketLike>>>();
function getPartyUserSockets(partyId: string, userId: string) {
const partyMap = partySockets.get(partyId);
if (!partyMap) return null;
return partyMap.get(userId) ?? null;
}
export function registerPartySocket(
partyId: string,
userId: string,
ws: WebSocketLike,
) {
let partyMap = partySockets.get(partyId);
if (!partyMap) {
partyMap = new Map();
partySockets.set(partyId, partyMap);
}
let userSockets = partyMap.get(userId);
if (!userSockets) {
userSockets = new Set();
partyMap.set(userId, userSockets);
}
userSockets.add(ws);
}
export function unregisterPartySocket(
partyId: string,
userId: string,
ws: WebSocketLike,
) {
const partyMap = partySockets.get(partyId);
if (!partyMap) return;
const userSockets = partyMap.get(userId);
if (!userSockets) return;
userSockets.delete(ws);
if (userSockets.size === 0) {
partyMap.delete(userId);
}
if (partyMap.size === 0) {
partySockets.delete(partyId);
}
}
export function broadcastPartyEvent(partyId: string, event: PartySocketEvent) {
const partyMap = partySockets.get(partyId);
if (!partyMap) return;
const payload = JSON.stringify(event);
for (const userSockets of partyMap.values()) {
for (const ws of userSockets) {
ws.send(payload);
}
}
}
export function sendPartyEventToUser(
partyId: string,
userId: string,
event: PartySocketEvent,
) {
const userSockets = getPartyUserSockets(partyId, userId);
if (!userSockets) return;
const payload = JSON.stringify(event);
for (const ws of userSockets) {
ws.send(payload);
}
}

View file

@ -1,8 +1,14 @@
import Elysia, { t } from "elysia";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { betterAuthElysia } from "../auth"; import Elysia, { t } from "elysia";
import { auth, betterAuthElysia } from "../auth";
import { db } from "../db"; import { db } from "../db";
import { party, partyMember } from "../db/schema"; import { party, partyMember } from "../db/schema";
import {
broadcastPartyEvent,
registerPartySocket,
sendPartyEventToUser,
unregisterPartySocket,
} from "../party-sockets";
const PARTY_STATUS = ["created", "started", "ended"] as const; const PARTY_STATUS = ["created", "started", "ended"] as const;
@ -11,11 +17,29 @@ type PartyStatus = (typeof PARTY_STATUS)[number];
type DbClient = typeof db; type DbClient = typeof db;
type DbTransaction = Parameters<typeof db.transaction>[0] extends ( type DbTransaction = Parameters<typeof db.transaction>[0] extends (
tx: infer T, tx: infer T,
) => Promise<any> ) => Promise<unknown>
? T ? T
: never; : never;
type DbLike = DbClient | DbTransaction; type DbLike = DbClient | DbTransaction;
type PartySnapshot = NonNullable<Awaited<ReturnType<typeof getPartyStatus>>>;
type PartySocketMessage =
| {
type: "member_payload";
payload: unknown;
}
| {
type: "ping";
};
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
type PartyWsData = {
user?: { id: string };
partyId?: string;
};
async function getPartyForUser(userId: string) { async function getPartyForUser(userId: string) {
const memberships = await db.query.partyMember.findMany({ const memberships = await db.query.partyMember.findMany({
where: { where: {
@ -63,6 +87,23 @@ async function getPartyStatus(partyId: string) {
}; };
} }
function broadcastSnapshot(partyId: string, snapshot: PartySnapshot | null) {
if (!snapshot) return;
broadcastPartyEvent(partyId, {
type: "party_status",
party: snapshot.party,
members: snapshot.members,
});
}
function getPayloadSize(payload: unknown) {
try {
return JSON.stringify(payload).length;
} catch {
return Infinity;
}
}
async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) { async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
const members = await dbClient.query.partyMember.findMany({ const members = await dbClient.query.partyMember.findMany({
where: { where: {
@ -86,6 +127,7 @@ async function leaveParty(dbClient: DbLike, userId: string) {
joinedAt: "asc", joinedAt: "asc",
}, },
}); });
let newHostId: string | null = null;
if (nextHost) { if (nextHost) {
const currentParty = await dbClient.query.party.findFirst({ const currentParty = await dbClient.query.party.findFirst({
where: { where: {
@ -100,10 +142,14 @@ async function leaveParty(dbClient: DbLike, userId: string) {
lastUpdated: new Date(), lastUpdated: new Date(),
}) })
.where(eq(party.id, member.partyId)); .where(eq(party.id, member.partyId));
newHostId = nextHost.userId;
} }
} }
await cleanupPartyIfEmpty(dbClient, member.partyId); await cleanupPartyIfEmpty(dbClient, member.partyId);
return member.partyId; return {
partyId: member.partyId,
newHostId,
};
} }
function isValidStatus(status: string): status is PartyStatus { function isValidStatus(status: string): status is PartyStatus {
@ -114,6 +160,101 @@ export const partyApp = new Elysia()
.use(betterAuthElysia) .use(betterAuthElysia)
.group("/party", (app) => .group("/party", (app) =>
app app
.ws("/ws", {
beforeHandle: async ({ request, set }) => {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
set.status = 401;
return;
}
return {
user: session.user,
session: session.session,
};
},
open: async (ws) => {
const data = ws.data as unknown as PartyWsData;
const user = data.user;
if (!user) return;
const membership = await getMemberRecord(db, user.id);
if (!membership) {
ws.send(
JSON.stringify({
type: "error",
message: "You are not in a party.",
}),
);
ws.close?.(1008, "Not in a party");
return;
}
const snapshot = await getPartyStatus(membership.partyId);
data.partyId = membership.partyId;
registerPartySocket(membership.partyId, user.id, ws);
if (snapshot) {
ws.send(
JSON.stringify({
type: "snapshot",
party: snapshot.party,
members: snapshot.members,
}),
);
}
},
message: async (ws, message: PartySocketMessage) => {
const data = ws.data as unknown as PartyWsData;
const user = data.user;
if (!user) return;
if (message.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
if (message.type !== "member_payload") return;
const membership = await getMemberRecord(db, user.id);
if (!membership) return;
if (getPayloadSize(message.payload) > MAX_MEMBER_PAYLOAD_SIZE) {
ws.send(
JSON.stringify({
type: "error",
message: "Payload too large.",
}),
);
return;
}
const currentParty = await db.query.party.findFirst({
where: { id: membership.partyId },
});
if (!currentParty) return;
sendPartyEventToUser(membership.partyId, currentParty.hostId, {
type: "member_payload",
fromUserId: user.id,
payload: message.payload,
});
},
close: async (ws) => {
const data = ws.data as unknown as PartyWsData;
const user = data.user;
const { partyId } = data;
if (!user) return;
if (!partyId) {
const membership = await getMemberRecord(db, user.id);
if (!membership) return;
unregisterPartySocket(membership.partyId, user.id, ws);
return;
}
unregisterPartySocket(partyId, user.id, ws);
},
body: t.Union([
t.Object({ type: t.Literal("ping") }),
t.Object({ type: t.Literal("member_payload"), payload: t.Any() }),
]),
})
.get( .get(
"/status", "/status",
async ({ user }) => { async ({ user }) => {
@ -138,9 +279,11 @@ export const partyApp = new Elysia()
return { error: "Target user not found." }; return { error: "Target user not found." };
} }
const { partyId, hostChanged, leaveResult } = await db.transaction(
async (tx) => {
const leaveResult = await leaveParty(tx, user.id);
let partyId: string | null = null; let partyId: string | null = null;
await db.transaction(async (tx) => { let hostChanged = false;
await leaveParty(tx, user.id);
const targetMembership = await getMemberRecord(tx, targetUserId); const targetMembership = await getMemberRecord(tx, targetUserId);
if (targetMembership) { if (targetMembership) {
@ -152,6 +295,7 @@ export const partyApp = new Elysia()
lastUpdated: new Date(), lastUpdated: new Date(),
}) })
.where(eq(party.id, partyId)); .where(eq(party.id, partyId));
hostChanged = true;
} else { } else {
const created = await tx const created = await tx
.insert(party) .insert(party)
@ -160,21 +304,61 @@ export const partyApp = new Elysia()
hostId: targetUserId, hostId: targetUserId,
}) })
.returning({ id: party.id }); .returning({ id: party.id });
partyId = created[0]!.id; const createdId = created[0]?.id ?? null;
if (!createdId) {
return {
partyId: null,
hostChanged,
leaveResult,
};
}
partyId = createdId;
await tx.insert(partyMember).values({ await tx.insert(partyMember).values({
partyId, partyId,
userId: targetUserId, userId: targetUserId,
}); });
} }
if (!partyId) {
return {
partyId: null,
hostChanged,
leaveResult,
};
}
await tx await tx
.insert(partyMember) .insert(partyMember)
.values({ partyId, userId: user.id }) .values({ partyId, userId: user.id })
.onConflictDoNothing(); .onConflictDoNothing();
});
return {
partyId,
hostChanged,
leaveResult,
};
},
);
if (!partyId) return { party: null, members: [] }; if (!partyId) return { party: null, members: [] };
const status = await getPartyStatus(partyId); const status = await getPartyStatus(partyId);
if (leaveResult?.newHostId) {
broadcastPartyEvent(leaveResult.partyId, {
type: "host_changed",
hostId: leaveResult.newHostId,
});
}
if (hostChanged) {
broadcastPartyEvent(partyId, {
type: "host_changed",
hostId: targetUserId,
});
}
broadcastPartyEvent(partyId, {
type: "member_joined",
userId: user.id,
});
broadcastSnapshot(partyId, status);
return status ?? { party: null, members: [] }; return status ?? { party: null, members: [] };
}, },
{ {
@ -187,11 +371,22 @@ export const partyApp = new Elysia()
.post( .post(
"/leave", "/leave",
async ({ user }) => { async ({ user }) => {
const partyId = await db.transaction(async (tx) => { const result = await db.transaction(async (tx) => {
return await leaveParty(tx, user.id); return await leaveParty(tx, user.id);
}); });
if (!partyId) return { party: null, members: [] }; if (!result) return { party: null, members: [] };
const status = await getPartyStatus(partyId); const status = await getPartyStatus(result.partyId);
broadcastPartyEvent(result.partyId, {
type: "member_left",
userId: user.id,
});
if (result.newHostId) {
broadcastPartyEvent(result.partyId, {
type: "host_changed",
hostId: result.newHostId,
});
}
broadcastSnapshot(result.partyId, status);
return status ?? { party: null, members: [] }; return status ?? { party: null, members: [] };
}, },
{ auth: true }, { auth: true },
@ -232,6 +427,12 @@ export const partyApp = new Elysia()
await cleanupPartyIfEmpty(tx, currentMembership.partyId); await cleanupPartyIfEmpty(tx, currentMembership.partyId);
}); });
const status = await getPartyStatus(currentMembership.partyId); const status = await getPartyStatus(currentMembership.partyId);
broadcastPartyEvent(currentMembership.partyId, {
type: "member_left",
userId: body.memberUserId,
kickedBy: user.id,
});
broadcastSnapshot(currentMembership.partyId, status);
return status ?? { party: null, members: [] }; return status ?? { party: null, members: [] };
}, },
{ {
@ -285,6 +486,7 @@ export const partyApp = new Elysia()
}); });
const status = await getPartyStatus(currentMembership.partyId); const status = await getPartyStatus(currentMembership.partyId);
broadcastSnapshot(currentMembership.partyId, status);
return status ?? { party: null, members: [] }; return status ?? { party: null, members: [] };
}, },
{ {

View file

@ -1,5 +1,5 @@
import Elysia from "elysia";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import Elysia from "elysia";
import { betterAuthElysia } from "../auth"; import { betterAuthElysia } from "../auth";
import { db } from "../db"; import { db } from "../db";
import { import {

View file

@ -1,5 +1,4 @@
import { DBOS, ConfiguredInstance } from "@dbos-inc/dbos-sdk"; import { ConfiguredInstance, DBOS } from "@dbos-inc/dbos-sdk";
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
import type { import type {
Artist, Artist,
PlayHistory, PlayHistory,
@ -7,6 +6,8 @@ import type {
SavedTrack, SavedTrack,
Track, Track,
} from "@spotify/web-api-ts-sdk"; } from "@spotify/web-api-ts-sdk";
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
import { eq } from "drizzle-orm";
import { auth, SPOTIFY_CLIENT_ID } from "../auth"; import { auth, SPOTIFY_CLIENT_ID } from "../auth";
import { db } from "../db"; import { db } from "../db";
import { import {
@ -24,7 +25,6 @@ import {
upsertTopArtists, upsertTopArtists,
upsertTopTracks, upsertTopTracks,
} from "../db/spotify"; } from "../db/spotify";
import { eq } from "drizzle-orm";
const timelines = ["short_term", "medium_term", "long_term"] as const; const timelines = ["short_term", "medium_term", "long_term"] as const;
type Timeline = (typeof timelines)[number]; type Timeline = (typeof timelines)[number];
@ -39,10 +39,6 @@ type SyncPayload = {
}; };
export class SpotifySyncWorkflow extends ConfiguredInstance { export class SpotifySyncWorkflow extends ConfiguredInstance {
constructor(name: string) {
super(name);
}
@DBOS.workflow() @DBOS.workflow()
async syncUser(userId: string) { async syncUser(userId: string) {
console.log("Sync start"); console.log("Sync start");
@ -186,7 +182,8 @@ export class SpotifySyncWorkflow extends ConfiguredInstance {
const artists = page.artists; const artists = page.artists;
followed.push(...artists.items); followed.push(...artists.items);
if (!artists.next || artists.items.length === 0) break; if (!artists.next || artists.items.length === 0) break;
after = artists.items[artists.items.length - 1]!.id; const lastArtist = artists.items.at(-1);
after = lastArtist?.id;
} }
return followed; return followed;
} }
@ -235,8 +232,12 @@ export class SpotifySyncWorkflow extends ConfiguredInstance {
}); });
return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, {
access_token: accessToken.accessToken, access_token: accessToken.accessToken,
expires_in: Date.now() - Number(accessToken.accessTokenExpiresAt!), expires_in: accessToken.accessTokenExpiresAt
expires: Number(accessToken.accessTokenExpiresAt), ? Date.now() - Number(accessToken.accessTokenExpiresAt)
: 0,
expires: accessToken.accessTokenExpiresAt
? Number(accessToken.accessTokenExpiresAt)
: 0,
refresh_token: "", refresh_token: "",
token_type: "", token_type: "",
}); });

View file

@ -25,6 +25,6 @@
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false
}, }
} }