diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 14f8e53..2301476 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -6,6 +6,7 @@ on: pull_request: branches: - main + - drizzle-v1 jobs: biome: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 76bac50..d31ea3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: push: branches: - main + - drizzle-v1 paths-ignore: - 'docs/**' workflow_dispatch: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1a47965..d7dce40 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Overview -**Bedstack (Stripped)** is a _distilled version_ of [the full Bedstack architecture](https://github.com/bedtime-coders/bedstack/blob/main/ARCHITECTURE.md). It keeps the _feature-sliced, modular structure_ but simplifies the layering for _rapid prototyping_. +**Bedstack (Stripped)** is a _distilled version_ of [the full Bedstack architecture](https://github.com/bedtime-coders/bedstack/blob/drizzle-v1/ARCHITECTURE.md). It keeps the _feature-sliced, modular structure_ but simplifies the layering for _rapid prototyping_. Each feature is self-contained and designed for clarity, fast development, and maintainability - without the overhead of full enterprise layering. @@ -109,7 +109,7 @@ drizzle/ # Migrations, reset, seed ### See Also -- [Bedstack Full Architecture](https://github.com/bedtime-coders/bedstack/blob/main/ARCHITECTURE.md) +- [Bedstack Full Architecture](https://github.com/bedtime-coders/bedstack/blob/drizzle-v1/ARCHITECTURE.md) - [ElysiaJS Docs](https://elysiajs.com/docs) - [Drizzle ORM Docs](https://orm.drizzle.team/docs) - [TypeBox Docs](https://typebox.io/docs) diff --git a/README.md b/README.md index c7033a2..63c6833 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,22 @@ Logo for Bedstack RealWorld example

Bedstack (Stripped)

-[![Tests Status](https://github.com/bedtime-coders/bedstack-stripped/actions/workflows/tests.yml/badge.svg?event=push&branch=main&)](https://github.com/bedtime-coders/bedstac/actions/workflows/tests.yml?query=branch%3Amain+event%3Apush) [![Discord](https://img.shields.io/discord/1164270344115335320?label=Chat&color=5865f4&logo=discord&labelColor=121214)](https://discord.gg/8UcP9QB5AV) [![License](https://custom-icon-badges.demolab.com/github/license/bedtime-coders/bedstack-stripped?label=License&color=blue&logo=law&labelColor=0d1117)](https://github.com/bedtime-coders/bedstack-stripped/blob/main/LICENSE) [![Bun](https://img.shields.io/badge/Bun-14151a?logo=bun&logoColor=fbf0df)](https://bun.sh/) [![ElysiaJS](https://custom-icon-badges.demolab.com/badge/ElysiaJS-0f172b.svg?logo=elysia)](https://elysiajs.com/) [![Drizzle](https://img.shields.io/badge/Drizzle-C5F74F?logo=drizzle&logoColor=000)](https://drizzle.team/) [![Biome](https://img.shields.io/badge/Biome-24272f?logo=biome&logoColor=f6f6f9)](https://biomejs.dev/) [![Scalar](https://img.shields.io/badge/Scalar-080808?logo=scalar&logoColor=e7e7e7)](https://scalar.com/) [![Star](https://custom-icon-badges.demolab.com/github/stars/bedtime-coders/bedstack-stripped?logo=star&logoColor=373737&label=Star)](https://github.com/bedtime-coders/bedstack-stripped/stargazers/) +[![Tests Status](https://github.com/bedtime-coders/bedstack-stripped/actions/workflows/tests.yml/badge.svg?event=push&branch=drizzle-v1&)](https://github.com/bedtime-coders/bedstac/actions/workflows/tests.yml?query=branch%drizzle-v1+event%3Apush) [![Discord](https://img.shields.io/discord/1164270344115335320?label=Chat&color=5865f4&logo=discord&labelColor=121214)](https://discord.gg/8UcP9QB5AV) [![License](https://custom-icon-badges.demolab.com/github/license/bedtime-coders/bedstack-stripped?label=License&color=blue&logo=law&labelColor=0d1117)](https://github.com/bedtime-coders/bedstack-stripped/blob/drizzle-v1/LICENSE) [![Bun](https://img.shields.io/badge/Bun-14151a?logo=bun&logoColor=fbf0df)](https://bun.sh/) [![ElysiaJS](https://custom-icon-badges.demolab.com/badge/ElysiaJS-0f172b.svg?logo=elysia)](https://elysiajs.com/) [![Drizzle](https://img.shields.io/badge/Drizzle-C5F74F?logo=drizzle&logoColor=000)](https://drizzle.team/) [![Biome](https://img.shields.io/badge/Biome-24272f?logo=biome&logoColor=f6f6f9)](https://biomejs.dev/) [![Scalar](https://img.shields.io/badge/Scalar-080808?logo=scalar&logoColor=e7e7e7)](https://scalar.com/) [![Star](https://custom-icon-badges.demolab.com/github/stars/bedtime-coders/bedstack-stripped?logo=star&logoColor=373737&label=Star)](https://github.com/bedtime-coders/bedstack-stripped/stargazers/) ⚡ Stripped version of [Bedstack](https://github.com/bedtime-coders/bedstack) for rapid prototyping +> [!IMPORTANT] +> You are viewing the **`drizzle-v1`** branch of _Bedstack (Stripped)_, where we use [Drizzle ORM v1, currently in beta](https://orm.drizzle.team/roadmap). Click [here](https://github.com/bedtime-coders/bedstack-stripped/tree/main) to view the main branch. + +## Drizzle ORM v1 & Relational API v2 + +This branch uses [Drizzle ORM v1](https://orm.drizzle.team/roadmap), which is currently in beta. The main feature from Drizzle ORM v1 that we use here is Relational API v2 (often referred to as rqbv2). + +- GitHub Discussion to learn more about Relational API v2: https://github.com/drizzle-team/drizzle-orm/discussions/2316. +- Drizzle website page to track the release of Drizzle ORM v1: https://orm.drizzle.team/roadmap. + ## Bedstack: Bun + ElysiaJS + Drizzle Stack **Bedstack** is a collection of bleeding-edge technologies to build modern web applications. diff --git a/scripts/db/reset.ts b/scripts/db/reset.ts index c0a3e4c..cc610a6 100644 --- a/scripts/db/reset.ts +++ b/scripts/db/reset.ts @@ -1,22 +1,35 @@ +import { $ } from "bun"; import chalk from "chalk"; -import { drizzle } from "drizzle-orm/bun-sql"; -import { reset } from "drizzle-seed"; -import * as articlesSchema from "@/articles/articles.schema"; -import * as commentsSchema from "@/comments/comments.schema"; -import { env } from "@/core/env"; -import * as profilesSchema from "@/profiles/profiles.schema"; -import * as tagsSchema from "@/tags/tags.schema"; -import * as usersSchema from "@/users/users.schema"; - -const schema = { - ...usersSchema, - ...profilesSchema, - ...tagsSchema, - ...articlesSchema, - ...commentsSchema, -}; +import { sql } from "drizzle-orm"; +import { db } from "@/core/database"; console.info(chalk.gray("Resetting database")); -// See: https://github.com/drizzle-team/drizzle-orm/issues/3599 -await reset(drizzle(env.DATABASE_URL), schema); +const query = sql` + -- Delete all tables + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP + EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + + -- Delete enums + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (select t.typname as enum_name + from pg_type t + join pg_enum e on t.oid = e.enumtypid + join pg_catalog.pg_namespace n ON n.oid = t.typnamespace + where n.nspname = current_schema()) LOOP + EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.enum_name); + END LOOP; + END $$; + `; + +await db.execute(query); + +// Push the schema to the database +await $`bun run db:push`.quiet(); console.info(`[${chalk.green("✓")}] Database reset complete`); diff --git a/src/articles/articles.plugin.ts b/src/articles/articles.plugin.ts index 4a10bd1..5ae6575 100644 --- a/src/articles/articles.plugin.ts +++ b/src/articles/articles.plugin.ts @@ -1,8 +1,7 @@ -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Elysia, NotFoundError, t } from "elysia"; import { StatusCodes } from "http-status-codes"; import { db } from "@/core/database/db"; -import { follows } from "@/profiles/profiles.schema"; import { DEFAULT_LIMIT, DEFAULT_OFFSET } from "@/shared/constants"; import { RealWorldError } from "@/shared/errors"; import { auth } from "@/shared/plugins"; @@ -29,86 +28,43 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) }, auth: { currentUserId }, }) => { - const [authorUser, favoritedUser] = await Promise.all([ - authorUsername - ? db.query.users.findFirst({ - // where: eq(users.username, authorUsername), - // relational queries v2: - where: { - username: authorUsername, - }, - }) - : undefined, - favoritedByUsername - ? db.query.users.findFirst({ - where: { - username: favoritedByUsername, - }, - }) - : undefined, - ]); - - if ( - (authorUsername && !authorUser) || - (favoritedByUsername && !favoritedUser) - ) { - return toArticlesResponse([]); - } - - // Build dynamic filters - const filters = []; - if (authorUser) { - filters.push(eq(articles.authorId, authorUser.id)); - } - if (favoritedUser) { - filters.push( - inArray( - articles.id, - db - .select({ articleId: favorites.articleId }) - .from(favorites) - .where(eq(favorites.userId, favoritedUser.id)), - ), - ); - } - if (tagName) { - filters.push( - inArray( - articles.id, - db - .select({ articleId: articlesToTags.articleId }) - .from(articlesToTags) - .innerJoin(tags, eq(tags.id, articlesToTags.tagId)) - .where(eq(tags.name, tagName)), - ), - ); - } - const enrichedArticles = await db.query.articles.findMany({ where: { - RAW: filters.length > 0 ? and(...filters) : undefined, + ...(authorUsername && { + author: { + username: authorUsername, + }, + }), + ...(favoritedByUsername && { + favorites: { + user: { + username: favoritedByUsername, + }, + }, + }), + ...(tagName && { + tags: { + name: tagName, + }, + }), }, with: { - author: currentUserId - ? { - with: { - followers: { - where: eq(follows.followerId, currentUserId), - }, + author: { + with: { + followers: { + where: { + id: currentUserId, }, - } - : true, - tags: { with: { tag: true } }, + }, + }, + }, + tags: true, favorites: true, }, - // orderBy: [desc(articles.createdAt)], - orderBy: { - createdAt: "desc", - }, + orderBy: { createdAt: "desc" }, limit, offset, }); - return toArticlesResponse(enrichedArticles, { currentUserId, }); @@ -129,16 +85,16 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) const enrichedArticle = await db.query.articles.findFirst({ where: { slug }, with: { - author: currentUserId - ? { - with: { - followers: { - where: eq(follows.followerId, currentUserId), - }, + author: { + with: { + followers: { + where: { + id: currentUserId, }, - } - : true, - tags: { with: { tag: true } }, + }, + }, + }, + tags: true, }, }); @@ -170,27 +126,25 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) query: { limit = DEFAULT_LIMIT, offset = DEFAULT_OFFSET }, auth: { currentUserId }, }) => { - // Get followed user IDs - const followed = await db - .select({ followedId: follows.followedId }) - .from(follows) - .where(eq(follows.followerId, currentUserId)); - - const followedIds = followed.map((f) => f.followedId); - if (followedIds.length === 0) return toArticlesResponse([]); - - // Get articles from followed authors const enrichedArticles = await db.query.articles.findMany({ - where: inArray(articles.authorId, followedIds), + where: { + author: { + followers: { + id: currentUserId, + }, + }, + }, with: { author: { with: { followers: { - where: eq(follows.followerId, currentUserId), + where: { + id: currentUserId, + }, }, }, }, - tags: { with: { tag: true } }, + tags: true, favorites: true, }, orderBy: { @@ -199,7 +153,6 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) limit, offset, }); - return toArticlesResponse(enrichedArticles, { currentUserId }); }, { @@ -225,7 +178,11 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) .onConflictDoNothing(); const relevantTags = await db.query.tags.findMany({ - where: inArray(tags.name, tagList), + where: { + name: { + in: tagList, + }, + }, }); const [createdArticle] = await db @@ -255,7 +212,7 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) where: { id: createdArticle.id }, with: { author: true, - tags: { with: { tag: true } }, + tags: true, }, }); @@ -333,7 +290,11 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) // Get all relevant tags in one query const relevantTags = await db.query.tags.findMany({ - where: inArray(tags.name, article.tagList), + where: { + name: { + in: article.tagList, + }, + }, }); // Connect tags to article @@ -353,11 +314,13 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) author: { with: { followers: { - where: eq(follows.followerId, currentUserId), + where: { + id: currentUserId, + }, }, }, }, - tags: { with: { tag: true } }, + tags: true, favorites: true, // Load all favorites to get count }, }); @@ -427,11 +390,13 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) author: { with: { followers: { - where: eq(follows.followerId, currentUserId), + where: { + id: currentUserId, + }, }, }, }, - tags: { with: { tag: true } }, + tags: true, favorites: true, // Load all favorites to get count }, }); @@ -458,11 +423,13 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) author: { with: { followers: { - where: eq(follows.followerId, currentUserId), + where: { + id: currentUserId, + }, }, }, }, - tags: { with: { tag: true } }, + tags: true, favorites: true, }, }); @@ -492,11 +459,13 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) author: { with: { followers: { - where: eq(follows.followerId, currentUserId), + where: { + id: currentUserId, + }, }, }, }, - tags: { with: { tag: true } }, + tags: true, favorites: true, // Load all favorites to get count }, }); @@ -527,11 +496,13 @@ export const articlesPlugin = new Elysia({ tags: ["Articles"] }) author: { with: { followers: { - where: eq(follows.followerId, currentUserId), + where: { + id: currentUserId, + }, }, }, }, - tags: { with: { tag: true } }, + tags: true, favorites: true, }, }); diff --git a/src/articles/interfaces/enriched-article.interface.ts b/src/articles/interfaces/enriched-article.interface.ts index 23b81fb..d73b095 100644 --- a/src/articles/interfaces/enriched-article.interface.ts +++ b/src/articles/interfaces/enriched-article.interface.ts @@ -1,6 +1,5 @@ import type { InferSelectModel } from "drizzle-orm"; -import type { follows } from "@/profiles/profiles.schema"; -import type { articlesToTags, tags } from "@/tags/tags.schema"; +import type { tags } from "@/tags/tags.schema"; import type { users } from "@/users/users.schema"; import type { articles, favorites } from "../articles.schema"; @@ -9,13 +8,9 @@ import type { articles, favorites } from "../articles.schema"; */ export type EnrichedArticle = InferSelectModel & { author: InferSelectModel & { - followers?: Array>; + followers?: Array>; }; - tags: Array< - InferSelectModel & { - tag: InferSelectModel; - } - >; + tags: Array>; favorites?: Array>; }; @@ -24,7 +19,7 @@ export type EnrichedArticle = InferSelectModel & { */ export type PersonalizedEnrichedArticle = EnrichedArticle & { author: InferSelectModel & { - followers?: Array>; + followers?: Array>; }; favorites: Array>; }; diff --git a/src/articles/mappers/to-articles-response.ts b/src/articles/mappers/to-articles-response.ts index fd6e819..bb6812c 100644 --- a/src/articles/mappers/to-articles-response.ts +++ b/src/articles/mappers/to-articles-response.ts @@ -14,8 +14,7 @@ export function toArticlesResponse( const myFavorites = article.favorites?.filter((f) => f.userId === currentUserId) ?? []; const myFollows = - article.author.followers?.filter((f) => f.followerId === currentUserId) ?? - []; + article.author.followers?.filter((f) => f.id === currentUserId) ?? []; const favoritesCount = article.favorites?.length ?? 0; const isFavorited = myFavorites.length > 0; const isFollowing = myFollows.length > 0; @@ -24,7 +23,7 @@ export function toArticlesResponse( title: article.title, description: article.description, tagList: article.tags - .map((t) => t.tag.name) + .map((t) => t.name) .sort((a, b) => a.localeCompare(b)), createdAt: article.createdAt.toISOString(), updatedAt: article.updatedAt.toISOString(), diff --git a/src/articles/mappers/to-response.ts b/src/articles/mappers/to-response.ts index b6e64a0..a3f384c 100644 --- a/src/articles/mappers/to-response.ts +++ b/src/articles/mappers/to-response.ts @@ -39,7 +39,7 @@ export function toResponse( const favorited = article.favorites?.some((f) => f.userId === currentUserId); const favoritesCount = article.favorites?.length ?? 0; const following = article.author.followers?.some( - (f) => f.followerId === currentUserId, + (f) => f.id === currentUserId, ); return { @@ -49,7 +49,7 @@ export function toResponse( description: article.description, body: article.body, tagList: article.tags - .map((t) => t.tag.name) + .map((t) => t.name) .sort((a, b) => a.localeCompare(b)), createdAt: article.createdAt.toISOString(), updatedAt: article.updatedAt.toISOString(), diff --git a/src/comments/comments.plugin.ts b/src/comments/comments.plugin.ts index 6f36ceb..710edb8 100644 --- a/src/comments/comments.plugin.ts +++ b/src/comments/comments.plugin.ts @@ -1,9 +1,7 @@ import { eq } from "drizzle-orm"; import { Elysia, NotFoundError, t } from "elysia"; import { StatusCodes } from "http-status-codes"; -import { articles } from "@/articles/articles.schema"; import { db } from "@/core/database/db"; -import { follows } from "@/profiles/profiles.schema"; import { RealWorldError } from "@/shared/errors"; import { auth } from "@/shared/plugins"; import { commentsModel, UUID } from "./comments.model"; @@ -27,28 +25,19 @@ export const commentsPlugin = new Elysia({ tags: ["Comments"] }) const enrichedComments = await db.query.comments.findMany({ with: { - author: currentUserId - ? { - with: { - followers: { - where: { followerId: currentUserId }, - }, + author: { + with: { + followers: { + where: { + id: currentUserId, }, - } - : true, + }, + }, + }, }, where: { articleId: article.id }, orderBy: { createdAt: "desc" }, }); - - const firstComment = enrichedComments[0]; - if (firstComment) { - const followers = firstComment.author?.followers; - enrichedComments.forEach((comment) => { - comment.author.followers = followers; - }); - } - return toCommentsResponse(enrichedComments, { currentUserId }); }, { @@ -100,7 +89,9 @@ export const commentsPlugin = new Elysia({ tags: ["Comments"] }) author: { with: { followers: { - where: { followerId: currentUserId }, + where: { + id: currentUserId, + }, }, }, }, diff --git a/src/comments/interfaces/enriched-comment.interface.ts b/src/comments/interfaces/enriched-comment.interface.ts index aca41ef..f9c1acc 100644 --- a/src/comments/interfaces/enriched-comment.interface.ts +++ b/src/comments/interfaces/enriched-comment.interface.ts @@ -1,10 +1,9 @@ import type { InferSelectModel } from "drizzle-orm"; -import type { follows } from "@/profiles/profiles.schema"; import type { users } from "@/users/users.schema"; import type { comments } from "../comments.schema"; export type EnrichedComment = InferSelectModel & { author: InferSelectModel & { - followers?: InferSelectModel[]; + followers?: Array>; }; }; diff --git a/src/comments/mappers/to-response.mapper.ts b/src/comments/mappers/to-response.mapper.ts index 7365c2e..7b06f56 100644 --- a/src/comments/mappers/to-response.mapper.ts +++ b/src/comments/mappers/to-response.mapper.ts @@ -37,7 +37,7 @@ export function toCommentResponse( following: Boolean( currentUserId && enrichedComment.author.followers?.some( - (f) => f.followerId === currentUserId, + (f) => f.id === currentUserId, ), ), }, diff --git a/src/core/database/relations.ts b/src/core/database/relations.ts index 0e0ccd7..324d2c2 100644 --- a/src/core/database/relations.ts +++ b/src/core/database/relations.ts @@ -15,52 +15,107 @@ const schema = { favorites, }; -export const relations = defineRelations( - schema, - ({ - one, - many, - articles, - users, - comments, - articlesToTags, - favorites, - tags, - }) => ({ - articles: { - user: one.users({ - from: articles.authorId, - to: users.id, - alias: "articles_authorId_users_id", - }), - usersViaComments: many.users({ - from: articles.id.through(comments.articleId), - to: users.id.through(comments.authorId), - alias: "articles_id_users_id_via_comments", - }), - tags: many.tags({ - from: articles.id.through(articlesToTags.articleId), - to: tags.id.through(articlesToTags.tagId), - }), - usersViaFavorites: many.users({ - from: articles.id.through(favorites.articleId), - to: users.id.through(favorites.userId), - alias: "articles_id_users_id_via_favorites", - }), - }, - users: { - articlesAuthorId: many.articles({ - alias: "articles_authorId_users_id", - }), - articlesViaComments: many.articles({ - alias: "articles_id_users_id_via_comments", - }), - articlesViaFavorites: many.articles({ - alias: "articles_id_users_id_via_favorites", - }), - }, - tags: { - articles: many.articles(), - }, - }), -); +export const relations = defineRelations(schema, (r) => ({ + // Articles relations + articles: { + author: r.one.users({ + from: r.articles.authorId, + to: r.users.id, + optional: false, + }), + tags: r.many.tags({ + from: r.articles.id.through(r.articlesToTags.articleId), + to: r.tags.id.through(r.articlesToTags.tagId), + }), + comments: r.many.comments({ + from: r.articles.id, + to: r.comments.articleId, + }), + favorites: r.many.favorites({ + from: r.articles.id, + to: r.favorites.articleId, + }), + }, + + // Users relations + users: { + // One-to-many: users can have many articles, comments, favorites + articles: r.many.articles({ + from: r.users.id, + to: r.articles.authorId, + }), + comments: r.many.comments({ + from: r.users.id, + to: r.comments.authorId, + }), + favorites: r.many.favorites({ + from: r.users.id, + to: r.favorites.userId, + }), + + // Many-to-many: users can follow many users and be followed by many users + following: r.many.users({ + from: r.users.id.through(r.follows.followerId), + to: r.users.id.through(r.follows.followedId), + }), + followers: r.many.users({ + from: r.users.id.through(r.follows.followedId), + to: r.users.id.through(r.follows.followerId), + }), + }, + + // Comments relations + comments: { + author: r.one.users({ + from: r.comments.authorId, + to: r.users.id, + optional: false, + }), + article: r.one.articles({ + from: r.comments.articleId, + to: r.articles.id, + }), + }, + + // Tags relations + tags: { + articles: r.many.articles({ + from: r.tags.id.through(r.articlesToTags.tagId), + to: r.articles.id.through(r.articlesToTags.articleId), + }), + }, + + // Junction table relations (if needed for direct access) + follows: { + follower: r.one.users({ + from: r.follows.followerId, + to: r.users.id, + }), + followed: r.one.users({ + from: r.follows.followedId, + to: r.users.id, + }), + }, + + articlesToTags: { + article: r.one.articles({ + from: r.articlesToTags.articleId, + to: r.articles.id, + }), + tag: r.one.tags({ + from: r.articlesToTags.tagId, + to: r.tags.id, + }), + }, + + favorites: { + user: r.one.users({ + from: r.favorites.userId, + to: r.users.id, + }), + article: r.one.articles({ + from: r.favorites.articleId, + to: r.articles.id, + }), + }, +})); diff --git a/src/core/plugins/errors.plugin.ts b/src/core/plugins/errors.plugin.ts index e58bf49..d035a43 100644 --- a/src/core/plugins/errors.plugin.ts +++ b/src/core/plugins/errors.plugin.ts @@ -1,4 +1,4 @@ -import { DrizzleQueryError } from "drizzle-orm/errors"; +import { DrizzleError } from "drizzle-orm/errors"; import { type Elysia, NotFoundError, ValidationError } from "elysia"; import { pick } from "radashi"; import { DEFAULT_ERROR_MESSAGE } from "@/shared/constants"; @@ -29,7 +29,7 @@ export const errors = (app: Elysia) => } // db errors - if (error instanceof DrizzleQueryError) { + if (error instanceof DrizzleError) { return formatDBError(error); } diff --git a/src/profiles/profiles.plugin.ts b/src/profiles/profiles.plugin.ts index 4821e4d..1eb642b 100644 --- a/src/profiles/profiles.plugin.ts +++ b/src/profiles/profiles.plugin.ts @@ -4,7 +4,6 @@ import { StatusCodes } from "http-status-codes"; import { db } from "@/core/database/db"; import { RealWorldError } from "@/shared/errors"; import { auth } from "@/shared/plugins"; -import { users } from "@/users/users.schema"; import { toResponse } from "./mappers"; import { profilesModel } from "./profiles.model"; import { follows } from "./profiles.schema"; diff --git a/src/profiles/profiles.schema.ts b/src/profiles/profiles.schema.ts index 4cea6bc..90adc27 100644 --- a/src/profiles/profiles.schema.ts +++ b/src/profiles/profiles.schema.ts @@ -1,6 +1,7 @@ import { sql } from "drizzle-orm"; import { check, + index, pgTable, primaryKey, timestamp, @@ -8,15 +9,19 @@ import { } from "drizzle-orm/pg-core"; import { users } from "@/users/users.schema"; +/** + * Junction table for many-to-many relationship between users (following/followers) + * This implements the users-to-users relationship through the follows table + */ export const follows = pgTable( "follows", { followerId: uuid("follower_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), followedId: uuid("followed_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at") .notNull() @@ -24,7 +29,17 @@ export const follows = pgTable( .$onUpdate(() => new Date()), }, (table) => [ + // Primary key on both foreign key columns primaryKey({ columns: [table.followerId, table.followedId] }), + // Individual indexes for single-side queries + index("follows_follower_id_idx").on(table.followerId), + index("follows_followed_id_idx").on(table.followedId), + // Composite index for efficient many-to-many relationship queries + index("follows_follower_followed_idx").on( + table.followerId, + table.followedId, + ), + // Prevent self-following check( "unique_follower_following", sql`${table.followerId} != ${table.followedId}`, diff --git a/src/shared/errors/utils.ts b/src/shared/errors/utils.ts index 58b5629..88ffad4 100644 --- a/src/shared/errors/utils.ts +++ b/src/shared/errors/utils.ts @@ -1,4 +1,4 @@ -import type { DrizzleQueryError } from "drizzle-orm/errors"; +import type { DrizzleError } from "drizzle-orm/errors"; import type { NotFoundError, ValidationError } from "elysia"; import { ConflictingFieldsError } from "./conflicting-fields"; @@ -72,7 +72,7 @@ export function formatNotFoundError(error: NotFoundError) { }; } -export function formatDBError(error: DrizzleQueryError) { +export function formatDBError(error: DrizzleError) { console.error(error); return { errors: { diff --git a/src/shared/plugins/auth.plugin.ts b/src/shared/plugins/auth.plugin.ts index 1e8d3d0..8e4e9d7 100644 --- a/src/shared/plugins/auth.plugin.ts +++ b/src/shared/plugins/auth.plugin.ts @@ -1,10 +1,8 @@ -import { eq } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { StatusCodes } from "http-status-codes"; import { db } from "@/core/database/db"; import { env } from "@/core/env"; import { RealWorldError } from "@/shared/errors"; -import { users } from "@/users/users.schema"; import { name } from "../../../package.json"; import jwt from "./jwt.plugin"; import { token } from "./token.plugin"; @@ -39,7 +37,7 @@ export const auth = new Elysia() iat: Math.floor(Date.now() / 1000), })) satisfies SignFn, jwtPayload: jwtPayload || null, - currentUserId: jwtPayload ? jwtPayload.uid : null, + currentUserId: jwtPayload ? jwtPayload.uid : undefined, }, }; }) @@ -64,7 +62,9 @@ export const auth = new Elysia() // Check user exists in DB const user = await db.query.users.findFirst({ - where: eq(users.id, currentUserId), + where: { + id: currentUserId, + }, }); if (!user) { throw new RealWorldError(StatusCodes.UNAUTHORIZED, {