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: { dbCredentials: {
url: process.env.DATABASE_URL!, url: process.env.DATABASE_URL!,
}, },
schemaFilter: ["public"],
}); });

View file

@ -14,6 +14,7 @@
"typescript": "^5" "typescript": "^5"
}, },
"dependencies": { "dependencies": {
"@dbos-inc/dbos-sdk": "^4.14.6",
"@spotify/web-api-ts-sdk": "^1.2.0", "@spotify/web-api-ts-sdk": "^1.2.0",
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
"better-auth": "^1.6.5", "better-auth": "^1.6.5",

View file

@ -15,7 +15,7 @@ import {
import { user } from "./auth-schema"; import { user } from "./auth-schema";
export * 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", { export const artist = pgTable("artist", {
id: uuid().defaultRandom().primaryKey().notNull(), id: uuid().defaultRandom().primaryKey().notNull(),
@ -34,16 +34,16 @@ export const genre = pgTable("genre", {
export const artistGenre = pgTable( export const artistGenre = pgTable(
"artist_genre", "artist_genre",
{ {
artist: uuid() artistId: uuid()
.references(() => artist.id) .references(() => artist.id)
.notNull(), .notNull(),
genre: uuid() genreId: uuid()
.references(() => genre.id) .references(() => genre.id)
.notNull(), .notNull(),
}, },
(artistGenres) => [ (artistGenres) => [
primaryKey({ primaryKey({
columns: [artistGenres.artist, artistGenres.genre], columns: [artistGenres.artistId, artistGenres.genreId],
}), }),
], ],
); );
@ -66,16 +66,16 @@ export const platformImage = pgTable(
export const artistImage = pgTable( export const artistImage = pgTable(
"artist_image", "artist_image",
{ {
artist: uuid() artistId: uuid()
.references(() => artist.id) .references(() => artist.id)
.notNull(), .notNull(),
image: uuid() imageId: uuid()
.references(() => platformImage.id) .references(() => platformImage.id)
.notNull(), .notNull(),
}, },
(artistImages) => [ (artistImages) => [
primaryKey({ primaryKey({
columns: [artistImages.artist, artistImages.image], columns: [artistImages.artistId, artistImages.imageId],
}), }),
], ],
); );
@ -94,16 +94,16 @@ export const album = pgTable("album", {
export const albumImage = pgTable( export const albumImage = pgTable(
"album_image", "album_image",
{ {
album: uuid() albumId: uuid()
.references(() => album.id) .references(() => album.id)
.notNull(), .notNull(),
image: uuid() imageId: uuid()
.references(() => platformImage.id) .references(() => platformImage.id)
.notNull(), .notNull(),
}, },
(albumImage) => [ (albumImage) => [
primaryKey({ primaryKey({
columns: [albumImage.album, albumImage.image], columns: [albumImage.albumId, albumImage.imageId],
}), }),
], ],
); );
@ -111,16 +111,16 @@ export const albumImage = pgTable(
export const albumArtist = pgTable( export const albumArtist = pgTable(
"album_artist", "album_artist",
{ {
album: uuid() albumId: uuid()
.references(() => album.id) .references(() => album.id)
.notNull(), .notNull(),
artist: uuid() artistId: uuid()
.references(() => artist.id) .references(() => artist.id)
.notNull(), .notNull(),
}, },
(albumArtist) => [ (albumArtist) => [
primaryKey({ primaryKey({
columns: [albumArtist.album, albumArtist.artist], columns: [albumArtist.albumId, albumArtist.artistId],
}), }),
], ],
); );
@ -128,16 +128,16 @@ export const albumArtist = pgTable(
export const albumGenre = pgTable( export const albumGenre = pgTable(
"album_genre", "album_genre",
{ {
album: uuid() albumId: uuid()
.references(() => album.id) .references(() => album.id)
.notNull(), .notNull(),
genre: uuid() genreId: uuid()
.references(() => genre.id) .references(() => genre.id)
.notNull(), .notNull(),
}, },
(albumGenre) => [ (albumGenre) => [
primaryKey({ primaryKey({
columns: [albumGenre.album, albumGenre.genre], columns: [albumGenre.albumId, albumGenre.genreId],
}), }),
], ],
); );
@ -146,7 +146,7 @@ export const track = pgTable(
"track", "track",
{ {
id: uuid().defaultRandom().primaryKey().notNull(), id: uuid().defaultRandom().primaryKey().notNull(),
album: uuid() albumId: uuid()
.references(() => album.id) .references(() => album.id)
.notNull(), .notNull(),
name: text(), name: text(),
@ -158,29 +158,29 @@ export const track = pgTable(
disc_number: integer(), disc_number: integer(),
track_number: integer(), track_number: integer(),
}, },
(track) => [index().on([track.album])], (track) => [index().on(track.albumId)],
); );
export const trackArtist = pgTable( export const trackArtist = pgTable(
"track_artist", "track_artist",
{ {
track: uuid() trackId: uuid()
.references(() => track.id) .references(() => track.id)
.notNull(), .notNull(),
artist: uuid() artistId: uuid()
.references(() => artist.id) .references(() => artist.id)
.notNull(), .notNull(),
}, },
(trackArtist) => [ (trackArtist) => [
primaryKey({ primaryKey({
columns: [trackArtist.track, trackArtist.artist], columns: [trackArtist.trackId, trackArtist.artistId],
}), }),
index().on([trackArtist.track]), index("track_artist_track_id_idx").on(trackArtist.trackId),
index().on([trackArtist.artist]), index("track_artist_artist_id_idx").on(trackArtist.artistId),
], ],
); );
const topTimeline = pgEnum("top_timeline", [ export const topTimeline = pgEnum("top_timeline", [
"short_term", "short_term",
"medium_term", "medium_term",
"long_term", "long_term",
@ -189,96 +189,96 @@ const topTimeline = pgEnum("top_timeline", [
export const topTrack = pgTable( export const topTrack = pgTable(
"top_track", "top_track",
{ {
track: uuid() trackId: uuid()
.references(() => track.id) .references(() => track.id)
.notNull(), .notNull(),
position: integer().notNull(), position: integer().notNull(),
user: text() userId: text()
.references(() => user.id) .references(() => user.id)
.notNull(), .notNull(),
timeline: topTimeline().notNull(), timeline: topTimeline().notNull(),
}, },
(topTrack) => [ (topTrack) => [
primaryKey({ 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( export const topArtist = pgTable(
"top_artist", "top_artist",
{ {
artist: uuid() artistId: uuid()
.references(() => artist.id) .references(() => artist.id)
.notNull(), .notNull(),
position: integer().notNull(), position: integer().notNull(),
user: text() userId: text()
.references(() => user.id) .references(() => user.id)
.notNull(), .notNull(),
timeline: topTimeline().notNull(), timeline: topTimeline().notNull(),
}, },
(topArtist) => [ (topArtist) => [
primaryKey({ 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( export const savedAlbum = pgTable(
"saved_album", "saved_album",
{ {
album: uuid() albumId: uuid()
.references(() => album.id) .references(() => album.id)
.notNull(), .notNull(),
user: text() userId: text()
.references(() => user.id) .references(() => user.id)
.notNull(), .notNull(),
saved_at: timestamp("saved_at").defaultNow().notNull(), saved_at: timestamp("saved_at").defaultNow().notNull(),
}, },
(savedAlbum) => [ (savedAlbum) => [
primaryKey({ primaryKey({
columns: [savedAlbum.album, savedAlbum.user], columns: [savedAlbum.albumId, savedAlbum.userId],
}), }),
index().on([savedAlbum.user]), index().on(savedAlbum.userId),
], ],
); );
export const savedTrack = pgTable( export const savedTrack = pgTable(
"saved_track", "saved_track",
{ {
track: uuid() trackId: uuid()
.references(() => track.id) .references(() => track.id)
.notNull(), .notNull(),
user: text() userId: text()
.references(() => user.id) .references(() => user.id)
.notNull(), .notNull(),
saved_at: timestamp("saved_at").defaultNow().notNull(), saved_at: timestamp("saved_at").defaultNow().notNull(),
}, },
(savedTrack) => [ (savedTrack) => [
primaryKey({ primaryKey({
columns: [savedTrack.track, savedTrack.user], columns: [savedTrack.trackId, savedTrack.userId],
}), }),
index().on([savedTrack.user]), index().on(savedTrack.userId),
], ],
); );
export const followedArtist = pgTable( export const followedArtist = pgTable(
"followed_artist", "followed_artist",
{ {
artist: uuid() artistId: uuid()
.references(() => artist.id) .references(() => artist.id)
.notNull(), .notNull(),
user: text() userId: text()
.references(() => user.id) .references(() => user.id)
.notNull(), .notNull(),
}, },
(followedArtist) => [ (followedArtist) => [
primaryKey({ 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", "playback_history",
{ {
id: uuid().defaultRandom().primaryKey().notNull(), id: uuid().defaultRandom().primaryKey().notNull(),
track: uuid() trackId: uuid()
.references(() => track.id) .references(() => track.id)
.notNull(), .notNull(),
user: text() userId: text()
.references(() => user.id) .references(() => user.id)
.notNull(), .notNull(),
played_at: timestamp("played_at").defaultNow().notNull(), played_at: timestamp("played_at").defaultNow().notNull(),
}, },
(playbackHistory) => [index().on([playbackHistory.user])], (playbackHistory) => [index().on(playbackHistory.userId)],
); );
export const relations = defineRelations( export const relations = defineRelations(
@ -326,43 +326,42 @@ export const relations = defineRelations(
albumArtists: r.many.albumArtist(), albumArtists: r.many.albumArtist(),
topArtists: r.many.topArtist(), topArtists: r.many.topArtist(),
followedArtists: r.many.followedArtist(), followedArtists: r.many.followedArtist(),
albumGenres: r.many.albumGenre(),
genres: r.many.genre({ genres: r.many.genre({
from: r.artist.id.through(r.artistGenre.artist), from: r.artist.id.through(r.artistGenre.artistId),
to: r.genre.id.through(r.artistGenre.genre), to: r.genre.id.through(r.artistGenre.genreId),
}), }),
images: r.many.platformImage({ images: r.many.platformImage({
from: r.artist.id.through(r.artistImage.artist), from: r.artist.id.through(r.artistImage.artistId),
to: r.platformImage.id.through(r.artistImage.image), to: r.platformImage.id.through(r.artistImage.imageId),
}), }),
albums: r.many.album({ albums: r.many.album({
from: r.artist.id.through(r.albumArtist.artist), from: r.artist.id.through(r.albumArtist.artistId),
to: r.album.id.through(r.albumArtist.album), to: r.album.id.through(r.albumArtist.albumId),
}), }),
tracks: r.many.track({ tracks: r.many.track({
from: r.artist.id.through(r.trackArtist.artist), from: r.artist.id.through(r.trackArtist.artistId),
to: r.track.id.through(r.trackArtist.track), to: r.track.id.through(r.trackArtist.trackId),
}), }),
}, },
genre: { genre: {
artistGenres: r.many.artistGenre(), artistGenres: r.many.artistGenre(),
albumGenres: r.many.albumGenre(), albumGenres: r.many.albumGenre(),
artists: r.many.artist({ artists: r.many.artist({
from: r.genre.id.through(r.artistGenre.genre), from: r.genre.id.through(r.artistGenre.genreId),
to: r.artist.id.through(r.artistGenre.artist), to: r.artist.id.through(r.artistGenre.artistId),
}), }),
albums: r.many.album({ albums: r.many.album({
from: r.genre.id.through(r.albumGenre.genre), from: r.genre.id.through(r.albumGenre.genreId),
to: r.album.id.through(r.albumGenre.album), to: r.album.id.through(r.albumGenre.albumId),
}), }),
}, },
artistGenre: { artistGenre: {
artist: r.one.artist({ artist: r.one.artist({
from: r.artistGenre.artist, from: r.artistGenre.artistId,
to: r.artist.id, to: r.artist.id,
}), }),
genre: r.one.genre({ genre: r.one.genre({
from: r.artistGenre.genre, from: r.artistGenre.genreId,
to: r.genre.id, to: r.genre.id,
}), }),
}, },
@ -370,21 +369,21 @@ export const relations = defineRelations(
artistImages: r.many.artistImage(), artistImages: r.many.artistImage(),
albumImages: r.many.albumImage(), albumImages: r.many.albumImage(),
artists: r.many.artist({ artists: r.many.artist({
from: r.platformImage.id.through(r.artistImage.image), from: r.platformImage.id.through(r.artistImage.imageId),
to: r.artist.id.through(r.artistImage.artist), to: r.artist.id.through(r.artistImage.artistId),
}), }),
albums: r.many.album({ albums: r.many.album({
from: r.platformImage.id.through(r.albumImage.image), from: r.platformImage.id.through(r.albumImage.imageId),
to: r.album.id.through(r.albumImage.album), to: r.album.id.through(r.albumImage.albumId),
}), }),
}, },
artistImage: { artistImage: {
artist: r.one.artist({ artist: r.one.artist({
from: r.artistImage.artist, from: r.artistImage.artistId,
to: r.artist.id, to: r.artist.id,
}), }),
image: r.one.platformImage({ image: r.one.platformImage({
from: r.artistImage.image, from: r.artistImage.imageId,
to: r.platformImage.id, to: r.platformImage.id,
}), }),
}, },
@ -395,51 +394,51 @@ export const relations = defineRelations(
albumGenres: r.many.albumGenre(), albumGenres: r.many.albumGenre(),
savedAlbums: r.many.savedAlbum(), savedAlbums: r.many.savedAlbum(),
images: r.many.platformImage({ images: r.many.platformImage({
from: r.album.id.through(r.albumImage.album), from: r.album.id.through(r.albumImage.albumId),
to: r.platformImage.id.through(r.albumImage.image), to: r.platformImage.id.through(r.albumImage.imageId),
}), }),
artists: r.many.artist({ artists: r.many.artist({
from: r.album.id.through(r.albumArtist.album), from: r.album.id.through(r.albumArtist.albumId),
to: r.artist.id.through(r.albumArtist.artist), to: r.artist.id.through(r.albumArtist.artistId),
}), }),
genres: r.many.genre({ genres: r.many.genre({
from: r.album.id.through(r.albumGenre.album), from: r.album.id.through(r.albumGenre.albumId),
to: r.genre.id.through(r.albumGenre.genre), to: r.genre.id.through(r.albumGenre.genreId),
}), }),
}, },
albumImage: { albumImage: {
album: r.one.album({ album: r.one.album({
from: r.albumImage.album, from: r.albumImage.albumId,
to: r.album.id, to: r.album.id,
}), }),
image: r.one.platformImage({ image: r.one.platformImage({
from: r.albumImage.image, from: r.albumImage.imageId,
to: r.platformImage.id, to: r.platformImage.id,
}), }),
}, },
albumArtist: { albumArtist: {
album: r.one.album({ album: r.one.album({
from: r.albumArtist.album, from: r.albumArtist.albumId,
to: r.album.id, to: r.album.id,
}), }),
artist: r.one.artist({ artist: r.one.artist({
from: r.albumArtist.artist, from: r.albumArtist.artistId,
to: r.artist.id, to: r.artist.id,
}), }),
}, },
albumGenre: { albumGenre: {
album: r.one.album({ album: r.one.album({
from: r.albumGenre.album, from: r.albumGenre.albumId,
to: r.album.id, to: r.album.id,
}), }),
genre: r.one.genre({ genre: r.one.genre({
from: r.albumGenre.genre, from: r.albumGenre.genreId,
to: r.genre.id, to: r.genre.id,
}), }),
}, },
track: { track: {
album: r.one.album({ album: r.one.album({
from: r.track.album, from: r.track.albumId,
to: r.album.id, to: r.album.id,
}), }),
trackArtists: r.many.trackArtist(), trackArtists: r.many.trackArtist(),
@ -447,77 +446,77 @@ export const relations = defineRelations(
savedTracks: r.many.savedTrack(), savedTracks: r.many.savedTrack(),
playbackHistory: r.many.playbackHistory(), playbackHistory: r.many.playbackHistory(),
artists: r.many.artist({ artists: r.many.artist({
from: r.track.id.through(r.trackArtist.track), from: r.track.id.through(r.trackArtist.trackId),
to: r.artist.id.through(r.trackArtist.artist), to: r.artist.id.through(r.trackArtist.artistId),
}), }),
}, },
trackArtist: { trackArtist: {
track: r.one.track({ track: r.one.track({
from: r.trackArtist.track, from: r.trackArtist.trackId,
to: r.track.id, to: r.track.id,
}), }),
artist: r.one.artist({ artist: r.one.artist({
from: r.trackArtist.artist, from: r.trackArtist.artistId,
to: r.artist.id, to: r.artist.id,
}), }),
}, },
topTrack: { topTrack: {
track: r.one.track({ track: r.one.track({
from: r.topTrack.track, from: r.topTrack.trackId,
to: r.track.id, to: r.track.id,
}), }),
user: r.one.user({ user: r.one.user({
from: r.topTrack.user, from: r.topTrack.userId,
to: r.user.id, to: r.user.id,
}), }),
}, },
topArtist: { topArtist: {
artist: r.one.artist({ artist: r.one.artist({
from: r.topArtist.artist, from: r.topArtist.artistId,
to: r.artist.id, to: r.artist.id,
}), }),
user: r.one.user({ user: r.one.user({
from: r.topArtist.user, from: r.topArtist.userId,
to: r.user.id, to: r.user.id,
}), }),
}, },
savedAlbum: { savedAlbum: {
album: r.one.album({ album: r.one.album({
from: r.savedAlbum.album, from: r.savedAlbum.albumId,
to: r.album.id, to: r.album.id,
}), }),
user: r.one.user({ user: r.one.user({
from: r.savedAlbum.user, from: r.savedAlbum.userId,
to: r.user.id, to: r.user.id,
}), }),
}, },
savedTrack: { savedTrack: {
track: r.one.track({ track: r.one.track({
from: r.savedTrack.track, from: r.savedTrack.trackId,
to: r.track.id, to: r.track.id,
}), }),
user: r.one.user({ user: r.one.user({
from: r.savedTrack.user, from: r.savedTrack.userId,
to: r.user.id, to: r.user.id,
}), }),
}, },
followedArtist: { followedArtist: {
artist: r.one.artist({ artist: r.one.artist({
from: r.followedArtist.artist, from: r.followedArtist.artistId,
to: r.artist.id, to: r.artist.id,
}), }),
user: r.one.user({ user: r.one.user({
from: r.followedArtist.user, from: r.followedArtist.userId,
to: r.user.id, to: r.user.id,
}), }),
}, },
playbackHistory: { playbackHistory: {
track: r.one.track({ track: r.one.track({
from: r.playbackHistory.track, from: r.playbackHistory.trackId,
to: r.track.id, to: r.track.id,
}), }),
user: r.one.user({ user: r.one.user({
from: r.playbackHistory.user, from: r.playbackHistory.userId,
to: r.user.id, to: r.user.id,
}), }),
}, },

View file

@ -1,7 +1,6 @@
import type { import type {
Album, Album,
Artist, Artist,
FollowedArtists,
Image, Image,
PlayHistory, PlayHistory,
SavedAlbum, SavedAlbum,
@ -35,8 +34,16 @@ import { defaultSdk } from "../auth";
export const PLATFORM_SPOTIFY = "spotify" as const; export const PLATFORM_SPOTIFY = "spotify" as const;
export async function upsertImages(images: Image[]) { type DbClient = typeof db;
await db.insert(platformImage).values( 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 }) => ({ images.map(({ url, height, width }) => ({
platform: PLATFORM_SPOTIFY, platform: PLATFORM_SPOTIFY,
url, url,
@ -46,12 +53,12 @@ export async function upsertImages(images: Image[]) {
); );
} }
export async function upsertGenres(genres: string[]) { export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
await db.insert(genre).values(genres.map((name) => ({ name }))); await dbClient.insert(genre).values(genres.map((name) => ({ name })));
} }
export async function upsertArtists(artists: Artist[]) { export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
await db.insert(artist).values( await dbClient.insert(artist).values(
artists.map(({ id, name, images, genres, popularity, type }) => ({ artists.map(({ id, name, images, genres, popularity, type }) => ({
platform: PLATFORM_SPOTIFY, platform: PLATFORM_SPOTIFY,
platform_id: id, platform_id: id,
@ -60,14 +67,20 @@ export async function upsertArtists(artists: Artist[]) {
type, type,
})), })),
); );
await upsertImages(artists.flatMap((a) => a.images)); await upsertImages(
await upsertGenres(artists.flatMap((a) => a.genres)); artists.flatMap((a) => a.images),
dbClient,
);
await upsertGenres(
artists.flatMap((a) => a.genres),
dbClient,
);
for (const spotifyArtist of artists) { for (const spotifyArtist of artists) {
await db.insert(artistImage).select( await dbClient.insert(artistImage).select(
db dbClient
.select({ .select({
artist: artist.id, artistId: artist.id,
image: platformImage.id, imageId: platformImage.id,
}) })
.from(platformImage) .from(platformImage)
.where( .where(
@ -81,11 +94,11 @@ export async function upsertArtists(artists: Artist[]) {
) )
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)), .innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
); );
await db.insert(artistGenre).select( await dbClient.insert(artistGenre).select(
db dbClient
.select({ .select({
artist: artist.id, artistId: artist.id,
genre: genre.id, genreId: genre.id,
}) })
.from(genre) .from(genre)
.where(inArray(genre.name, spotifyArtist.genres)) .where(inArray(genre.name, spotifyArtist.genres))
@ -94,19 +107,22 @@ export async function upsertArtists(artists: Artist[]) {
} }
} }
async function getMissingArtists(artistIds: string[]) { async function getMissingArtists(artistIds: string[], dbClient: DbLike = db) {
const existingArtists = await db const existingArtists = await dbClient
.select({ id: artist.id }) .select({ id: artist.id })
.from(artist) .from(artist)
.where(inArray(artist.platform_id, artistIds)); .where(inArray(artist.platform_id, artistIds));
return artistIds.filter((id) => !existingArtists.some((a) => a.id === id)); return artistIds.filter((id) => !existingArtists.some((a) => a.id === id));
} }
async function lookupMissingArtists(artistIds: string[]) { async function lookupMissingArtists(
const missingArtistIds = await getMissingArtists(artistIds); artistIds: string[],
dbClient: DbLike = db,
) {
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
if (missingArtistIds.length === 0) return []; if (missingArtistIds.length === 0) return [];
const missingArtists = await defaultSdk.artists.get(missingArtistIds); const missingArtists = await defaultSdk.artists.get(missingArtistIds);
await upsertArtists(missingArtists); await upsertArtists(missingArtists, dbClient);
return missingArtists; return missingArtists;
} }
@ -116,51 +132,66 @@ function isFullArtistArray(
return "images" in artists[0]!; 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; if (artists.length === 0) return;
let missingArtists: Artist[]; let missingArtists: Artist[];
if (isFullArtistArray(artists)) { 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; if (missingArtistIds.length === 0) return;
missingArtists = artists.filter((a) => missingArtistIds.includes(a.id)); missingArtists = artists.filter((a) => missingArtistIds.includes(a.id));
} else { } else {
missingArtists = await lookupMissingArtists(artists.map((t) => t.id)); missingArtists = await lookupMissingArtists(
artists.map((t) => t.id),
dbClient,
);
} }
if (missingArtists.length === 0) return; if (missingArtists.length === 0) return;
await upsertArtists(missingArtists); await upsertArtists(missingArtists, dbClient);
return missingArtists; return missingArtists;
} }
async function getArtistIdMap(artistIds: string[]) { async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) {
if (artistIds.length === 0) return new Map<string, string>(); 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 }) .select({ id: artist.id, platform_id: artist.platform_id })
.from(artist) .from(artist)
.where(inArray(artist.platform_id, artistIds)); .where(inArray(artist.platform_id, artistIds));
return new Map(rows.map((row) => [row.platform_id, row.id])); 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>(); 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 }) .select({ id: album.id, platform_id: album.platform_id })
.from(album) .from(album)
.where(inArray(album.platform_id, albumIds)); .where(inArray(album.platform_id, albumIds));
return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); return new Map(rows.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>(); 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 }) .select({ id: track.id, platform_id: track.platform_id })
.from(track) .from(track)
.where(inArray(track.platform_id, trackIds)); .where(inArray(track.platform_id, trackIds));
return new Map(rows.map((row) => [row.platform_id ?? "", row.id])); return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
} }
export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) { export async function upsertAlbums(
await upsertMissingArtists(albums.flatMap((a) => a.artists)); albums: Album[] | SimplifiedAlbum[],
await db.insert(album).values( 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 }) => ({ albums.map(({ id, name, type, popularity, release_date, label }) => ({
platform: PLATFORM_SPOTIFY, platform: PLATFORM_SPOTIFY,
platform_id: id, platform_id: id,
@ -171,14 +202,20 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) {
label, label,
})), })),
); );
await upsertImages(albums.flatMap((a) => a.images)); await upsertImages(
await upsertGenres(albums.flatMap((a) => a.genres)); albums.flatMap((a) => a.images),
dbClient,
);
await upsertGenres(
albums.flatMap((a) => a.genres),
dbClient,
);
for (const spotifyAlbum of albums) { for (const spotifyAlbum of albums) {
await db.insert(albumImage).select( await dbClient.insert(albumImage).select(
db dbClient
.select({ .select({
album: album.id, albumId: album.id,
image: platformImage.id, imageId: platformImage.id,
}) })
.from(platformImage) .from(platformImage)
.where( .where(
@ -192,21 +229,26 @@ export async function upsertAlbums(albums: Album[] | SimplifiedAlbum[]) {
) )
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)), .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
); );
await db.insert(albumArtist).select( await dbClient.insert(albumArtist).select(
db dbClient
.select({ .select({
album: album.id, albumId: album.id,
artist: artist.id, artistId: artist.id,
}) })
.from(artist) .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)), .innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
); );
await db.insert(albumGenre).select( await dbClient.insert(albumGenre).select(
db dbClient
.select({ .select({
album: album.id, albumId: album.id,
genre: genre.id, genreId: genre.id,
}) })
.from(genre) .from(genre)
.where(inArray(genre.name, spotifyAlbum.genres)) .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; if (tracks.length === 0) return;
await upsertAlbums(tracks.map((t) => t.album)); await upsertAlbums(
await upsertMissingArtists(tracks.flatMap((t) => t.artists)); tracks.map((t) => t.album),
const albumIdMap = await getAlbumIdMap(tracks.map((t) => t.album.id)); dbClient,
await db.insert(track).values( );
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) => ({ tracks.map((spotifyTrack) => ({
album: albumIdMap.get(spotifyTrack.album.id)!, albumId: albumIdMap.get(spotifyTrack.album.id)!,
name: spotifyTrack.name, name: spotifyTrack.name,
platform: PLATFORM_SPOTIFY, platform: PLATFORM_SPOTIFY,
platform_id: spotifyTrack.id, platform_id: spotifyTrack.id,
@ -234,14 +285,19 @@ export async function upsertTracks(tracks: Track[]) {
})), })),
); );
for (const spotifyTrack of tracks) { for (const spotifyTrack of tracks) {
await db.insert(trackArtist).select( await dbClient.insert(trackArtist).select(
db dbClient
.select({ .select({
track: track.id, trackId: track.id,
artist: artist.id, artistId: artist.id,
}) })
.from(artist) .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)), .innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
); );
} }
@ -251,15 +307,19 @@ export async function upsertTopArtists(
userId: string, userId: string,
timeline: "short_term" | "medium_term" | "long_term", timeline: "short_term" | "medium_term" | "long_term",
artists: Artist[], artists: Artist[],
dbClient: DbLike = db,
) { ) {
if (artists.length === 0) return; if (artists.length === 0) return;
await upsertArtists(artists); await upsertArtists(artists, dbClient);
const artistIdMap = await getArtistIdMap(artists.map((t) => t.id)); const artistIdMap = await getArtistIdMap(
await db.insert(topArtist).values( artists.map((t) => t.id),
dbClient,
);
await dbClient.insert(topArtist).values(
artists.map((spotifyArtist, index) => ({ artists.map((spotifyArtist, index) => ({
artist: artistIdMap.get(spotifyArtist.id)!, artistId: artistIdMap.get(spotifyArtist.id)!,
position: index + 1, position: index + 1,
user: userId, userId,
timeline, timeline,
})), })),
); );
@ -269,43 +329,61 @@ export async function upsertTopTracks(
userId: string, userId: string,
timeline: "short_term" | "medium_term" | "long_term", timeline: "short_term" | "medium_term" | "long_term",
tracks: Track[], tracks: Track[],
dbClient: DbLike = db,
) { ) {
if (tracks.length === 0) return; if (tracks.length === 0) return;
await upsertTracks(tracks); await upsertTracks(tracks, dbClient);
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id)); const trackIdMap = await getTrackIdMap(
await db.insert(topTrack).values( tracks.map((t) => t.id),
dbClient,
);
await dbClient.insert(topTrack).values(
tracks.map((spotifyTrack, index) => ({ tracks.map((spotifyTrack, index) => ({
track: trackIdMap.get(spotifyTrack.id)!, trackId: trackIdMap.get(spotifyTrack.id)!,
position: index + 1, position: index + 1,
user: userId, userId,
timeline, 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; if (saved.length === 0) return;
const albums = saved.map((item) => item.album); const albums = saved.map((item) => item.album);
await upsertAlbums(albums); await upsertAlbums(albums, dbClient);
const albumIdMap = await getAlbumIdMap(albums.map((t) => t.id)); const albumIdMap = await getAlbumIdMap(
await db.insert(savedAlbum).values( albums.map((t) => t.id),
dbClient,
);
await dbClient.insert(savedAlbum).values(
saved.map((item) => ({ saved.map((item) => ({
album: albumIdMap.get(item.album.id)!, albumId: albumIdMap.get(item.album.id)!,
user: userId, userId,
saved_at: new Date(item.added_at), 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; if (saved.length === 0) return;
const tracks = saved.map((item) => item.track); const tracks = saved.map((item) => item.track);
await upsertTracks(tracks); await upsertTracks(tracks, dbClient);
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id)); const trackIdMap = await getTrackIdMap(
await db.insert(savedTrack).values( tracks.map((t) => t.id),
dbClient,
);
await dbClient.insert(savedTrack).values(
saved.map((item) => ({ saved.map((item) => ({
track: trackIdMap.get(item.track.id)!, trackId: trackIdMap.get(item.track.id)!,
user: userId, userId,
saved_at: new Date(item.added_at), saved_at: new Date(item.added_at),
})), })),
); );
@ -313,16 +391,19 @@ export async function upsertSavedTracks(userId: string, saved: SavedTrack[]) {
export async function upsertFollowedArtists( export async function upsertFollowedArtists(
userId: string, userId: string,
followed: FollowedArtists, artists: Artist[],
dbClient: DbLike = db,
) { ) {
const artists = followed.artists.items;
if (artists.length === 0) return; if (artists.length === 0) return;
await upsertArtists(artists); await upsertArtists(artists, dbClient);
const artistIdMap = await getArtistIdMap(artists.map((t) => t.id)); const artistIdMap = await getArtistIdMap(
await db.insert(followedArtist).values( artists.map((t) => t.id),
dbClient,
);
await dbClient.insert(followedArtist).values(
artists.map((spotifyArtist) => ({ artists.map((spotifyArtist) => ({
artist: artistIdMap.get(spotifyArtist.id)!, artistId: artistIdMap.get(spotifyArtist.id)!,
user: userId, userId,
})), })),
); );
} }
@ -330,15 +411,19 @@ export async function upsertFollowedArtists(
export async function upsertPlaybackHistory( export async function upsertPlaybackHistory(
userId: string, userId: string,
items: PlayHistory[], items: PlayHistory[],
dbClient: DbLike = db,
) { ) {
if (items.length === 0) return; if (items.length === 0) return;
const tracks = items.map((item) => item.track); const tracks = items.map((item) => item.track);
await upsertTracks(tracks); await upsertTracks(tracks, dbClient);
const trackIdMap = await getTrackIdMap(tracks.map((t) => t.id)); const trackIdMap = await getTrackIdMap(
await db.insert(playbackHistory).values( tracks.map((t) => t.id),
dbClient,
);
await dbClient.insert(playbackHistory).values(
items.map((item) => ({ items.map((item) => ({
track: trackIdMap.get(item.track.id)!, trackId: trackIdMap.get(item.track.id)!,
user: userId, userId,
played_at: new Date(item.played_at), 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 { Elysia, t } from "elysia";
import { betterAuthElysia } from "./auth"; import { betterAuthElysia } from "./auth";
import { syncApp } from "./routes/sync"; import { syncApp } from "./routes/sync";
import { DBOS } from "@dbos-inc/dbos-sdk";
import "./workflows/sync";
import "./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; 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 Elysia from "elysia";
import { auth, betterAuthElysia, SPOTIFY_CLIENT_ID } from "../auth"; import { betterAuthElysia } from "../auth";
import { SpotifyApi } from "@spotify/web-api-ts-sdk"; import { spotifySyncWorkflow } from "../workflows/sync";
import {
upsertFollowedArtists,
upsertPlaybackHistory,
upsertSavedAlbums,
upsertSavedTracks,
upsertTopArtists,
upsertTopTracks,
} from "../db/spotify";
export const syncApp = new Elysia().use(betterAuthElysia).post( export const syncApp = new Elysia().use(betterAuthElysia).post(
"/sync", "/sync",
async ({ user }) => { async ({ user }) => {
const accessToken = await auth.api.getAccessToken({ return await spotifySyncWorkflow.syncUser(user.id);
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 };
}, },
{ {
auth: true, 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", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
"experimentalDecorators": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
@ -24,6 +25,6 @@
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false,
} },
} }

View file

@ -15,6 +15,7 @@
"api": { "api": {
"name": "api", "name": "api",
"dependencies": { "dependencies": {
"@dbos-inc/dbos-sdk": "^4.14.6",
"@spotify/web-api-ts-sdk": "^1.2.0", "@spotify/web-api-ts-sdk": "^1.2.0",
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js", "@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
"better-auth": "^1.6.5", "better-auth": "^1.6.5",
@ -207,6 +208,8 @@
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@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=="], "@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=="], "@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": ["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=="], "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=="], "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=="], "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=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
@ -765,6 +770,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "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=="], "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=="], "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-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=="], "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=="], "isbot": ["isbot@5.1.39", "", {}, "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw=="],
@ -1197,6 +1206,8 @@
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], "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=="], "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=="], "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=="], "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": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="],
"seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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=="], "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/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=="], "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 { treaty } from "@elysiajs/eden";
import type { App } from "../../../api/src/index"; 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 { UserInfo } from "#/components/user-info";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
@ -7,6 +8,7 @@ function App() {
return ( return (
<main> <main>
<UserInfo /> <UserInfo />
<SyncButton />
</main> </main>
); );
} }