From d4f9a8c2ddff86e2bef09862b9eacbf02a4ea817 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Mon, 20 Apr 2026 00:01:12 +0200 Subject: [PATCH] progress on sync --- api/drizzle.config.ts | 1 + api/package.json | 1 + api/src/db/schema.ts | 193 ++++++++++---------- api/src/db/spotify.ts | 275 +++++++++++++++++++---------- api/src/dbos.ts | 7 + api/src/index.ts | 12 +- api/src/routes/sync.ts | 55 +----- api/src/workflows/sync.ts | 201 +++++++++++++++++++++ api/tsconfig.json | 5 +- bun.lock | 27 ++- web/src/components/sync-button.tsx | 22 +++ web/src/lib/eden.ts | 2 +- web/src/routes/index.tsx | 2 + 13 files changed, 553 insertions(+), 250 deletions(-) create mode 100644 api/src/dbos.ts create mode 100644 api/src/workflows/sync.ts create mode 100644 web/src/components/sync-button.tsx diff --git a/api/drizzle.config.ts b/api/drizzle.config.ts index 412a576..ff5cc94 100644 --- a/api/drizzle.config.ts +++ b/api/drizzle.config.ts @@ -7,4 +7,5 @@ export default defineConfig({ dbCredentials: { url: process.env.DATABASE_URL!, }, + schemaFilter: ["public"], }); diff --git a/api/package.json b/api/package.json index fabe084..1eca677 100644 --- a/api/package.json +++ b/api/package.json @@ -14,6 +14,7 @@ "typescript": "^5" }, "dependencies": { + "@dbos-inc/dbos-sdk": "^4.14.6", "@spotify/web-api-ts-sdk": "^1.2.0", "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", "better-auth": "^1.6.5", diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 435f5d8..1806d38 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -15,7 +15,7 @@ import { import { user } from "./auth-schema"; export * from "./auth-schema"; -const platform = pgEnum("platform", ["spotify"]); +export const platform = pgEnum("enum_platform", ["spotify", "apple"]); export const artist = pgTable("artist", { id: uuid().defaultRandom().primaryKey().notNull(), @@ -34,16 +34,16 @@ export const genre = pgTable("genre", { export const artistGenre = pgTable( "artist_genre", { - artist: uuid() + artistId: uuid() .references(() => artist.id) .notNull(), - genre: uuid() + genreId: uuid() .references(() => genre.id) .notNull(), }, (artistGenres) => [ primaryKey({ - columns: [artistGenres.artist, artistGenres.genre], + columns: [artistGenres.artistId, artistGenres.genreId], }), ], ); @@ -66,16 +66,16 @@ export const platformImage = pgTable( export const artistImage = pgTable( "artist_image", { - artist: uuid() + artistId: uuid() .references(() => artist.id) .notNull(), - image: uuid() + imageId: uuid() .references(() => platformImage.id) .notNull(), }, (artistImages) => [ primaryKey({ - columns: [artistImages.artist, artistImages.image], + columns: [artistImages.artistId, artistImages.imageId], }), ], ); @@ -94,16 +94,16 @@ export const album = pgTable("album", { export const albumImage = pgTable( "album_image", { - album: uuid() + albumId: uuid() .references(() => album.id) .notNull(), - image: uuid() + imageId: uuid() .references(() => platformImage.id) .notNull(), }, (albumImage) => [ primaryKey({ - columns: [albumImage.album, albumImage.image], + columns: [albumImage.albumId, albumImage.imageId], }), ], ); @@ -111,16 +111,16 @@ export const albumImage = pgTable( export const albumArtist = pgTable( "album_artist", { - album: uuid() + albumId: uuid() .references(() => album.id) .notNull(), - artist: uuid() + artistId: uuid() .references(() => artist.id) .notNull(), }, (albumArtist) => [ primaryKey({ - columns: [albumArtist.album, albumArtist.artist], + columns: [albumArtist.albumId, albumArtist.artistId], }), ], ); @@ -128,16 +128,16 @@ export const albumArtist = pgTable( export const albumGenre = pgTable( "album_genre", { - album: uuid() + albumId: uuid() .references(() => album.id) .notNull(), - genre: uuid() + genreId: uuid() .references(() => genre.id) .notNull(), }, (albumGenre) => [ primaryKey({ - columns: [albumGenre.album, albumGenre.genre], + columns: [albumGenre.albumId, albumGenre.genreId], }), ], ); @@ -146,7 +146,7 @@ export const track = pgTable( "track", { id: uuid().defaultRandom().primaryKey().notNull(), - album: uuid() + albumId: uuid() .references(() => album.id) .notNull(), name: text(), @@ -158,29 +158,29 @@ export const track = pgTable( disc_number: integer(), track_number: integer(), }, - (track) => [index().on([track.album])], + (track) => [index().on(track.albumId)], ); export const trackArtist = pgTable( "track_artist", { - track: uuid() + trackId: uuid() .references(() => track.id) .notNull(), - artist: uuid() + artistId: uuid() .references(() => artist.id) .notNull(), }, (trackArtist) => [ primaryKey({ - columns: [trackArtist.track, trackArtist.artist], + columns: [trackArtist.trackId, trackArtist.artistId], }), - index().on([trackArtist.track]), - index().on([trackArtist.artist]), + index("track_artist_track_id_idx").on(trackArtist.trackId), + index("track_artist_artist_id_idx").on(trackArtist.artistId), ], ); -const topTimeline = pgEnum("top_timeline", [ +export const topTimeline = pgEnum("top_timeline", [ "short_term", "medium_term", "long_term", @@ -189,96 +189,96 @@ const topTimeline = pgEnum("top_timeline", [ export const topTrack = pgTable( "top_track", { - track: uuid() + trackId: uuid() .references(() => track.id) .notNull(), position: integer().notNull(), - user: text() + userId: text() .references(() => user.id) .notNull(), timeline: topTimeline().notNull(), }, (topTrack) => [ primaryKey({ - columns: [topTrack.track, topTrack.user, topTrack.timeline], + columns: [topTrack.trackId, topTrack.userId, topTrack.timeline], }), - index().on([topTrack.user]), + index().on(topTrack.userId), ], ); export const topArtist = pgTable( "top_artist", { - artist: uuid() + artistId: uuid() .references(() => artist.id) .notNull(), position: integer().notNull(), - user: text() + userId: text() .references(() => user.id) .notNull(), timeline: topTimeline().notNull(), }, (topArtist) => [ primaryKey({ - columns: [topArtist.artist, topArtist.user, topArtist.timeline], + columns: [topArtist.artistId, topArtist.userId, topArtist.timeline], }), - index().on([topArtist.user]), + index().on(topArtist.userId), ], ); export const savedAlbum = pgTable( "saved_album", { - album: uuid() + albumId: uuid() .references(() => album.id) .notNull(), - user: text() + userId: text() .references(() => user.id) .notNull(), saved_at: timestamp("saved_at").defaultNow().notNull(), }, (savedAlbum) => [ primaryKey({ - columns: [savedAlbum.album, savedAlbum.user], + columns: [savedAlbum.albumId, savedAlbum.userId], }), - index().on([savedAlbum.user]), + index().on(savedAlbum.userId), ], ); export const savedTrack = pgTable( "saved_track", { - track: uuid() + trackId: uuid() .references(() => track.id) .notNull(), - user: text() + userId: text() .references(() => user.id) .notNull(), saved_at: timestamp("saved_at").defaultNow().notNull(), }, (savedTrack) => [ primaryKey({ - columns: [savedTrack.track, savedTrack.user], + columns: [savedTrack.trackId, savedTrack.userId], }), - index().on([savedTrack.user]), + index().on(savedTrack.userId), ], ); export const followedArtist = pgTable( "followed_artist", { - artist: uuid() + artistId: uuid() .references(() => artist.id) .notNull(), - user: text() + userId: text() .references(() => user.id) .notNull(), }, (followedArtist) => [ primaryKey({ - columns: [followedArtist.artist, followedArtist.user], + columns: [followedArtist.artistId, followedArtist.userId], }), - index().on([followedArtist.user]), + index().on(followedArtist.userId), ], ); @@ -286,15 +286,15 @@ export const playbackHistory = pgTable( "playback_history", { id: uuid().defaultRandom().primaryKey().notNull(), - track: uuid() + trackId: uuid() .references(() => track.id) .notNull(), - user: text() + userId: text() .references(() => user.id) .notNull(), played_at: timestamp("played_at").defaultNow().notNull(), }, - (playbackHistory) => [index().on([playbackHistory.user])], + (playbackHistory) => [index().on(playbackHistory.userId)], ); export const relations = defineRelations( @@ -326,43 +326,42 @@ export const relations = defineRelations( albumArtists: r.many.albumArtist(), topArtists: r.many.topArtist(), followedArtists: r.many.followedArtist(), - albumGenres: r.many.albumGenre(), genres: r.many.genre({ - from: r.artist.id.through(r.artistGenre.artist), - to: r.genre.id.through(r.artistGenre.genre), + from: r.artist.id.through(r.artistGenre.artistId), + to: r.genre.id.through(r.artistGenre.genreId), }), images: r.many.platformImage({ - from: r.artist.id.through(r.artistImage.artist), - to: r.platformImage.id.through(r.artistImage.image), + from: r.artist.id.through(r.artistImage.artistId), + to: r.platformImage.id.through(r.artistImage.imageId), }), albums: r.many.album({ - from: r.artist.id.through(r.albumArtist.artist), - to: r.album.id.through(r.albumArtist.album), + from: r.artist.id.through(r.albumArtist.artistId), + to: r.album.id.through(r.albumArtist.albumId), }), tracks: r.many.track({ - from: r.artist.id.through(r.trackArtist.artist), - to: r.track.id.through(r.trackArtist.track), + from: r.artist.id.through(r.trackArtist.artistId), + to: r.track.id.through(r.trackArtist.trackId), }), }, genre: { artistGenres: r.many.artistGenre(), albumGenres: r.many.albumGenre(), artists: r.many.artist({ - from: r.genre.id.through(r.artistGenre.genre), - to: r.artist.id.through(r.artistGenre.artist), + from: r.genre.id.through(r.artistGenre.genreId), + to: r.artist.id.through(r.artistGenre.artistId), }), albums: r.many.album({ - from: r.genre.id.through(r.albumGenre.genre), - to: r.album.id.through(r.albumGenre.album), + from: r.genre.id.through(r.albumGenre.genreId), + to: r.album.id.through(r.albumGenre.albumId), }), }, artistGenre: { artist: r.one.artist({ - from: r.artistGenre.artist, + from: r.artistGenre.artistId, to: r.artist.id, }), genre: r.one.genre({ - from: r.artistGenre.genre, + from: r.artistGenre.genreId, to: r.genre.id, }), }, @@ -370,21 +369,21 @@ export const relations = defineRelations( artistImages: r.many.artistImage(), albumImages: r.many.albumImage(), artists: r.many.artist({ - from: r.platformImage.id.through(r.artistImage.image), - to: r.artist.id.through(r.artistImage.artist), + from: r.platformImage.id.through(r.artistImage.imageId), + to: r.artist.id.through(r.artistImage.artistId), }), albums: r.many.album({ - from: r.platformImage.id.through(r.albumImage.image), - to: r.album.id.through(r.albumImage.album), + from: r.platformImage.id.through(r.albumImage.imageId), + to: r.album.id.through(r.albumImage.albumId), }), }, artistImage: { artist: r.one.artist({ - from: r.artistImage.artist, + from: r.artistImage.artistId, to: r.artist.id, }), image: r.one.platformImage({ - from: r.artistImage.image, + from: r.artistImage.imageId, to: r.platformImage.id, }), }, @@ -395,51 +394,51 @@ export const relations = defineRelations( albumGenres: r.many.albumGenre(), savedAlbums: r.many.savedAlbum(), images: r.many.platformImage({ - from: r.album.id.through(r.albumImage.album), - to: r.platformImage.id.through(r.albumImage.image), + from: r.album.id.through(r.albumImage.albumId), + to: r.platformImage.id.through(r.albumImage.imageId), }), artists: r.many.artist({ - from: r.album.id.through(r.albumArtist.album), - to: r.artist.id.through(r.albumArtist.artist), + from: r.album.id.through(r.albumArtist.albumId), + to: r.artist.id.through(r.albumArtist.artistId), }), genres: r.many.genre({ - from: r.album.id.through(r.albumGenre.album), - to: r.genre.id.through(r.albumGenre.genre), + from: r.album.id.through(r.albumGenre.albumId), + to: r.genre.id.through(r.albumGenre.genreId), }), }, albumImage: { album: r.one.album({ - from: r.albumImage.album, + from: r.albumImage.albumId, to: r.album.id, }), image: r.one.platformImage({ - from: r.albumImage.image, + from: r.albumImage.imageId, to: r.platformImage.id, }), }, albumArtist: { album: r.one.album({ - from: r.albumArtist.album, + from: r.albumArtist.albumId, to: r.album.id, }), artist: r.one.artist({ - from: r.albumArtist.artist, + from: r.albumArtist.artistId, to: r.artist.id, }), }, albumGenre: { album: r.one.album({ - from: r.albumGenre.album, + from: r.albumGenre.albumId, to: r.album.id, }), genre: r.one.genre({ - from: r.albumGenre.genre, + from: r.albumGenre.genreId, to: r.genre.id, }), }, track: { album: r.one.album({ - from: r.track.album, + from: r.track.albumId, to: r.album.id, }), trackArtists: r.many.trackArtist(), @@ -447,77 +446,77 @@ export const relations = defineRelations( savedTracks: r.many.savedTrack(), playbackHistory: r.many.playbackHistory(), artists: r.many.artist({ - from: r.track.id.through(r.trackArtist.track), - to: r.artist.id.through(r.trackArtist.artist), + from: r.track.id.through(r.trackArtist.trackId), + to: r.artist.id.through(r.trackArtist.artistId), }), }, trackArtist: { track: r.one.track({ - from: r.trackArtist.track, + from: r.trackArtist.trackId, to: r.track.id, }), artist: r.one.artist({ - from: r.trackArtist.artist, + from: r.trackArtist.artistId, to: r.artist.id, }), }, topTrack: { track: r.one.track({ - from: r.topTrack.track, + from: r.topTrack.trackId, to: r.track.id, }), user: r.one.user({ - from: r.topTrack.user, + from: r.topTrack.userId, to: r.user.id, }), }, topArtist: { artist: r.one.artist({ - from: r.topArtist.artist, + from: r.topArtist.artistId, to: r.artist.id, }), user: r.one.user({ - from: r.topArtist.user, + from: r.topArtist.userId, to: r.user.id, }), }, savedAlbum: { album: r.one.album({ - from: r.savedAlbum.album, + from: r.savedAlbum.albumId, to: r.album.id, }), user: r.one.user({ - from: r.savedAlbum.user, + from: r.savedAlbum.userId, to: r.user.id, }), }, savedTrack: { track: r.one.track({ - from: r.savedTrack.track, + from: r.savedTrack.trackId, to: r.track.id, }), user: r.one.user({ - from: r.savedTrack.user, + from: r.savedTrack.userId, to: r.user.id, }), }, followedArtist: { artist: r.one.artist({ - from: r.followedArtist.artist, + from: r.followedArtist.artistId, to: r.artist.id, }), user: r.one.user({ - from: r.followedArtist.user, + from: r.followedArtist.userId, to: r.user.id, }), }, playbackHistory: { track: r.one.track({ - from: r.playbackHistory.track, + from: r.playbackHistory.trackId, to: r.track.id, }), user: r.one.user({ - from: r.playbackHistory.user, + from: r.playbackHistory.userId, to: r.user.id, }), }, diff --git a/api/src/db/spotify.ts b/api/src/db/spotify.ts index 5616f42..7d9e96d 100644 --- a/api/src/db/spotify.ts +++ b/api/src/db/spotify.ts @@ -1,7 +1,6 @@ import type { Album, Artist, - FollowedArtists, Image, PlayHistory, SavedAlbum, @@ -35,8 +34,16 @@ import { defaultSdk } from "../auth"; export const PLATFORM_SPOTIFY = "spotify" as const; -export async function upsertImages(images: Image[]) { - await db.insert(platformImage).values( +type DbClient = typeof db; +type DbTransaction = Parameters[0] extends ( + tx: infer T, +) => Promise + ? T + : never; +type DbLike = DbClient | DbTransaction; + +export async function upsertImages(images: Image[], dbClient: DbLike = db) { + await dbClient.insert(platformImage).values( images.map(({ url, height, width }) => ({ platform: PLATFORM_SPOTIFY, url, @@ -46,12 +53,12 @@ export async function upsertImages(images: Image[]) { ); } -export async function upsertGenres(genres: string[]) { - await db.insert(genre).values(genres.map((name) => ({ name }))); +export async function upsertGenres(genres: string[], dbClient: DbLike = db) { + await dbClient.insert(genre).values(genres.map((name) => ({ name }))); } -export async function upsertArtists(artists: Artist[]) { - await db.insert(artist).values( +export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) { + await dbClient.insert(artist).values( artists.map(({ id, name, images, genres, popularity, type }) => ({ platform: PLATFORM_SPOTIFY, platform_id: id, @@ -60,14 +67,20 @@ export async function upsertArtists(artists: Artist[]) { type, })), ); - await upsertImages(artists.flatMap((a) => a.images)); - await upsertGenres(artists.flatMap((a) => a.genres)); + await upsertImages( + artists.flatMap((a) => a.images), + dbClient, + ); + await upsertGenres( + artists.flatMap((a) => a.genres), + dbClient, + ); for (const spotifyArtist of artists) { - await db.insert(artistImage).select( - db + await dbClient.insert(artistImage).select( + dbClient .select({ - artist: artist.id, - image: platformImage.id, + artistId: artist.id, + imageId: platformImage.id, }) .from(platformImage) .where( @@ -81,11 +94,11 @@ export async function upsertArtists(artists: Artist[]) { ) .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), ); - await db.insert(artistGenre).select( - db + await dbClient.insert(artistGenre).select( + dbClient .select({ - artist: artist.id, - genre: genre.id, + artistId: artist.id, + genreId: genre.id, }) .from(genre) .where(inArray(genre.name, spotifyArtist.genres)) @@ -94,19 +107,22 @@ export async function upsertArtists(artists: Artist[]) { } } -async function getMissingArtists(artistIds: string[]) { - const existingArtists = await db +async function getMissingArtists(artistIds: string[], dbClient: DbLike = db) { + const existingArtists = await dbClient .select({ id: artist.id }) .from(artist) .where(inArray(artist.platform_id, artistIds)); return artistIds.filter((id) => !existingArtists.some((a) => a.id === id)); } -async function lookupMissingArtists(artistIds: string[]) { - const missingArtistIds = await getMissingArtists(artistIds); +async function lookupMissingArtists( + artistIds: string[], + dbClient: DbLike = db, +) { + const missingArtistIds = await getMissingArtists(artistIds, dbClient); if (missingArtistIds.length === 0) return []; const missingArtists = await defaultSdk.artists.get(missingArtistIds); - await upsertArtists(missingArtists); + await upsertArtists(missingArtists, dbClient); return missingArtists; } @@ -116,51 +132,66 @@ function isFullArtistArray( return "images" in artists[0]!; } -async function upsertMissingArtists(artists: SimplifiedArtist[] | Artist[]) { +async function upsertMissingArtists( + artists: SimplifiedArtist[] | Artist[], + dbClient: DbLike = db, +) { if (artists.length === 0) return; let missingArtists: Artist[]; if (isFullArtistArray(artists)) { - const missingArtistIds = await getMissingArtists(artists.map((t) => t.id)); + const missingArtistIds = await getMissingArtists( + artists.map((t) => t.id), + dbClient, + ); if (missingArtistIds.length === 0) return; missingArtists = artists.filter((a) => missingArtistIds.includes(a.id)); } else { - missingArtists = await lookupMissingArtists(artists.map((t) => t.id)); + missingArtists = await lookupMissingArtists( + artists.map((t) => t.id), + dbClient, + ); } if (missingArtists.length === 0) return; - await upsertArtists(missingArtists); + await upsertArtists(missingArtists, dbClient); return missingArtists; } -async function getArtistIdMap(artistIds: string[]) { +async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) { if (artistIds.length === 0) return new Map(); - const rows = await db + const rows = await dbClient .select({ id: artist.id, platform_id: artist.platform_id }) .from(artist) .where(inArray(artist.platform_id, artistIds)); return new Map(rows.map((row) => [row.platform_id, row.id])); } -async function getAlbumIdMap(albumIds: string[]) { +async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) { if (albumIds.length === 0) return new Map(); - const rows = await db + const rows = await dbClient .select({ id: album.id, platform_id: album.platform_id }) .from(album) .where(inArray(album.platform_id, albumIds)); return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); } -async function getTrackIdMap(trackIds: string[]) { +async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) { if (trackIds.length === 0) return new Map(); - const rows = await db + const rows = await dbClient .select({ id: track.id, platform_id: track.platform_id }) .from(track) .where(inArray(track.platform_id, trackIds)); return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); } -export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) { - await upsertMissingArtists(albums.flatMap((a) => a.artists)); - await db.insert(album).values( +export async function upsertAlbums( + albums: Album[] | SimplifiedAlbum[], + dbClient: DbLike = db, +) { + await upsertMissingArtists( + albums.flatMap((a) => a.artists), + dbClient, + ); + await dbClient.insert(album).values( albums.map(({ id, name, type, popularity, release_date, label }) => ({ platform: PLATFORM_SPOTIFY, platform_id: id, @@ -171,14 +202,20 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) { label, })), ); - await upsertImages(albums.flatMap((a) => a.images)); - await upsertGenres(albums.flatMap((a) => a.genres)); + await upsertImages( + albums.flatMap((a) => a.images), + dbClient, + ); + await upsertGenres( + albums.flatMap((a) => a.genres), + dbClient, + ); for (const spotifyAlbum of albums) { - await db.insert(albumImage).select( - db + await dbClient.insert(albumImage).select( + dbClient .select({ - album: album.id, - image: platformImage.id, + albumId: album.id, + imageId: platformImage.id, }) .from(platformImage) .where( @@ -192,21 +229,26 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) { ) .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), ); - await db.insert(albumArtist).select( - db + await dbClient.insert(albumArtist).select( + dbClient .select({ - album: album.id, - artist: artist.id, + albumId: album.id, + artistId: artist.id, }) .from(artist) - .where(inArray(artist.platform_id, spotifyAlbum.artists.map((t) => t.id))) + .where( + inArray( + artist.platform_id, + spotifyAlbum.artists.map((t) => t.id), + ), + ) .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), ); - await db.insert(albumGenre).select( - db + await dbClient.insert(albumGenre).select( + dbClient .select({ - album: album.id, - genre: genre.id, + albumId: album.id, + genreId: genre.id, }) .from(genre) .where(inArray(genre.name, spotifyAlbum.genres)) @@ -215,14 +257,23 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) { } } -export async function upsertTracks(tracks: Track[]) { +export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) { if (tracks.length === 0) return; - await upsertAlbums(tracks.map((t) => t.album)); - await upsertMissingArtists(tracks.flatMap((t) => t.artists)); - const albumIdMap = await getAlbumIdMap(tracks.map((t) => t.album.id)); - await db.insert(track).values( + await upsertAlbums( + tracks.map((t) => t.album), + dbClient, + ); + await upsertMissingArtists( + tracks.flatMap((t) => t.artists), + dbClient, + ); + const albumIdMap = await getAlbumIdMap( + tracks.map((t) => t.album.id), + dbClient, + ); + await dbClient.insert(track).values( tracks.map((spotifyTrack) => ({ - album: albumIdMap.get(spotifyTrack.album.id)!, + albumId: albumIdMap.get(spotifyTrack.album.id)!, name: spotifyTrack.name, platform: PLATFORM_SPOTIFY, platform_id: spotifyTrack.id, @@ -234,14 +285,19 @@ export async function upsertTracks(tracks: Track[]) { })), ); for (const spotifyTrack of tracks) { - await db.insert(trackArtist).select( - db + await dbClient.insert(trackArtist).select( + dbClient .select({ - track: track.id, - artist: artist.id, + trackId: track.id, + artistId: artist.id, }) .from(artist) - .where(inArray(artist.platform_id, spotifyTrack.artists.map((t) => t.id))) + .where( + inArray( + artist.platform_id, + spotifyTrack.artists.map((t) => t.id), + ), + ) .innerJoin(track, eq(track.platform_id, spotifyTrack.id)), ); } @@ -251,15 +307,19 @@ export async function upsertTopArtists( userId: string, timeline: "short_term" | "medium_term" | "long_term", artists: Artist[], + dbClient: DbLike = db, ) { if (artists.length === 0) return; - await upsertArtists(artists); - const artistIdMap = await getArtistIdMap(artists.map((t) => t.id)); - await db.insert(topArtist).values( + await upsertArtists(artists, dbClient); + const artistIdMap = await getArtistIdMap( + artists.map((t) => t.id), + dbClient, + ); + await dbClient.insert(topArtist).values( artists.map((spotifyArtist, index) => ({ - artist: artistIdMap.get(spotifyArtist.id)!, + artistId: artistIdMap.get(spotifyArtist.id)!, position: index + 1, - user: userId, + userId, timeline, })), ); @@ -269,43 +329,61 @@ export async function upsertTopTracks( userId: string, timeline: "short_term" | "medium_term" | "long_term", tracks: Track[], + dbClient: DbLike = db, ) { if (tracks.length === 0) return; - await upsertTracks(tracks); - const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id)); - await db.insert(topTrack).values( + await upsertTracks(tracks, dbClient); + const trackIdMap = await getTrackIdMap( + tracks.map((t) => t.id), + dbClient, + ); + await dbClient.insert(topTrack).values( tracks.map((spotifyTrack, index) => ({ - track: trackIdMap.get(spotifyTrack.id)!, + trackId: trackIdMap.get(spotifyTrack.id)!, position: index + 1, - user: userId, + userId, timeline, })), ); } -export async function upsertSavedAlbums(userId: string, saved: SavedAlbum[]) { +export async function upsertSavedAlbums( + userId: string, + saved: SavedAlbum[], + dbClient: DbLike = db, +) { if (saved.length === 0) return; const albums = saved.map((item) => item.album); - await upsertAlbums(albums); - const albumIdMap = await getAlbumIdMap(albums.map((t) => t.id)); - await db.insert(savedAlbum).values( + await upsertAlbums(albums, dbClient); + const albumIdMap = await getAlbumIdMap( + albums.map((t) => t.id), + dbClient, + ); + await dbClient.insert(savedAlbum).values( saved.map((item) => ({ - album: albumIdMap.get(item.album.id)!, - user: userId, + albumId: albumIdMap.get(item.album.id)!, + userId, saved_at: new Date(item.added_at), })), ); } -export async function upsertSavedTracks(userId: string, saved: SavedTrack[]) { +export async function upsertSavedTracks( + userId: string, + saved: SavedTrack[], + dbClient: DbLike = db, +) { if (saved.length === 0) return; const tracks = saved.map((item) => item.track); - await upsertTracks(tracks); - const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id)); - await db.insert(savedTrack).values( + await upsertTracks(tracks, dbClient); + const trackIdMap = await getTrackIdMap( + tracks.map((t) => t.id), + dbClient, + ); + await dbClient.insert(savedTrack).values( saved.map((item) => ({ - track: trackIdMap.get(item.track.id)!, - user: userId, + trackId: trackIdMap.get(item.track.id)!, + userId, saved_at: new Date(item.added_at), })), ); @@ -313,16 +391,19 @@ export async function upsertSavedTracks(userId: string, saved: SavedTrack[]) { export async function upsertFollowedArtists( userId: string, - followed: FollowedArtists, + artists: Artist[], + dbClient: DbLike = db, ) { - const artists = followed.artists.items; if (artists.length === 0) return; - await upsertArtists(artists); - const artistIdMap = await getArtistIdMap(artists.map((t) => t.id)); - await db.insert(followedArtist).values( + await upsertArtists(artists, dbClient); + const artistIdMap = await getArtistIdMap( + artists.map((t) => t.id), + dbClient, + ); + await dbClient.insert(followedArtist).values( artists.map((spotifyArtist) => ({ - artist: artistIdMap.get(spotifyArtist.id)!, - user: userId, + artistId: artistIdMap.get(spotifyArtist.id)!, + userId, })), ); } @@ -330,15 +411,19 @@ export async function upsertFollowedArtists( export async function upsertPlaybackHistory( userId: string, items: PlayHistory[], + dbClient: DbLike = db, ) { if (items.length === 0) return; const tracks = items.map((item) => item.track); - await upsertTracks(tracks); - const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id)); - await db.insert(playbackHistory).values( + await upsertTracks(tracks, dbClient); + const trackIdMap = await getTrackIdMap( + tracks.map((t) => t.id), + dbClient, + ); + await dbClient.insert(playbackHistory).values( items.map((item) => ({ - track: trackIdMap.get(item.track.id)!, - user: userId, + trackId: trackIdMap.get(item.track.id)!, + userId, played_at: new Date(item.played_at), })), ); diff --git a/api/src/dbos.ts b/api/src/dbos.ts new file mode 100644 index 0000000..d1155d8 --- /dev/null +++ b/api/src/dbos.ts @@ -0,0 +1,7 @@ +import { DBOS } from "@dbos-inc/dbos-sdk"; +import "./workflows/sync"; + +DBOS.setConfig({ + name: "itpdp", + systemDatabaseUrl: process.env.DATABASE_URL, +}); diff --git a/api/src/index.ts b/api/src/index.ts index 6ffa0f9..2345dc6 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,7 +1,17 @@ import { Elysia, t } from "elysia"; import { betterAuthElysia } from "./auth"; import { syncApp } from "./routes/sync"; +import { DBOS } from "@dbos-inc/dbos-sdk"; +import "./workflows/sync"; +import "./dbos.ts"; -const app = new Elysia().use(betterAuthElysia).use(syncApp).listen(4000); +const app = new Elysia() + .use(betterAuthElysia) + .group("/api", (app) => app.use(syncApp)) + .listen(4000); export type App = typeof app; + +await DBOS.launch({ + conductorKey: process.env.DBOS_CONDUCTOR_KEY, +}); diff --git a/api/src/routes/sync.ts b/api/src/routes/sync.ts index a8ca0d4..0f79195 100644 --- a/api/src/routes/sync.ts +++ b/api/src/routes/sync.ts @@ -1,60 +1,11 @@ import Elysia from "elysia"; -import { auth, betterAuthElysia, SPOTIFY_CLIENT_ID } from "../auth"; -import { SpotifyApi } from "@spotify/web-api-ts-sdk"; -import { - upsertFollowedArtists, - upsertPlaybackHistory, - upsertSavedAlbums, - upsertSavedTracks, - upsertTopArtists, - upsertTopTracks, -} from "../db/spotify"; +import { betterAuthElysia } from "../auth"; +import { spotifySyncWorkflow } from "../workflows/sync"; export const syncApp = new Elysia().use(betterAuthElysia).post( "/sync", async ({ user }) => { - const accessToken = await auth.api.getAccessToken({ - body: { - userId: user.id, - providerId: "spotify", - }, - }); - const sdk = SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { - access_token: accessToken.accessToken, - expires_in: Date.now() - Number(accessToken.accessTokenExpiresAt!), - expires: Number(accessToken.accessTokenExpiresAt), - refresh_token: "", - token_type: "", - }); - for (const timeline of [ - "short_term", - "medium_term", - "long_term", - ] as const) { - const topArtists = await sdk.currentUser.topItems( - "artists", - timeline, - 50, - ); - await upsertTopArtists(user.id, timeline, topArtists.items); - } - for (const timeline of [ - "short_term", - "medium_term", - "long_term", - ] as const) { - const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50); - await upsertTopTracks(user.id, timeline, topTracks.items); - } - const followedArtists = await sdk.currentUser.followedArtists(); - await upsertFollowedArtists(user.id, followedArtists); - const savedAlbums = await sdk.currentUser.albums.savedAlbums(50); - await upsertSavedAlbums(user.id, savedAlbums.items); - const savedTracks = await sdk.currentUser.tracks.savedTracks(50); - await upsertSavedTracks(user.id, savedTracks.items); - const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50); - await upsertPlaybackHistory(user.id, recentlyPlayed.items); - return { ok: true }; + return await spotifySyncWorkflow.syncUser(user.id); }, { auth: true, diff --git a/api/src/workflows/sync.ts b/api/src/workflows/sync.ts new file mode 100644 index 0000000..7f35d37 --- /dev/null +++ b/api/src/workflows/sync.ts @@ -0,0 +1,201 @@ +import { DBOS, ConfiguredInstance } from "@dbos-inc/dbos-sdk"; +import { SpotifyApi } from "@spotify/web-api-ts-sdk"; +import type { + Artist, + PlayHistory, + SavedAlbum, + SavedTrack, + Track, +} from "@spotify/web-api-ts-sdk"; +import { auth, SPOTIFY_CLIENT_ID } from "../auth"; +import { db } from "../db"; +import { + followedArtist, + savedAlbum, + savedTrack, + topArtist, + topTrack, +} from "../db/schema"; +import { + upsertFollowedArtists, + upsertPlaybackHistory, + upsertSavedAlbums, + upsertSavedTracks, + upsertTopArtists, + upsertTopTracks, +} from "../db/spotify"; +import { eq } from "drizzle-orm"; + +const timelines = ["short_term", "medium_term", "long_term"] as const; +type Timeline = (typeof timelines)[number]; + +type SyncPayload = { + topArtistsByTimeline: Record; + topTracksByTimeline: Record; + followedArtists: Artist[]; + savedAlbums: SavedAlbum[]; + savedTracks: SavedTrack[]; + recentlyPlayed: PlayHistory[]; +}; + +export class SpotifySyncWorkflow extends ConfiguredInstance { + constructor(name: string) { + super(name); + } + + @DBOS.workflow() + async syncUser(userId: string) { + console.log("Sync start"); + const data = await this.fetchSpotifyData(userId); + console.log("Sync data fetched"); + await this.persistSpotifyData(userId, data); + console.log("Synced"); + return { ok: true }; + } + + private async fetchSpotifyData(userId: string): Promise { + const topArtistsByTimeline = await this.fetchTopArtists(userId); + const topTracksByTimeline = await this.fetchTopTracks(userId); + const followedArtists = await this.fetchFollowedArtists(userId); + const savedAlbums = await this.fetchSavedAlbums(userId); + const savedTracks = await this.fetchSavedTracks(userId); + const recentlyPlayed = await this.fetchRecentlyPlayed(userId); + return { + topArtistsByTimeline, + topTracksByTimeline, + followedArtists, + savedAlbums, + savedTracks, + recentlyPlayed, + }; + } + + @DBOS.step() + private async persistSpotifyData(userId: string, data: SyncPayload) { + await db.transaction(async (tx) => { + await tx.delete(topArtist).where(eq(topArtist.userId, userId)); + await tx.delete(topTrack).where(eq(topTrack.userId, userId)); + await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId)); + await tx.delete(savedTrack).where(eq(savedTrack.userId, userId)); + await tx.delete(followedArtist).where(eq(followedArtist.userId, userId)); + for (const timeline of timelines) { + await upsertTopArtists( + userId, + timeline, + data.topArtistsByTimeline[timeline], + tx, + ); + } + for (const timeline of timelines) { + await upsertTopTracks( + userId, + timeline, + data.topTracksByTimeline[timeline], + tx, + ); + } + await upsertFollowedArtists(userId, data.followedArtists, tx); + await upsertSavedAlbums(userId, data.savedAlbums, tx); + await upsertSavedTracks(userId, data.savedTracks, tx); + await upsertPlaybackHistory(userId, data.recentlyPlayed, tx); + }); + } + + @DBOS.step() + private async fetchTopArtists( + userId: string, + ): Promise> { + const sdk = await this.createSdk(userId); + const topArtistsByTimeline = {} as Record; + for (const timeline of timelines) { + const topArtists = await sdk.currentUser.topItems( + "artists", + timeline, + 50, + ); + topArtistsByTimeline[timeline] = topArtists.items; + } + return topArtistsByTimeline; + } + + @DBOS.step() + private async fetchTopTracks( + userId: string, + ): Promise> { + const sdk = await this.createSdk(userId); + const topTracksByTimeline = {} as Record; + for (const timeline of timelines) { + const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50); + topTracksByTimeline[timeline] = topTracks.items; + } + return topTracksByTimeline; + } + + @DBOS.step() + private async fetchFollowedArtists(userId: string): Promise { + const sdk = await this.createSdk(userId); + const followed: Artist[] = []; + let after: string | undefined; + while (true) { + const page = await sdk.currentUser.followedArtists(after, 50); + const artists = page.artists; + followed.push(...artists.items); + if (!artists.next) break; + after = artists.next; + } + return followed; + } + + @DBOS.step() + private async fetchSavedAlbums(userId: string): Promise { + const sdk = await this.createSdk(userId); + const saved: SavedAlbum[] = []; + let offset = 0; + while (true) { + const page = await sdk.currentUser.albums.savedAlbums(50, offset); + saved.push(...page.items); + offset += page.items.length; + if (!page.next || offset >= page.total) break; + } + return saved; + } + + @DBOS.step() + private async fetchSavedTracks(userId: string): Promise { + const sdk = await this.createSdk(userId); + const saved: SavedTrack[] = []; + let offset = 0; + while (true) { + const page = await sdk.currentUser.tracks.savedTracks(50, offset); + saved.push(...page.items); + offset += page.items.length; + if (!page.next || offset >= page.total) break; + } + return saved; + } + + @DBOS.step() + private async fetchRecentlyPlayed(userId: string): Promise { + const sdk = await this.createSdk(userId); + const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50); + return recentlyPlayed.items; + } + + private async createSdk(userId: string) { + const accessToken = await auth.api.getAccessToken({ + body: { + userId, + providerId: "spotify", + }, + }); + return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { + access_token: accessToken.accessToken, + expires_in: Date.now() - Number(accessToken.accessTokenExpiresAt!), + expires: Number(accessToken.accessTokenExpiresAt), + refresh_token: "", + token_type: "", + }); + } +} + +export const spotifySyncWorkflow = new SpotifySyncWorkflow("spotify-sync"); diff --git a/api/tsconfig.json b/api/tsconfig.json index bfa0fea..0c3ab69 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -7,6 +7,7 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, + "experimentalDecorators": true, // Bundler mode "moduleResolution": "bundler", @@ -24,6 +25,6 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "noPropertyAccessFromIndexSignature": false, + }, } diff --git a/bun.lock b/bun.lock index 457c5b3..ca26454 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "api": { "name": "api", "dependencies": { + "@dbos-inc/dbos-sdk": "^4.14.6", "@spotify/web-api-ts-sdk": "^1.2.0", "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", "better-auth": "^1.6.5", @@ -207,6 +208,8 @@ "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dbos-inc/dbos-sdk": ["@dbos-inc/dbos-sdk@4.14.6", "", { "dependencies": { "commander": "12.0.0", "pg": "8.11.3", "serialize-error": "8.1.0", "superjson": "^1.13.3", "ws": "^8.18.1", "yaml": "^2.8.3" }, "bin": { "dbos": "dist/src/cli/cli.js", "dbos-sdk": "dist/src/cli/cli.js" } }, "sha512-uf7s8Z1fov3gAd5MLPK4SQX9sCgzJ4uF7W8Z5KZiEhUtCr0SsOJZb96Nse1YpXNohL5Npz1tEkNH9n+ThDlwRA=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.61.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-2OUX4KDKvQA6oa7oESG8eNcV4K/2C5jgrbxUcT0VoH9Zelg6dT+rDYew4w2GmXRV3db0tUaM4QZG3MyJL3fU5Q=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], @@ -705,6 +708,8 @@ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer-writer": ["buffer-writer@2.0.0", "", {}, "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -751,7 +756,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "commander": ["commander@12.0.0", "", {}, "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -765,6 +770,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], @@ -1047,6 +1054,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isbot": ["isbot@5.1.39", "", {}, "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw=="], @@ -1197,6 +1206,8 @@ "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + "packet-reader": ["packet-reader@1.0.0", "", {}, "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], @@ -1343,6 +1354,8 @@ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], + "seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], "seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], @@ -1413,6 +1426,8 @@ "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "superjson": ["superjson@1.13.3", "", { "dependencies": { "copy-anything": "^3.0.2" } }, "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -1461,7 +1476,7 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -1549,6 +1564,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1563,6 +1580,8 @@ "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@dbos-inc/dbos-sdk/pg": ["pg@8.11.3", "", { "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", "pg-connection-string": "^2.6.2", "pg-pool": "^3.6.1", "pg-protocol": "^1.6.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1633,6 +1652,8 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -1651,6 +1672,8 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "shadcn/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/web/src/components/sync-button.tsx b/web/src/components/sync-button.tsx new file mode 100644 index 0000000..85e5856 --- /dev/null +++ b/web/src/components/sync-button.tsx @@ -0,0 +1,22 @@ +import { client } from "#/lib/eden"; +import { toast } from "sonner"; +import { Button } from "./ui/button"; + +export function SyncButton() { + return ( + + ); +} diff --git a/web/src/lib/eden.ts b/web/src/lib/eden.ts index d1db8d4..b840be4 100644 --- a/web/src/lib/eden.ts +++ b/web/src/lib/eden.ts @@ -1,4 +1,4 @@ import { treaty } from "@elysiajs/eden"; import type { App } from "../../../api/src/index"; -export const client = treaty("localhost:3000"); +export const client = treaty("127.0.0.1:3000", {}); diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 511cd90..c9bf3ab 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,3 +1,4 @@ +import { SyncButton } from "#/components/sync-button"; import { UserInfo } from "#/components/user-info"; import { createFileRoute } from "@tanstack/react-router"; @@ -7,6 +8,7 @@ function App() { return (
+
); }