progress on sync

This commit is contained in:
Daniel Bulant 2026-04-20 00:01:12 +02:00
parent ba27e07749
commit d4f9a8c2dd
No known key found for this signature in database
13 changed files with 553 additions and 250 deletions

View file

@ -7,4 +7,5 @@ export default defineConfig({
dbCredentials: {
url: process.env.DATABASE_URL!,
},
schemaFilter: ["public"],
});

View file

@ -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",

View file

@ -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,
}),
},

View file

@ -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<typeof db.transaction>[0] extends (
tx: infer T,
) => Promise<any>
? 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<string, string>();
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<string, string>();
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<string, string>();
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),
})),
);

7
api/src/dbos.ts Normal file
View file

@ -0,0 +1,7 @@
import { DBOS } from "@dbos-inc/dbos-sdk";
import "./workflows/sync";
DBOS.setConfig({
name: "itpdp",
systemDatabaseUrl: process.env.DATABASE_URL,
});

View file

@ -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,
});

View file

@ -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,

201
api/src/workflows/sync.ts Normal file
View file

@ -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<Timeline, Artist[]>;
topTracksByTimeline: Record<Timeline, Track[]>;
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<SyncPayload> {
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<Record<Timeline, Artist[]>> {
const sdk = await this.createSdk(userId);
const topArtistsByTimeline = {} as Record<Timeline, Artist[]>;
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<Record<Timeline, Track[]>> {
const sdk = await this.createSdk(userId);
const topTracksByTimeline = {} as Record<Timeline, Track[]>;
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<Artist[]> {
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<SavedAlbum[]> {
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<SavedTrack[]> {
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<PlayHistory[]> {
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");

View file

@ -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,
},
}

View file

@ -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=="],

View file

@ -0,0 +1,22 @@
import { client } from "#/lib/eden";
import { toast } from "sonner";
import { Button } from "./ui/button";
export function SyncButton() {
return (
<Button
onClick={async () => {
toast.promise(
client.api.sync.post().then((data) => console.log(data)),
{
loading: "Syncing...",
success: "Synced!",
error: "Sync failed",
},
);
}}
>
Sync
</Button>
);
}

View file

@ -1,4 +1,4 @@
import { treaty } from "@elysiajs/eden";
import type { App } from "../../../api/src/index";
export const client = treaty<App>("localhost:3000");
export const client = treaty<App>("127.0.0.1:3000", {});

View file

@ -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 (
<main>
<UserInfo />
<SyncButton />
</main>
);
}