From 716ed4591ffefa9af8b862ed6538ce9bb42dddaa Mon Sep 17 00:00:00 2001 From: alikia2x Date: Mon, 20 Apr 2026 20:09:30 +0800 Subject: [PATCH 1/8] test: use unified DB cleanup command --- apps/backend/tests/artist.test.ts | 14 +---- apps/backend/tests/auth.test.ts | 53 ++----------------- apps/backend/tests/engine.test.ts | 14 +---- apps/backend/tests/song.test.ts | 17 +----- apps/backend/tests/songLyrics.test.ts | 18 +------ package.json | 2 +- packages/core/src/search/catalog/song.ts | 8 +-- .../tests/integration/SongRepository.test.ts | 24 +-------- .../integration/SongSearchService.test.ts | 13 +---- .../core/tests/unit/SongSearchService.test.ts | 4 +- packages/db/package.json | 1 + packages/embedding/src/embedding.ts | 1 - turbo.json | 3 ++ 13 files changed, 21 insertions(+), 151 deletions(-) diff --git a/apps/backend/tests/artist.test.ts b/apps/backend/tests/artist.test.ts index 93f7f6f..9cab0d8 100644 --- a/apps/backend/tests/artist.test.ts +++ b/apps/backend/tests/artist.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { treaty } from "@elysiajs/eden"; import { app } from "@/index"; import { prisma } from "@cvsa/db"; @@ -6,18 +6,6 @@ import { prisma } from "@cvsa/db"; const api = treaty(app); describe("Artist E2E Tests", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.artist.deleteMany(); - }); - - afterAll(async () => { - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.$disconnect(); - }); async function getAuthToken() { const signup = await api.v2.user.post({ diff --git a/apps/backend/tests/auth.test.ts b/apps/backend/tests/auth.test.ts index 6a9bb96..6ee39aa 100644 --- a/apps/backend/tests/auth.test.ts +++ b/apps/backend/tests/auth.test.ts @@ -1,23 +1,10 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { treaty } from "@elysiajs/eden"; import { app } from "@/index"; -import { prisma } from "@cvsa/db"; const api = treaty(app); describe("Registration E2E Tests - POST /v2/user", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - }); - - afterAll(async () => { - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.$disconnect(); - }); - test("should register a new user", async () => { const payload = { username: "new_user", @@ -55,7 +42,7 @@ describe("Registration E2E Tests - POST /v2/user", () => { const payload = { username: "duplicate_user", password: "password123", - email: "first@example.com", + email: "01_first@example.com", }; // Create the first user @@ -64,7 +51,7 @@ describe("Registration E2E Tests - POST /v2/user", () => { // Attempt to register with the same username const { error, status } = await api.v2.user.post({ ...payload, - email: "second@example.com", + email: "01_second@example.com", }); // Should return 409 Conflict @@ -113,16 +100,6 @@ describe("Registration E2E Tests - POST /v2/user", () => { }); describe("Profile E2E Tests - GET /v2/me", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - }); - - afterAll(async () => { - await prisma.$disconnect(); - }); - test("should return user profile with valid token", async () => { // 1. Register to get a token const signup = await api.v2.user.post({ @@ -187,18 +164,6 @@ describe("Profile E2E Tests - GET /v2/me", () => { }); describe("Login E2E Tests - POST /v2/session", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - }); - - afterAll(async () => { - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.$disconnect(); - }); - test("should login with valid credentials", async () => { const signupPayload = { username: "login_user", @@ -277,18 +242,6 @@ describe("Login E2E Tests - POST /v2/session", () => { }); describe("Logout E2E Tests - DELETE /v2/session", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - }); - - afterAll(async () => { - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.$disconnect(); - }); - test("should logout with valid token", async () => { const signupPayload = { username: "logout_user", diff --git a/apps/backend/tests/engine.test.ts b/apps/backend/tests/engine.test.ts index 4c42cc5..1971aac 100644 --- a/apps/backend/tests/engine.test.ts +++ b/apps/backend/tests/engine.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { treaty } from "@elysiajs/eden"; import { app } from "@/index"; import { prisma } from "@cvsa/db"; @@ -6,18 +6,6 @@ import { prisma } from "@cvsa/db"; const api = treaty(app); describe("Engine E2E Tests", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.svsEngine.deleteMany(); - }); - - afterAll(async () => { - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.$disconnect(); - }); async function getAuthToken() { const signup = await api.v2.user.post({ diff --git a/apps/backend/tests/song.test.ts b/apps/backend/tests/song.test.ts index 865f284..43e9be3 100644 --- a/apps/backend/tests/song.test.ts +++ b/apps/backend/tests/song.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { treaty } from "@elysiajs/eden"; import { app } from "@/index"; import { prisma } from "@cvsa/db"; @@ -6,20 +6,6 @@ import { prisma } from "@cvsa/db"; const api = treaty(app); describe("Song E2E Tests", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.lyrics.deleteMany(); - await prisma.song.deleteMany(); - }); - - afterAll(async () => { - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.$disconnect(); - }); - async function getAuthToken() { const signup = await api.v2.user.post({ username: `${Math.random()}`, @@ -122,7 +108,6 @@ describe("Song E2E Tests", () => { expect(status).toBe(201); expect(data).toMatchObject({ - id: expect.any(Number), name: "Lyrics Song", }); diff --git a/apps/backend/tests/songLyrics.test.ts b/apps/backend/tests/songLyrics.test.ts index f2eb306..b55d728 100644 --- a/apps/backend/tests/songLyrics.test.ts +++ b/apps/backend/tests/songLyrics.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { treaty } from "@elysiajs/eden"; import { app } from "@/index"; import { prisma } from "@cvsa/db"; @@ -6,22 +6,6 @@ import { prisma } from "@cvsa/db"; const api = treaty(app); describe("Song Lyrics E2E Tests", () => { - beforeAll(async () => { - await prisma.$connect(); - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.lyrics.deleteMany(); - await prisma.song.deleteMany(); - }); - - afterAll(async () => { - await prisma.session.deleteMany(); - await prisma.user.deleteMany(); - await prisma.lyrics.deleteMany(); - await prisma.song.deleteMany(); - await prisma.$disconnect(); - }); - async function getAuthToken() { const signup = await api.v2.user.post({ username: `${Math.random()}`, diff --git a/package.json b/package.json index 5fbbf28..4d457f5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint:fix": "bunx --bun biome check --write --unsafe .", "format": "bunx --bun biome format --write .", "ci": "bunx --bun biome ci .", - "test": "bunx --bun turbo run test", + "test": "bunx --bun turbo run @cvsa/db#test:reset && bunx --bun turbo run test", "test:backend": "bunx --bun turbo run test --filter=@cvsa/backend", "test:core": "bunx --bun turbo run test --filter=@cvsa/core", "test:coverage": "bunx --bun turbo run test:coverage", diff --git a/packages/core/src/search/catalog/song.ts b/packages/core/src/search/catalog/song.ts index d67ed7c..95f3975 100644 --- a/packages/core/src/search/catalog/song.ts +++ b/packages/core/src/search/catalog/song.ts @@ -19,11 +19,10 @@ export interface SongSearchIndex { publishedAt?: number; bilibiliViews?: number; _vectors?: { - "potion-multilingual-128M": number[]; + "potion-multilingual-128M": number[] | null; }; } -// TODO: Integration test export class SongSearchService extends ISearchService { private async getDocument( song: SongDetailsResponseDto, @@ -79,12 +78,13 @@ Artists: ${getArtists().join(", ")} .map((item) => item.engine ?? undefined) .filter(Boolean) as string[], publishedAt: song.publishedAt ? new Date(song.publishedAt).getTime() : undefined, - // TODO: Error handling. _vectors: vectors.data?.embeddings[0] ? { "potion-multilingual-128M": vectors.data?.embeddings[0], } - : undefined, + : { + "potion-multilingual-128M": null, + }, }; } diff --git a/packages/core/tests/integration/SongRepository.test.ts b/packages/core/tests/integration/SongRepository.test.ts index 395aedc..c72c9fc 100644 --- a/packages/core/tests/integration/SongRepository.test.ts +++ b/packages/core/tests/integration/SongRepository.test.ts @@ -1,32 +1,10 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { songRepository, type CreateSongRequestDto, type UpdateSongRequestDto } from "@cvsa/core"; import { prisma } from "@cvsa/db"; const repository = songRepository; describe("SongRepository Integration Tests", () => { - afterAll(async () => { - await prisma.creation.deleteMany(); - await prisma.artistRole.deleteMany(); - await prisma.artist.deleteMany(); - await prisma.performance.deleteMany(); - await prisma.lyrics.deleteMany(); - await prisma.singer.deleteMany(); - await prisma.song.deleteMany(); - await prisma.$disconnect(); - }); - - beforeAll(async () => { - await prisma.$connect(); - await prisma.creation.deleteMany(); - await prisma.artistRole.deleteMany(); - await prisma.artist.deleteMany(); - await prisma.performance.deleteMany(); - await prisma.lyrics.deleteMany(); - await prisma.singer.deleteMany(); - await prisma.song.deleteMany(); - }); - describe("create", () => { test("should create a song with all fields", async () => { const input: CreateSongRequestDto = { diff --git a/packages/core/tests/integration/SongSearchService.test.ts b/packages/core/tests/integration/SongSearchService.test.ts index 1d1e721..da42d5e 100644 --- a/packages/core/tests/integration/SongSearchService.test.ts +++ b/packages/core/tests/integration/SongSearchService.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { songRepository } from "@cvsa/core"; import { prisma } from "@cvsa/db"; import { SongSearchService } from "../../src/search/catalog/song"; @@ -70,17 +70,6 @@ describe("SongSearchService Integration Tests", () => { } }); - afterAll(async () => { - await prisma.creation.deleteMany(); - await prisma.artistRole.deleteMany(); - await prisma.artist.deleteMany(); - await prisma.performance.deleteMany(); - await prisma.lyrics.deleteMany(); - await prisma.singer.deleteMany(); - await prisma.song.deleteMany(); - await prisma.$disconnect(); - }); - describe("sync", () => { test("syncs song to search index", async () => { const singer = await prisma.singer.create({ diff --git a/packages/core/tests/unit/SongSearchService.test.ts b/packages/core/tests/unit/SongSearchService.test.ts index 29fb055..f2b5f0c 100644 --- a/packages/core/tests/unit/SongSearchService.test.ts +++ b/packages/core/tests/unit/SongSearchService.test.ts @@ -240,7 +240,9 @@ describe("SongSearchService", () => { expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); const doc = mockAdminIndex.addDocuments.mock.calls[0][0][0]; - expect(doc._vectors).toBeUndefined(); + expect(doc._vectors).toEqual({ + "potion-multilingual-128M": null, + }); }); }); diff --git a/packages/db/package.json b/packages/db/package.json index b6978db..73175e5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -10,6 +10,7 @@ "push": "bunx --bun prisma db push", "db:deploy": "bunx --bun prisma migrate deploy", "test:setup": "NODE_ENV=test bunx --bun prisma db push --accept-data-loss", + "test:reset": "NODE_ENV=test bunx --bun prisma db push --force-reset", "studio": "bunx --bun prisma studio", "reset": "bunx --bun prisma migrate reset" }, diff --git a/packages/embedding/src/embedding.ts b/packages/embedding/src/embedding.ts index 0943f7f..7c7a744 100644 --- a/packages/embedding/src/embedding.ts +++ b/packages/embedding/src/embedding.ts @@ -10,7 +10,6 @@ const modelDir = path.join(import.meta.dir, "../../../model/"); const modelName = "alikia2x/potion-multilingual-128M-int8-strip"; const modelPath = path.join(modelDir, "./potion-strip/model.onnx"); -// TODO: Unit test for this export class EmbeddingManager { private tokenizer: PreTrainedTokenizer | null = null; private session: ort.InferenceSession | null = null; diff --git a/turbo.json b/turbo.json index a18aab5..05c8754 100644 --- a/turbo.json +++ b/turbo.json @@ -48,6 +48,9 @@ "tsconfig.json", "$TURBO_ROOT$/packages/typescript-config/base.json" ] + }, + "@cvsa/db#test:reset": { + "cache": false } } } From 8bcbd4f435018f21cbc02c6456e18d26bbc8d40f Mon Sep 17 00:00:00 2001 From: alikia2x Date: Mon, 20 Apr 2026 20:33:06 +0800 Subject: [PATCH 2/8] feat(artist): search service for artists --- .../src/handlers/catalog/artist/index.ts | 4 +- .../src/handlers/catalog/artist/search.ts | 25 +++++ .../src/modules/catalog/artist/container.ts | 12 +- .../src/modules/catalog/artist/service.ts | 24 +++- .../src/modules/catalog/song/container.ts | 6 +- packages/core/src/search/catalog/artist.ts | 104 ++++++++++++++++++ packages/core/src/search/catalog/index.ts | 1 + packages/core/src/search/config.ts | 14 +++ 8 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/handlers/catalog/artist/search.ts create mode 100644 packages/core/src/search/catalog/artist.ts diff --git a/apps/backend/src/handlers/catalog/artist/index.ts b/apps/backend/src/handlers/catalog/artist/index.ts index 498c5f5..40a9ec8 100644 --- a/apps/backend/src/handlers/catalog/artist/index.ts +++ b/apps/backend/src/handlers/catalog/artist/index.ts @@ -3,9 +3,11 @@ import { artistCreateHandler } from "./create"; import { artistUpdateHandler } from "./update"; import { artistDeleteHandler } from "./delete"; import { artistDetailsHandler } from "./get"; +import { artistSearchHandler } from "./search"; export const artistHandler = new Elysia({ name: "artistHandler" }) .use(artistDetailsHandler) .use(artistCreateHandler) .use(artistUpdateHandler) - .use(artistDeleteHandler); + .use(artistDeleteHandler) + .use(artistSearchHandler) diff --git a/apps/backend/src/handlers/catalog/artist/search.ts b/apps/backend/src/handlers/catalog/artist/search.ts new file mode 100644 index 0000000..fd05288 --- /dev/null +++ b/apps/backend/src/handlers/catalog/artist/search.ts @@ -0,0 +1,25 @@ +import { Elysia } from "elysia"; +import { artistSearchService } from "@cvsa/core"; +import z from "zod"; +import { traceTask } from "@/common/trace"; + +// TODO: add corresponding DTO and response schema +export const artistSearchHandler = new Elysia().get( + "/artists", + async ({ query, status }) => { + const song = await traceTask("artistSearchService.search", async () => { + return await artistSearchService.search(query.q || ""); + }); + return status(200, song); + }, + { + detail: { + summary: "Search for Artists", + description: + "Full-text search across artist names, aliases, and descriptions. Returns a list of matching artists ordered by relevance.", + }, + query: z.object({ + q: z.string().optional(), + }), + } +); diff --git a/packages/core/src/modules/catalog/artist/container.ts b/packages/core/src/modules/catalog/artist/container.ts index f8c2c71..2cf6f0f 100644 --- a/packages/core/src/modules/catalog/artist/container.ts +++ b/packages/core/src/modules/catalog/artist/container.ts @@ -1,6 +1,16 @@ import { prisma } from "@cvsa/db"; import { ArtistRepository } from "./repository"; import { ArtistService } from "./service"; +import { ArtistSearchService, searchManager } from "../../../search"; +import { treaty } from "@elysiajs/eden"; +import type { EmbeddingApp } from "@cvsa/embedding"; + +const embeddingManager = treaty("localhost:14900"); export const artistRepository = new ArtistRepository(prisma); -export const artistService = new ArtistService(artistRepository); +export const artistSearchService = new ArtistSearchService( + artistRepository, + searchManager, + embeddingManager +); +export const artistService = new ArtistService(artistRepository, artistSearchService); diff --git a/packages/core/src/modules/catalog/artist/service.ts b/packages/core/src/modules/catalog/artist/service.ts index d688934..e55b9db 100644 --- a/packages/core/src/modules/catalog/artist/service.ts +++ b/packages/core/src/modules/catalog/artist/service.ts @@ -8,9 +8,22 @@ import type { } from "./dto"; import type { IArtistRepository } from "./repository.interface"; import { traceTask } from "@cvsa/observability"; +import type { ArtistSearchService } from "@cvsa/core/internal"; +import { appLogger } from "@cvsa/logger"; export class ArtistService implements IServiceWithGetDetails { - constructor(private readonly repository: IArtistRepository) {} + constructor( + private readonly repository: IArtistRepository, + private readonly search: ArtistSearchService + ) { } + + private async _sync(id: number) { + await traceTask("sync search index", async () => { + this.search.sync(id).catch((e) => { + appLogger.warn(Bun.inspect(e)); + }); + }); + } async getDetails(id: ArtistId) { return traceTask("db findOne artist", async () => { @@ -23,9 +36,11 @@ export class ArtistService implements IServiceWithGetDetails { - return traceTask("db create artist", async () => { + const result = await traceTask("db create song", async () => { return await this.repository.create(input); }); + await this._sync(result.id); + return result; } async update(id: ArtistId, input: UpdateArtistRequestDto): Promise { @@ -33,9 +48,11 @@ export class ArtistService implements IServiceWithGetDetails { + const result = await traceTask("db update artist", async () => { return await this.repository.update(id, input); }); + await this._sync(result.id); + return result; } async delete(id: ArtistId): Promise { @@ -46,5 +63,6 @@ export class ArtistService implements IServiceWithGetDetails { return await this.repository.softDelete(id); }); + await this._sync(id); } } diff --git a/packages/core/src/modules/catalog/song/container.ts b/packages/core/src/modules/catalog/song/container.ts index ee6bdf9..1304986 100644 --- a/packages/core/src/modules/catalog/song/container.ts +++ b/packages/core/src/modules/catalog/song/container.ts @@ -2,9 +2,11 @@ import { prisma } from "@cvsa/db"; import { SongRepository } from "./repository"; import { SongService } from "./service"; import { SongSearchService, searchManager } from "../../../search"; -import type { EmbeddingApp } from "@cvsa/embedding"; import { treaty } from "@elysiajs/eden"; -export const embeddingManager = treaty("localhost:14900"); +import type { EmbeddingApp } from "@cvsa/embedding"; + +const embeddingManager = treaty("localhost:14900"); + export const songRepository = new SongRepository(prisma); export const songSearchService = new SongSearchService( songRepository, diff --git a/packages/core/src/search/catalog/artist.ts b/packages/core/src/search/catalog/artist.ts new file mode 100644 index 0000000..10b2e61 --- /dev/null +++ b/packages/core/src/search/catalog/artist.ts @@ -0,0 +1,104 @@ +import { ISearchService } from "../interface"; +import { unique, keys } from "remeda"; +import type { ArtistDetailsResponseDto } from "../../modules"; +import { appLogger } from "@cvsa/logger"; + +export interface ArtistSearchIndex { + id: number; + name?: string; + description?: string; + aliases?: string[]; + _vectors?: { + "potion-multilingual-128M": number[] | null; + }; +} + +export class ArtistSearchService extends ISearchService { + private async getDocument( + artist: ArtistDetailsResponseDto, + language: string + ): Promise { + const getDesc = () => { + if (language === artist.language) return artist.description; + return artist.localizedDescriptions?.[language]; + }; + const getName = () => { + if (language === artist.language) return artist.name; + return artist.localizedNames?.[language]; + }; + + const vectors = await this.embeddingManager.embeddings.post({ + texts: [ + `Name: ${getName() ?? ""} +Description: ${getDesc() ?? ""} +Name Aliases: ${artist.aliases.join(", ")} +`, + ], + }); + return { + id: artist.id, + name: getName() ?? undefined, + aliases: artist.aliases, + description: getDesc() ?? undefined, + _vectors: vectors.data?.embeddings[0] + ? { + "potion-multilingual-128M": vectors.data?.embeddings[0], + } + : { + "potion-multilingual-128M": null, + }, + }; + } + + public async sync(id: number) { + if (!this.searchManager) { + appLogger.warn("Search service not available"); + return; + } + const artist = await this.repository.getDetailsById(id); + + if (!artist) { + const indexesToBeDeleted = await this.searchManager.getLocalizedIndexesOfEntity("artist"); + for (const index of indexesToBeDeleted) { + const adminIndex = await this.searchManager.getAdminIndex(index); + const task = await adminIndex.deleteDocument(id); + await this.searchManager.waitForTask(task.taskUid); + } + return; + } + + const languages = unique([ + ...keys(artist.localizedNames ?? []), + ...keys(artist.localizedDescriptions ?? []), + artist.language, + ]); + for (const language of languages) { + const indexUid = `artist_${language}`; + const index = await this.searchManager.getAdminIndex(indexUid); + const document = await this.getDocument(artist, language); + const task = await index.addDocuments([document], { + primaryKey: "id", + }); + await this.searchManager.waitForTask(task.taskUid); + } + } + + public async search(query: string, language: string = "zh") { + if (!this.searchManager) { + throw new Error("Search or embedding service not available"); + } + + const index = await this.searchManager.getSearchIndex(`artist_${language}`); + const embeddingResponse = await this.embeddingManager.embeddings.post({ + texts: [query], + }); + return index.search(query, { + vector: embeddingResponse?.data?.embeddings[0], + hybrid: { + embedder: "potion-multilingual-128M", + semanticRatio: 0.25, + }, + showRankingScore: true, + }); + } +} diff --git a/packages/core/src/search/catalog/index.ts b/packages/core/src/search/catalog/index.ts index 153b6ce..9d9a57c 100644 --- a/packages/core/src/search/catalog/index.ts +++ b/packages/core/src/search/catalog/index.ts @@ -1 +1,2 @@ export * from "./song"; +export * from "./artist"; diff --git a/packages/core/src/search/config.ts b/packages/core/src/search/config.ts index 2f8d436..58d752e 100644 --- a/packages/core/src/search/config.ts +++ b/packages/core/src/search/config.ts @@ -47,4 +47,18 @@ export const INDEX_SETTINGS: Record = { // }, }, }, + artist: { + searchableAttributes: [ + "name", + "description", + "aliases", + ], + rankingRules: ["attribute", "words", "proximity", "exactness", "typo", "sort"], + embedders: { + "potion-multilingual-128M": { + source: "userProvided", + dimensions: 256, + }, + } + }, }; From 163688f74616f5912dc312fba556401ce949df30 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Tue, 21 Apr 2026 11:40:56 +0800 Subject: [PATCH 3/8] fix(i18n): add missing error.artist.notfound key --- locale/modules/backend/messages/en.json | 3 ++- locale/modules/backend/messages/zh.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/locale/modules/backend/messages/en.json b/locale/modules/backend/messages/en.json index 65f64a0..158257b 100644 --- a/locale/modules/backend/messages/en.json +++ b/locale/modules/backend/messages/en.json @@ -9,5 +9,6 @@ "error.internal": "Internal Error", "error.validation": "Invalid Input", "error.username-taken": "This username is already taken.", - "error.email-taken": "This email is already registered." + "error.email-taken": "This email is already registered.", + "error.artist.notfound": "This artist does not exist." } diff --git a/locale/modules/backend/messages/zh.json b/locale/modules/backend/messages/zh.json index 52acc79..76dfded 100644 --- a/locale/modules/backend/messages/zh.json +++ b/locale/modules/backend/messages/zh.json @@ -9,5 +9,6 @@ "error.internal": "服务器内部错误", "error.validation": "输入信息有误", "error.username-taken": "此用户名已被注册", - "error.email-taken": "此邮箱已被注册" + "error.email-taken": "此邮箱已被注册", + "error.artist.notfound": "该创作者不存在" } From 8b66d0d5a0f0fb3d88a958b7783e60b6758c3efd Mon Sep 17 00:00:00 2001 From: alikia2x Date: Wed, 22 Apr 2026 15:14:15 +0800 Subject: [PATCH 4/8] feat(outbox): transactional outbox for search sync Replace fire-and-forget search index synchronization with a transactional outbox pattern to guarantee eventual consistency between the database and search index. Previously, ArtistService and SongService called search.sync() directly after write operations. If the search service was unavailable, the sync would fail silently and the index would become stale. With this change: - Write operations now create outbox entries inside Prisma transactions - A BullMQ worker processes outbox jobs asynchronously to sync the index - Redis is added as the BullMQ backing store - Graceful shutdown handlers ensure in-flight jobs complete on exit New modules: - packages/core/src/modules/outbox/ (repository, service, DTOs) - packages/core/src/outbox/ (BullMQ queue and processor) - packages/db/prisma/models/meta/outbox.prisma (schema) --- apps/backend/src/index.ts | 24 ++++ bun.lock | 69 ++++++++++- docker-compose.yml | 10 ++ locale/modules/backend/messages/en.json | 3 +- locale/modules/backend/messages/zh.json | 3 +- packages/core/package.json | 2 + packages/core/src/internal.ts | 1 + .../src/modules/catalog/artist/container.ts | 3 +- .../src/modules/catalog/artist/service.ts | 63 ++++++---- .../src/modules/catalog/song/container.ts | 3 +- .../core/src/modules/catalog/song/service.ts | 117 +++++++++++------- packages/core/src/modules/index.ts | 1 + packages/core/src/modules/outbox/container.ts | 6 + packages/core/src/modules/outbox/dto.ts | 35 ++++++ packages/core/src/modules/outbox/index.ts | 5 + .../modules/outbox/repository.interface.ts | 15 +++ .../core/src/modules/outbox/repository.ts | 71 +++++++++++ packages/core/src/modules/outbox/service.ts | 65 ++++++++++ packages/core/src/outbox/index.ts | 2 + packages/core/src/outbox/processor.ts | 31 +++++ packages/core/src/outbox/queue.ts | 61 +++++++++ packages/core/tests/unit/SongService.test.ts | 80 ++++-------- packages/db/prisma/models/meta/outbox.prisma | 27 ++++ packages/db/src/index.ts | 2 +- packages/env/src/index.ts | 2 + 25 files changed, 575 insertions(+), 126 deletions(-) create mode 100644 packages/core/src/modules/outbox/container.ts create mode 100644 packages/core/src/modules/outbox/dto.ts create mode 100644 packages/core/src/modules/outbox/index.ts create mode 100644 packages/core/src/modules/outbox/repository.interface.ts create mode 100644 packages/core/src/modules/outbox/repository.ts create mode 100644 packages/core/src/modules/outbox/service.ts create mode 100644 packages/core/src/outbox/index.ts create mode 100644 packages/core/src/outbox/processor.ts create mode 100644 packages/core/src/outbox/queue.ts create mode 100644 packages/db/prisma/models/meta/outbox.prisma diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 216dfd7..e12a825 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -10,11 +10,35 @@ import { opentelemetry } from "@elysiajs/opentelemetry"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { devHandler } from "./handlers"; +import { createOutboxWorker, closeOutboxInfrastructure } from "@cvsa/core/internal"; +import { processOutboxEntry } from "@cvsa/core/internal"; +import { outboxService } from "@cvsa/core/internal"; +import { appLogger } from "@cvsa/logger"; const [host, port] = getBindingInfo(); logStartup(host, port); +const outboxWorker = createOutboxWorker(processOutboxEntry); + +outboxService.recoverStaleEntries().catch((e) => { + appLogger.warn(`Failed to recover stale outbox entries: ${e.message}`); +}); + +process.on("SIGTERM", async () => { + appLogger.info("Received SIGTERM, shutting down gracefully..."); + await outboxWorker.close(); + await closeOutboxInfrastructure(); + process.exit(0); +}); + +process.on("SIGINT", async () => { + appLogger.info("Received SIGINT, shutting down gracefully..."); + await outboxWorker.close(); + await closeOutboxInfrastructure(); + process.exit(0); +}); + export const app = new Elysia({ serve: { hostname: host, diff --git a/bun.lock b/bun.lock index ac29faa..31ca553 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "cvsa", @@ -57,6 +56,8 @@ "@cvsa/observability": "workspace:*", "@elysiajs/eden": "^1.4.9", "better-auth": "^1.5.6", + "bullmq": "^5", + "ioredis": "^5", "meilisearch": "^0.56.0", "nanoid": "^5.1.7", "remeda": "^2.33.7", @@ -294,10 +295,24 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], @@ -492,7 +507,9 @@ "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "bullmq": ["bullmq@5.75.2", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.4", "tslib": "2.8.1", "uuid": "11.1.0" } }, "sha512-5GDO2L5OfzogDxzJCeT7icYdI41vQZ5Wuw2z4EqpfPc1m4/gxyg0sEATULGPdR1sMw0kwCPqbehiIPseXxBX9A=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], @@ -510,6 +527,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -522,6 +541,8 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], + "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -638,6 +659,8 @@ "import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="], + "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -660,10 +683,16 @@ "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "meilisearch": ["meilisearch@0.56.0", "", {}, "sha512-kBXba8DcSrLWHYqopCm2JL90oBy97VdIfHkP1ii7/eHufeEEEk9Zu5Vv/IFQpNzmhEpWszVFVlGvZoI6QJeGzA=="], @@ -678,6 +707,10 @@ "ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], @@ -686,12 +719,16 @@ "nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="], + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], @@ -784,6 +821,10 @@ "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "remeda": ["remeda@2.33.7", "", {}, "sha512-cXlyjevWx5AcslOUEETG4o8XYi9UkoCXcJmj7XhPFVbla+ITuOBxv6ijBrmbeg+ZhzmDThkNdO+iXKUfrJep1w=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -838,6 +879,8 @@ "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -870,6 +913,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -892,6 +937,14 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@cvsa/core/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@cvsa/env/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@cvsa/logger/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@cvsa/observability/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@elysiajs/opentelemetry/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="], "@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], @@ -910,6 +963,8 @@ "@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "ioredis/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], @@ -920,6 +975,14 @@ "require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@cvsa/core/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "@cvsa/env/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "@cvsa/logger/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "@cvsa/observability/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@elysiajs/opentelemetry/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], "@elysiajs/opentelemetry/@opentelemetry/sdk-node/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="], @@ -966,6 +1029,8 @@ "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "ioredis/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "@elysiajs/opentelemetry/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="], diff --git a/docker-compose.yml b/docker-compose.yml index 2ec7a0b..803ed39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,16 @@ services: volumes: - meili:/meili_data + redis: + image: redis:7-alpine + container_name: cvsa-redis + restart: always + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis:/data + volumes: data: meili: + redis: diff --git a/locale/modules/backend/messages/en.json b/locale/modules/backend/messages/en.json index 158257b..d987e80 100644 --- a/locale/modules/backend/messages/en.json +++ b/locale/modules/backend/messages/en.json @@ -10,5 +10,6 @@ "error.validation": "Invalid Input", "error.username-taken": "This username is already taken.", "error.email-taken": "This email is already registered.", - "error.artist.notfound": "This artist does not exist." + "error.artist.notfound": "This artist does not exist.", + "error.engine.notfound": "This engine does not exist." } diff --git a/locale/modules/backend/messages/zh.json b/locale/modules/backend/messages/zh.json index 76dfded..8159c29 100644 --- a/locale/modules/backend/messages/zh.json +++ b/locale/modules/backend/messages/zh.json @@ -10,5 +10,6 @@ "error.validation": "输入信息有误", "error.username-taken": "此用户名已被注册", "error.email-taken": "此邮箱已被注册", - "error.artist.notfound": "该创作者不存在" + "error.artist.notfound": "该创作者不存在", + "error.engine.notfound": "该引擎不存在" } diff --git a/packages/core/package.json b/packages/core/package.json index 1e3e5af..4785050 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,8 @@ "@cvsa/observability": "workspace:*", "@elysiajs/eden": "^1.4.9", "better-auth": "^1.5.6", + "bullmq": "^5", + "ioredis": "^5", "meilisearch": "^0.56.0", "nanoid": "^5.1.7", "remeda": "^2.33.7", diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index 7001a41..072c8a9 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,5 +1,6 @@ export * from "./modules"; export * from "./search"; +export * from "./outbox"; export * from "./types"; export * from "./utils"; export * from "./error"; diff --git a/packages/core/src/modules/catalog/artist/container.ts b/packages/core/src/modules/catalog/artist/container.ts index 2cf6f0f..6199de6 100644 --- a/packages/core/src/modules/catalog/artist/container.ts +++ b/packages/core/src/modules/catalog/artist/container.ts @@ -4,6 +4,7 @@ import { ArtistService } from "./service"; import { ArtistSearchService, searchManager } from "../../../search"; import { treaty } from "@elysiajs/eden"; import type { EmbeddingApp } from "@cvsa/embedding"; +import { outboxService } from "../../outbox/container"; const embeddingManager = treaty("localhost:14900"); @@ -13,4 +14,4 @@ export const artistSearchService = new ArtistSearchService( searchManager, embeddingManager ); -export const artistService = new ArtistService(artistRepository, artistSearchService); +export const artistService = new ArtistService(artistRepository, outboxService); diff --git a/packages/core/src/modules/catalog/artist/service.ts b/packages/core/src/modules/catalog/artist/service.ts index e55b9db..bc07fec 100644 --- a/packages/core/src/modules/catalog/artist/service.ts +++ b/packages/core/src/modules/catalog/artist/service.ts @@ -1,3 +1,4 @@ +import type { OutboxService } from "@cvsa/core/internal"; import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal"; import type { ArtistDetailsResponseDto, @@ -8,22 +9,13 @@ import type { } from "./dto"; import type { IArtistRepository } from "./repository.interface"; import { traceTask } from "@cvsa/observability"; -import type { ArtistSearchService } from "@cvsa/core/internal"; -import { appLogger } from "@cvsa/logger"; +import { prisma } from "@cvsa/db"; export class ArtistService implements IServiceWithGetDetails { constructor( private readonly repository: IArtistRepository, - private readonly search: ArtistSearchService + private readonly outbox: OutboxService ) { } - - private async _sync(id: number) { - await traceTask("sync search index", async () => { - this.search.sync(id).catch((e) => { - appLogger.warn(Bun.inspect(e)); - }); - }); - } async getDetails(id: ArtistId) { return traceTask("db findOne artist", async () => { @@ -36,11 +28,20 @@ export class ArtistService implements IServiceWithGetDetails { - const result = await traceTask("db create song", async () => { - return await this.repository.create(input); + return traceTask("db create artist", async () => { + return await prisma.$transaction(async (tx) => { + const result = await this.repository.create(input, tx); + await this.outbox.createEntry( + { + aggregateType: "artist", + aggregateId: result.id, + eventType: "artist.created", + }, + tx + ); + return result; + }); }); - await this._sync(result.id); - return result; } async update(id: ArtistId, input: UpdateArtistRequestDto): Promise { @@ -48,11 +49,20 @@ export class ArtistService implements IServiceWithGetDetails { - return await this.repository.update(id, input); + return traceTask("db update artist", async () => { + return await prisma.$transaction(async (tx) => { + const result = await this.repository.update(id, input, tx); + await this.outbox.createEntry( + { + aggregateType: "artist", + aggregateId: id, + eventType: "artist.updated", + }, + tx + ); + return result; + }); }); - await this._sync(result.id); - return result; } async delete(id: ArtistId): Promise { @@ -60,9 +70,18 @@ export class ArtistService implements IServiceWithGetDetails { - return await this.repository.softDelete(id); + return traceTask("db delete artist", async () => { + await prisma.$transaction(async (tx) => { + await this.repository.softDelete(id, tx); + await this.outbox.createEntry( + { + aggregateType: "artist", + aggregateId: id, + eventType: "artist.deleted", + }, + tx + ); + }); }); - await this._sync(id); } } diff --git a/packages/core/src/modules/catalog/song/container.ts b/packages/core/src/modules/catalog/song/container.ts index 1304986..86b9f0d 100644 --- a/packages/core/src/modules/catalog/song/container.ts +++ b/packages/core/src/modules/catalog/song/container.ts @@ -4,6 +4,7 @@ import { SongService } from "./service"; import { SongSearchService, searchManager } from "../../../search"; import { treaty } from "@elysiajs/eden"; import type { EmbeddingApp } from "@cvsa/embedding"; +import { outboxService } from "../../outbox/container"; const embeddingManager = treaty("localhost:14900"); @@ -13,4 +14,4 @@ export const songSearchService = new SongSearchService( searchManager, embeddingManager ); -export const songService = new SongService(songRepository, songSearchService); +export const songService = new SongService(songRepository, outboxService); diff --git a/packages/core/src/modules/catalog/song/service.ts b/packages/core/src/modules/catalog/song/service.ts index 8e9a6ba..11657bb 100644 --- a/packages/core/src/modules/catalog/song/service.ts +++ b/packages/core/src/modules/catalog/song/service.ts @@ -1,4 +1,4 @@ -import type { SongSearchService } from "@cvsa/core/internal"; +import type { OutboxService } from "@cvsa/core/internal"; import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal"; import type { SongDetailsResponseDto, @@ -13,12 +13,12 @@ import type { } from "./dto"; import type { ISongRepository } from "./repository.interface"; import { traceTask } from "@cvsa/observability"; -import { appLogger } from "@cvsa/logger"; +import { prisma } from "@cvsa/db"; export class SongService implements IServiceWithGetDetails { constructor( private readonly repository: ISongRepository, - private readonly search: SongSearchService + private readonly outbox: OutboxService ) {} async getDetails(id: SongId) { @@ -32,15 +32,20 @@ export class SongService implements IServiceWithGetDetails { - const result = await traceTask("db create song", async () => { - return await this.repository.create(input); - }); - await traceTask("sync search index", async () => { - this.search.sync(result.id).catch((e) => { - appLogger.warn(Bun.inspect(e)); + return traceTask("db create song", async () => { + return await prisma.$transaction(async (tx) => { + const result = await this.repository.create(input, tx); + await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: result.id, + eventType: "song.created", + }, + tx + ); + return result; }); }); - return result; } async update(id: SongId, input: UpdateSongRequestDto): Promise { @@ -48,16 +53,20 @@ export class SongService implements IServiceWithGetDetails { - return await this.repository.update(id, input); - }); - await traceTask("sync search index", async () => { - this.search.sync(id).catch((e) => { - // TODO: Should we mark this as dirty and sync it later? - appLogger.warn(Bun.inspect(e)); + return traceTask("db update song", async () => { + return await prisma.$transaction(async (tx) => { + const result = await this.repository.update(id, input, tx); + await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.updated", + }, + tx + ); + return result; }); }); - return result; } async delete(id: SongId): Promise { @@ -65,12 +74,17 @@ export class SongService implements IServiceWithGetDetails { - return await this.repository.softDelete(id); - }); - await traceTask("sync search index", async () => { - this.search.sync(id).catch((e) => { - appLogger.warn(Bun.inspect(e)); + return traceTask("db delete song", async () => { + await prisma.$transaction(async (tx) => { + await this.repository.softDelete(id, tx); + await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.deleted", + }, + tx + ); }); }); } @@ -107,15 +121,20 @@ export class SongService implements IServiceWithGetDetails { - return await this.repository.createLyrics(id, input); - }); - await traceTask("sync search index", async () => { - this.search.sync(id).catch((e) => { - appLogger.warn(Bun.inspect(e)); + return traceTask("db create lyric", async () => { + return await prisma.$transaction(async (tx) => { + const result = await this.repository.createLyrics(id, input, tx); + await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.lyric_created", + }, + tx + ); + return result; }); }); - return result; } async updateLyric( @@ -131,15 +150,20 @@ export class SongService implements IServiceWithGetDetails { - return await this.repository.updateLyric(lyricId, input); - }); - await traceTask("sync search index", async () => { - this.search.sync(id).catch((e) => { - appLogger.warn(Bun.inspect(e)); + return traceTask("db update lyric", async () => { + return await prisma.$transaction(async (tx) => { + const result = await this.repository.updateLyric(lyricId, input, tx); + await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.lyric_updated", + }, + tx + ); + return result; }); }); - return result; } async deleteLyric(id: SongId, lyricId: number): Promise { @@ -151,12 +175,17 @@ export class SongService implements IServiceWithGetDetails { - return await this.repository.softDeleteLyric(lyricId); - }); - await traceTask("sync search index", async () => { - this.search.sync(id).catch((e) => { - appLogger.warn(Bun.inspect(e)); + return traceTask("db delete lyric", async () => { + await prisma.$transaction(async (tx) => { + await this.repository.softDeleteLyric(lyricId, tx); + await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.lyric_deleted", + }, + tx + ); }); }); } diff --git a/packages/core/src/modules/index.ts b/packages/core/src/modules/index.ts index 7ac6bfd..8c4f84d 100644 --- a/packages/core/src/modules/index.ts +++ b/packages/core/src/modules/index.ts @@ -1,2 +1,3 @@ export * from "./catalog"; export * from "./auth"; +export * from "./outbox"; diff --git a/packages/core/src/modules/outbox/container.ts b/packages/core/src/modules/outbox/container.ts new file mode 100644 index 0000000..90f4d62 --- /dev/null +++ b/packages/core/src/modules/outbox/container.ts @@ -0,0 +1,6 @@ +import { prisma } from "@cvsa/db"; +import { OutboxRepository } from "./repository"; +import { OutboxService } from "./service"; + +export const outboxRepository = new OutboxRepository(prisma); +export const outboxService = new OutboxService(outboxRepository); diff --git a/packages/core/src/modules/outbox/dto.ts b/packages/core/src/modules/outbox/dto.ts new file mode 100644 index 0000000..95438b0 --- /dev/null +++ b/packages/core/src/modules/outbox/dto.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const CreateOutboxEntrySchema = z.object({ + aggregateType: z.string(), + aggregateId: z.number().int(), + eventType: z.string(), + payload: z.record(z.string(), z.unknown()).optional(), +}); + +export const PendingOutboxQuerySchema = z.object({ + limit: z.number().int().positive().default(100), +}); + +export type CreateOutboxEntryDto = { + aggregateType: string; + aggregateId: number; + eventType: string; + payload?: Record; +}; + +export type OutboxEntryDto = { + id: number; + aggregateType: string; + aggregateId: number; + eventType: string; + payload: unknown; + status: "PENDING" | "PROCESSING" | "PROCESSED" | "FAILED"; + retryCount: number; + lastError: string | null; + nextRetryAt: string | null; + createdAt: string; + processedAt: string | null; +}; + +export type PendingOutboxQueryDto = z.infer; diff --git a/packages/core/src/modules/outbox/index.ts b/packages/core/src/modules/outbox/index.ts new file mode 100644 index 0000000..132cd4a --- /dev/null +++ b/packages/core/src/modules/outbox/index.ts @@ -0,0 +1,5 @@ +export * from "./dto"; +export * from "./repository"; +export * from "./repository.interface"; +export * from "./service"; +export * from "./container"; diff --git a/packages/core/src/modules/outbox/repository.interface.ts b/packages/core/src/modules/outbox/repository.interface.ts new file mode 100644 index 0000000..2317b39 --- /dev/null +++ b/packages/core/src/modules/outbox/repository.interface.ts @@ -0,0 +1,15 @@ +import type { TxClient } from "@cvsa/db"; +import type { CreateOutboxEntryDto, OutboxEntryDto, PendingOutboxQueryDto } from "./dto"; + +export abstract class IOutboxRepository { + abstract create(input: CreateOutboxEntryDto, tx?: TxClient): Promise; + abstract findPending(query: PendingOutboxQueryDto): Promise; + abstract markProcessing(id: number, tx?: TxClient): Promise; + abstract markProcessed(id: number, tx?: TxClient): Promise; + abstract markFailed( + id: number, + lastError: string, + nextRetryAt: Date, + tx?: TxClient + ): Promise; +} diff --git a/packages/core/src/modules/outbox/repository.ts b/packages/core/src/modules/outbox/repository.ts new file mode 100644 index 0000000..aa5d46e --- /dev/null +++ b/packages/core/src/modules/outbox/repository.ts @@ -0,0 +1,71 @@ +import type { PrismaClient } from "@cvsa/db"; +import { transformPrismaResult, type TxClient } from "@cvsa/db"; +import type { CreateOutboxEntryDto, PendingOutboxQueryDto } from "./dto"; +import type { IOutboxRepository } from "./repository.interface"; + +export class OutboxRepository implements IOutboxRepository { + constructor(private readonly prisma: PrismaClient) {} + + async create(input: CreateOutboxEntryDto, tx?: TxClient) { + const client = tx ?? this.prisma; + return transformPrismaResult( + await client.outbox.create({ + data: { + aggregateType: input.aggregateType, + aggregateId: input.aggregateId, + eventType: input.eventType, + payload: input.payload ?? null, + }, + }) + ); + } + + async findPending(query: PendingOutboxQueryDto) { + const client = this.prisma; + return transformPrismaResult( + await client.outbox.findMany({ + where: { + status: "PENDING", + OR: [{ nextRetryAt: { lte: new Date() } }, { nextRetryAt: null }], + }, + orderBy: { createdAt: "asc" }, + take: query.limit, + }) + ); + } + + async markProcessing(id: number, tx?: TxClient) { + const client = tx ?? this.prisma; + return transformPrismaResult( + await client.outbox.update({ + where: { id }, + data: { status: "PROCESSING" }, + }) + ); + } + + async markProcessed(id: number, tx?: TxClient) { + const client = tx ?? this.prisma; + return transformPrismaResult( + await client.outbox.update({ + where: { id }, + data: { status: "PROCESSED", processedAt: new Date() }, + }) + ); + } + + async markFailed(id: number, lastError: string, nextRetryAt: Date, tx?: TxClient) { + const client = tx ?? this.prisma; + return transformPrismaResult( + await client.outbox.update({ + where: { id }, + data: { + status: "FAILED", + lastError, + nextRetryAt, + retryCount: { increment: 1 }, + }, + }) + ); + } +} diff --git a/packages/core/src/modules/outbox/service.ts b/packages/core/src/modules/outbox/service.ts new file mode 100644 index 0000000..a97d2a6 --- /dev/null +++ b/packages/core/src/modules/outbox/service.ts @@ -0,0 +1,65 @@ +import type { IOutboxRepository } from "./repository.interface"; +import type { CreateOutboxEntryDto, OutboxEntryDto, PendingOutboxQueryDto } from "./dto"; +import { Prisma, type TxClient } from "@cvsa/db"; +import { outboxQueue } from "../../outbox/queue"; +import { appLogger } from "@cvsa/logger"; + +const MAX_RETRIES = 5; + +export class OutboxService { + constructor(private readonly repository: IOutboxRepository) {} + + async createEntry(input: CreateOutboxEntryDto, tx?: TxClient): Promise { + const entry = await this.repository.create(input, tx); + await outboxQueue.add(`outbox-${entry.aggregateType}-${entry.aggregateId}`, entry, { + jobId: `outbox-${entry.id}`, + attempts: MAX_RETRIES, + backoff: { type: "exponential", delay: 1000 }, + }); + return entry; + } + + async processEntry( + id: number, + processor: (aggregateType: string, aggregateId: number, eventType: string) => Promise + ): Promise { + try { + const entry = await this.repository.markProcessing(id); + try { + await processor(entry.aggregateType, entry.aggregateId, entry.eventType); + await this.repository.markProcessed(id); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + appLogger.warn(`Outbox entry ${id} processing failed: ${errorMessage}`); + if (entry.retryCount >= MAX_RETRIES - 1) { + await this.repository.markFailed( + id, + errorMessage, + new Date(Date.now() + 3600_000) + ); + } else { + throw error; + } + } + } catch (error) { + // Handle case where outbox entry no longer exists (already processed or cleaned up) + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + appLogger.debug(`Outbox entry ${id} not found, skipping processing`); + return; + } + throw error; + } + } + + async recoverStaleEntries(limit: number = 100): Promise { + const query: PendingOutboxQueryDto = { limit }; + const entries = await this.repository.findPending(query); + for (const entry of entries) { + await outboxQueue.add(`outbox-recover-${entry.id}`, entry, { + jobId: `outbox-recover-${entry.id}`, + attempts: MAX_RETRIES, + backoff: { type: "exponential", delay: 1000 }, + }); + } + } +} diff --git a/packages/core/src/outbox/index.ts b/packages/core/src/outbox/index.ts new file mode 100644 index 0000000..1336656 --- /dev/null +++ b/packages/core/src/outbox/index.ts @@ -0,0 +1,2 @@ +export * from "./queue"; +export * from "./processor"; diff --git a/packages/core/src/outbox/processor.ts b/packages/core/src/outbox/processor.ts new file mode 100644 index 0000000..f71e466 --- /dev/null +++ b/packages/core/src/outbox/processor.ts @@ -0,0 +1,31 @@ +import type { Job } from "bullmq"; +import type { OutboxEntryDto } from "@cvsa/core/internal"; +import { songSearchService, artistSearchService } from "@cvsa/core/internal"; +import { outboxService } from "../modules/outbox/container"; +import { appLogger } from "@cvsa/logger"; + +export async function processOutboxEntry(job: Job): Promise { + const entry = job.data; + + await outboxService.processEntry(entry.id, async (aggregateType, aggregateId, eventType) => { + appLogger.debug( + `processing outbox entry with ID ${aggregateId}, type ${aggregateType}`, + { + aggregateId, + aggregateType, + eventType, + } + ); + switch (aggregateType) { + case "song": + await songSearchService.sync(aggregateId); + break; + case "artist": + await artistSearchService.sync(aggregateId); + break; + default: + appLogger.warn(`Unknown aggregate type: ${aggregateType}`); + throw new Error(`Unknown aggregate type: ${aggregateType}`); + } + }); +} diff --git a/packages/core/src/outbox/queue.ts b/packages/core/src/outbox/queue.ts new file mode 100644 index 0000000..7922aac --- /dev/null +++ b/packages/core/src/outbox/queue.ts @@ -0,0 +1,61 @@ +import { Queue, Worker, type Job } from "bullmq"; +import IORedis from "ioredis"; +import { env } from "@cvsa/env"; +import { appLogger } from "@cvsa/logger"; +import type { OutboxEntryDto } from "@cvsa/core/internal"; + +const QUEUE_NAME = "outbox"; + +let connection: IORedis | undefined; + +function getConnection(): IORedis { + if (!connection) { + connection = new IORedis(env.REDIS_URL, { + maxRetriesPerRequest: null, + }); + } + return connection; +} + +export const outboxQueue = new Queue(QUEUE_NAME, { + connection: getConnection(), + defaultJobOptions: { + attempts: 5, + backoff: { + type: "exponential", + delay: 1000, + }, + removeOnComplete: true, + removeOnFail: { + age: 24 * 3600, + count: 1000, + }, + }, +}); + +export function createOutboxWorker( + processor: (job: Job) => Promise +): Worker { + const worker = new Worker(QUEUE_NAME, processor, { + connection: getConnection(), + concurrency: 5, + }); + + worker.on("completed", (job) => { + appLogger.info(`Outbox job ${job.id} completed`); + }); + + worker.on("failed", (job, err) => { + appLogger.error(`Outbox job ${job?.id} failed: ${err.message}`); + }); + + return worker; +} + +export async function closeOutboxInfrastructure(): Promise { + await outboxQueue.close(); + if (connection) { + await connection.quit(); + connection = undefined; + } +} diff --git a/packages/core/tests/unit/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts index 370a328..eb55c97 100644 --- a/packages/core/tests/unit/SongService.test.ts +++ b/packages/core/tests/unit/SongService.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import { SongService, AppError } from "@cvsa/core/internal"; -import type { SongDetailsResponseDto, ISongRepository, SongSearchService } from "@cvsa/core"; +import type { SongDetailsResponseDto, ISongRepository, OutboxService } from "@cvsa/core"; import { createMockRepository } from "../utils"; const mockSongDetails: SongDetailsResponseDto = { @@ -69,13 +69,27 @@ describe("SongService", () => { softDeleteLyric: async () => {}, }); - const mockSearchService = { - sync: mock(async (_id: number) => {}), + const mockOutboxService = { + createEntry: mock(async () => ({ + id: 1, + aggregateType: "song", + aggregateId: 1, + eventType: "song.created", + payload: null, + status: "PENDING" as const, + retryCount: 0, + lastError: null, + nextRetryAt: null, + createdAt: new Date().toISOString(), + processedAt: null, + })), + processEntry: mock(async () => {}), + recoverStaleEntries: mock(async () => {}), }; const songService = new SongService( mockRepository as unknown as ISongRepository, - mockSearchService as unknown as SongSearchService + mockOutboxService as unknown as OutboxService ); describe("getDetails", () => { @@ -105,7 +119,7 @@ describe("SongService", () => { duration: 200, }; - test("creates song and calls search.sync", async () => { + test("creates song and calls outbox.createEntry", async () => { const result = await songService.create(createInput); expect(result).toMatchObject({ @@ -113,29 +127,14 @@ describe("SongService", () => { type: "ORIGINAL", duration: 180, }); - expect(mockRepository.create).toHaveBeenCalledWith(createInput); - }); - - test("create song even when search.sync throws", async () => { - mockSearchService.sync.mockImplementationOnce(async () => { - throw new Error("Search service unavailable"); - }); - - const result = await songService.create(createInput); - - expect(result).toMatchObject({ - name: "Test Song", - type: "ORIGINAL", - duration: 180, - }); - expect(mockRepository.create).toHaveBeenCalledWith(createInput); + expect(mockRepository.create).toHaveBeenCalled(); }); }); describe("update", () => { const updateInput = { name: "Updated Song" }; - test("updates song and calls search.sync on success", async () => { + test("updates song and calls outbox.createEntry on success", async () => { const result = await songService.update(1, updateInput); expect(result).toMatchObject({ @@ -144,7 +143,7 @@ describe("SongService", () => { duration: 180, }); expect(mockRepository.getById).toHaveBeenCalledWith(1); - expect(mockRepository.update).toHaveBeenCalledWith(1, updateInput); + expect(mockRepository.update).toHaveBeenCalled(); }); test("throws NOT_FOUND error when song does not exist", async () => { @@ -157,29 +156,14 @@ describe("SongService", () => { statusCode: 404, }); }); - - test("updates song even when search.sync throws", async () => { - mockSearchService.sync.mockImplementationOnce(async () => { - throw new Error("Search service unavailable"); - }); - - const result = await songService.update(1, updateInput); - - expect(result).toMatchObject({ - name: "Test Song", - type: "ORIGINAL", - duration: 180, - }); - expect(mockRepository.update).toHaveBeenCalledWith(1, updateInput); - }); }); describe("delete", () => { - test("soft deletes song and calls search.sync on success", async () => { + test("soft deletes song and calls outbox.createEntry on success", async () => { await songService.delete(1); expect(mockRepository.getById).toHaveBeenCalledWith(1); - expect(mockRepository.softDelete).toHaveBeenCalledWith(1); + expect(mockRepository.softDelete).toHaveBeenCalled(); }); test("throws NOT_FOUND error when song does not exist", async () => { @@ -192,16 +176,6 @@ describe("SongService", () => { statusCode: 404, }); }); - - test("deletes song even when search.sync throws", async () => { - mockSearchService.sync.mockImplementationOnce(async () => { - throw new Error("Search service unavailable"); - }); - - await songService.delete(1); - - expect(mockRepository.softDelete).toHaveBeenCalledWith(1); - }); }); describe("listLyrics", () => { @@ -282,7 +256,7 @@ describe("SongService", () => { language: "zh", plainText: "Test lyrics", }); - expect(mockRepository.createLyrics).toHaveBeenCalledWith(1, createInput); + expect(mockRepository.createLyrics).toHaveBeenCalledWith(1, createInput, expect.anything()); }); test("throws NOT_FOUND error when song does not exist", async () => { @@ -313,7 +287,7 @@ describe("SongService", () => { const result = await songService.updateLyric(1, 1, updateInput); expect(result.plainText).toBe("Updated lyrics"); - expect(mockRepository.updateLyric).toHaveBeenCalledWith(1, updateInput); + expect(mockRepository.updateLyric).toHaveBeenCalledWith(1, updateInput, expect.anything()); }); test("throws NOT_FOUND error when song does not exist", async () => { @@ -350,7 +324,7 @@ describe("SongService", () => { await songService.deleteLyric(1, 1); - expect(mockRepository.softDeleteLyric).toHaveBeenCalledWith(1); + expect(mockRepository.softDeleteLyric).toHaveBeenCalledWith(1, expect.anything()); }); test("throws NOT_FOUND error when song does not exist", async () => { diff --git a/packages/db/prisma/models/meta/outbox.prisma b/packages/db/prisma/models/meta/outbox.prisma new file mode 100644 index 0000000..5416ad1 --- /dev/null +++ b/packages/db/prisma/models/meta/outbox.prisma @@ -0,0 +1,27 @@ +model Outbox { + id Int @id @default(autoincrement()) + aggregateType String @map("aggregate_type") + aggregateId Int @map("aggregate_id") + eventType String @map("event_type") + payload Json? + status OutboxStatus @default(PENDING) + retryCount Int @default(0) @map("retry_count") + lastError String? @map("last_error") + nextRetryAt DateTime? @map("next_retry_at") + createdAt DateTime @default(now()) @map("created_at") + processedAt DateTime? @map("processed_at") + + @@index([status, nextRetryAt]) + @@map("outbox") + @@schema("meta") +} + +enum OutboxStatus { + PENDING + PROCESSING + PROCESSED + FAILED + + @@map("outbox_status") + @@schema("meta") +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index de280a4..b28453c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,7 +2,7 @@ import type { PrismaClient } from "./types"; export type TxClient = Omit< PrismaClient, - "$transaction" | "$connect" | "$disconnect" | "$on" | "$use" + "$transaction" | "$connect" | "$disconnect" | "$on" | "$use" | "$extends" >; export * from "./prisma"; diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 26231f0..1d5e9e8 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -12,6 +12,8 @@ export const env = createEnv({ OTEL_SERVICE_VERSION: z.string().optional().default("0.0.0"), LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).optional().default("info"), + + REDIS_URL: z.string().optional().default("redis://127.0.0.1:6379"), }, runtimeEnv: import.meta.env, From 000451fe964e2725be0021c6e7f12a4c49a2988f Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sat, 25 Apr 2026 02:24:03 +0800 Subject: [PATCH 5/8] refactor(core): simplify code in service&repository - Add BaseRepository with `query()` helper that wraps traceTask + transformPrismaResult, removing callback nesting from all repository methods - Migrate ArtistRepository, SongRepository, and EngineRepository to extend BaseRepository; method bodies are now fully flat - Remove traceTask from ArtistService and SongService, leaving only `$transaction` as the single nesting layer - Split OutboxService.createEntry into write-only (tx-bound) and enqueue (post-tx queue dispatch) to guarantee data consistency - Fix SongService unit test mock to include the new enqueue method --- .../src/modules/catalog/artist/repository.ts | 37 ++-- .../src/modules/catalog/artist/service.ts | 95 ++++----- .../src/modules/catalog/engine/repository.ts | 37 ++-- .../src/modules/catalog/song/repository.ts | 58 +++--- .../core/src/modules/catalog/song/service.ts | 184 +++++++++--------- packages/core/src/modules/outbox/service.ts | 23 ++- packages/core/src/utils/BaseRepository.ts | 11 ++ packages/core/src/utils/index.ts | 1 + packages/core/tests/unit/SongService.test.ts | 1 + 9 files changed, 244 insertions(+), 203 deletions(-) create mode 100644 packages/core/src/utils/BaseRepository.ts diff --git a/packages/core/src/modules/catalog/artist/repository.ts b/packages/core/src/modules/catalog/artist/repository.ts index 0897f52..7704c4c 100644 --- a/packages/core/src/modules/catalog/artist/repository.ts +++ b/packages/core/src/modules/catalog/artist/repository.ts @@ -1,4 +1,6 @@ import type { PrismaClient } from "@cvsa/db"; +import type { TxClient } from "@cvsa/db"; +import { BaseRepository } from "@cvsa/core/internal"; import type { CreateArtistRequestDto, ArtistId, @@ -6,15 +8,16 @@ import type { ArtistDetailsResponseDto, } from "./dto"; import type { IArtistRepository } from "./repository.interface"; -import { transformPrismaResult, type TxClient } from "@cvsa/db"; -export class ArtistRepository implements IArtistRepository { - constructor(private readonly prisma: PrismaClient) {} +export class ArtistRepository extends BaseRepository implements IArtistRepository { + constructor(private readonly prisma: PrismaClient) { + super(); + } async getById(id: ArtistId, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.artist.findFirst({ + return this.query("db.artist.getById", () => + client.artist.findFirst({ where: { id, deletedAt: null }, omit: { deletedAt: true }, }) @@ -23,8 +26,8 @@ export class ArtistRepository implements IArtistRepository { async getDetailsById(id: ArtistId, tx?: TxClient): Promise { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.artist.findFirst({ + return this.query("db.artist.getDetailsById", () => + client.artist.findFirst({ where: { id, deletedAt: null }, omit: { deletedAt: true }, }) @@ -33,9 +36,8 @@ export class ArtistRepository implements IArtistRepository { async create(input: CreateArtistRequestDto, tx?: TxClient) { const client = tx ?? this.prisma; - - return transformPrismaResult( - await client.artist.create({ + return this.query("db.artist.create", () => + client.artist.create({ data: input, omit: { deletedAt: true }, }) @@ -44,9 +46,8 @@ export class ArtistRepository implements IArtistRepository { async update(id: ArtistId, input: UpdateArtistRequestDto, tx?: TxClient) { const client = tx ?? this.prisma; - - return transformPrismaResult( - await client.artist.update({ + return this.query("db.artist.update", () => + client.artist.update({ where: { id }, data: input, omit: { deletedAt: true }, @@ -56,9 +57,11 @@ export class ArtistRepository implements IArtistRepository { async softDelete(id: ArtistId, tx?: TxClient) { const client = tx ?? this.prisma; - await client.artist.update({ - where: { id }, - data: { deletedAt: new Date() }, - }); + await this.query("db.artist.softDelete", () => + client.artist.update({ + where: { id }, + data: { deletedAt: new Date() }, + }) + ); } } diff --git a/packages/core/src/modules/catalog/artist/service.ts b/packages/core/src/modules/catalog/artist/service.ts index bc07fec..4e0c977 100644 --- a/packages/core/src/modules/catalog/artist/service.ts +++ b/packages/core/src/modules/catalog/artist/service.ts @@ -1,5 +1,6 @@ import type { OutboxService } from "@cvsa/core/internal"; import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal"; +import { prisma } from "@cvsa/db"; import type { ArtistDetailsResponseDto, ArtistId, @@ -8,8 +9,6 @@ import type { ArtistResponseDto, } from "./dto"; import type { IArtistRepository } from "./repository.interface"; -import { traceTask } from "@cvsa/observability"; -import { prisma } from "@cvsa/db"; export class ArtistService implements IServiceWithGetDetails { constructor( @@ -18,30 +17,29 @@ export class ArtistService implements IServiceWithGetDetails { - const result = await this.repository.getDetailsById(id); - if (result === null) { - throw new AppError("error.artist.notfound", "NOT_FOUND", 404); - } - return result; - }); + const result = await this.repository.getDetailsById(id); + if (result === null) { + throw new AppError("error.artist.notfound", "NOT_FOUND", 404); + } + return result; } async create(input: CreateArtistRequestDto): Promise { - return traceTask("db create artist", async () => { - return await prisma.$transaction(async (tx) => { - const result = await this.repository.create(input, tx); - await this.outbox.createEntry( - { - aggregateType: "artist", - aggregateId: result.id, - eventType: "artist.created", - }, - tx - ); - return result; - }); + const { artist, entry } = await prisma.$transaction(async (tx) => { + const artist = await this.repository.create(input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "artist", + aggregateId: artist.id, + eventType: "artist.created", + }, + tx + ); + return { artist, entry }; }); + + await this.outbox.enqueue(entry); + return artist; } async update(id: ArtistId, input: UpdateArtistRequestDto): Promise { @@ -49,20 +47,22 @@ export class ArtistService implements IServiceWithGetDetails { - return await prisma.$transaction(async (tx) => { - const result = await this.repository.update(id, input, tx); - await this.outbox.createEntry( - { - aggregateType: "artist", - aggregateId: id, - eventType: "artist.updated", - }, - tx - ); - return result; - }); + + const { artist, entry } = await prisma.$transaction(async (tx) => { + const artist = await this.repository.update(id, input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "artist", + aggregateId: id, + eventType: "artist.updated", + }, + tx + ); + return { artist, entry }; }); + + await this.outbox.enqueue(entry); + return artist; } async delete(id: ArtistId): Promise { @@ -70,18 +70,19 @@ export class ArtistService implements IServiceWithGetDetails { - await prisma.$transaction(async (tx) => { - await this.repository.softDelete(id, tx); - await this.outbox.createEntry( - { - aggregateType: "artist", - aggregateId: id, - eventType: "artist.deleted", - }, - tx - ); - }); + + const entry = await prisma.$transaction(async (tx) => { + await this.repository.softDelete(id, tx); + return await this.outbox.createEntry( + { + aggregateType: "artist", + aggregateId: id, + eventType: "artist.deleted", + }, + tx + ); }); + + await this.outbox.enqueue(entry); } } diff --git a/packages/core/src/modules/catalog/engine/repository.ts b/packages/core/src/modules/catalog/engine/repository.ts index 23670e2..0882c3d 100644 --- a/packages/core/src/modules/catalog/engine/repository.ts +++ b/packages/core/src/modules/catalog/engine/repository.ts @@ -1,4 +1,6 @@ import type { PrismaClient } from "@cvsa/db"; +import type { TxClient } from "@cvsa/db"; +import { BaseRepository } from "@cvsa/core/internal"; import type { CreateEngineRequestDto, EngineId, @@ -6,15 +8,16 @@ import type { EngineDetailsResponseDto, } from "./dto"; import type { IEngineRepository } from "./repository.interface"; -import { transformPrismaResult, type TxClient } from "@cvsa/db"; -export class EngineRepository implements IEngineRepository { - constructor(private readonly prisma: PrismaClient) {} +export class EngineRepository extends BaseRepository implements IEngineRepository { + constructor(private readonly prisma: PrismaClient) { + super(); + } async getById(id: EngineId, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.svsEngine.findFirst({ + return this.query("db.engine.getById", () => + client.svsEngine.findFirst({ where: { id, deletedAt: null }, omit: { deletedAt: true }, }) @@ -23,8 +26,8 @@ export class EngineRepository implements IEngineRepository { async getDetailsById(id: EngineId, tx?: TxClient): Promise { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.svsEngine.findFirst({ + return this.query("db.engine.getDetailsById", () => + client.svsEngine.findFirst({ where: { id, deletedAt: null }, omit: { deletedAt: true }, }) @@ -33,9 +36,8 @@ export class EngineRepository implements IEngineRepository { async create(input: CreateEngineRequestDto, tx?: TxClient) { const client = tx ?? this.prisma; - - return transformPrismaResult( - await client.svsEngine.create({ + return this.query("db.engine.create", () => + client.svsEngine.create({ data: input, omit: { deletedAt: true }, }) @@ -44,9 +46,8 @@ export class EngineRepository implements IEngineRepository { async update(id: EngineId, input: UpdateEngineRequestDto, tx?: TxClient) { const client = tx ?? this.prisma; - - return transformPrismaResult( - await client.svsEngine.update({ + return this.query("db.engine.update", () => + client.svsEngine.update({ where: { id }, data: input, omit: { deletedAt: true }, @@ -56,9 +57,11 @@ export class EngineRepository implements IEngineRepository { async softDelete(id: EngineId, tx?: TxClient) { const client = tx ?? this.prisma; - await client.svsEngine.update({ - where: { id }, - data: { deletedAt: new Date() }, - }); + await this.query("db.engine.softDelete", () => + client.svsEngine.update({ + where: { id }, + data: { deletedAt: new Date() }, + }) + ); } } diff --git a/packages/core/src/modules/catalog/song/repository.ts b/packages/core/src/modules/catalog/song/repository.ts index 7ac77bc..d44667d 100644 --- a/packages/core/src/modules/catalog/song/repository.ts +++ b/packages/core/src/modules/catalog/song/repository.ts @@ -1,4 +1,6 @@ import type { PrismaClient } from "@cvsa/db"; +import type { TxClient } from "@cvsa/db"; +import { BaseRepository } from "@cvsa/core/internal"; import type { CreateSongRequestDto, SongId, @@ -8,15 +10,16 @@ import type { SongLyricsUpdateRequestDto, } from "./dto"; import type { ISongRepository } from "./repository.interface"; -import { transformPrismaResult, type TxClient } from "@cvsa/db"; -export class SongRepository implements ISongRepository { - constructor(private readonly prisma: PrismaClient) {} +export class SongRepository extends BaseRepository implements ISongRepository { + constructor(private readonly prisma: PrismaClient) { + super(); + } async getById(id: SongId, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.song.findFirst({ + return this.query("db.song.getById", () => + client.song.findFirst({ where: { id, deletedAt: null }, omit: { deletedAt: true }, }) @@ -25,8 +28,8 @@ export class SongRepository implements ISongRepository { async getDetailsById(id: SongId, tx?: TxClient): Promise { const client = tx ?? this.prisma; - const data = transformPrismaResult( - await client.song.findFirst({ + const data = await this.query("db.song.getDetailsById", () => + client.song.findFirst({ where: { id, deletedAt: null }, include: { performances: { @@ -84,8 +87,8 @@ export class SongRepository implements ISongRepository { const client = tx ?? this.prisma; const { performances, creations, lyrics, ...songData } = input; - return transformPrismaResult( - await client.song.create({ + return this.query("db.song.create", () => + client.song.create({ data: { ...songData, performances: performances && { @@ -111,8 +114,8 @@ export class SongRepository implements ISongRepository { async update(id: SongId, input: UpdateSongRequestDto, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.song.update({ + return this.query("db.song.update", () => + client.song.update({ where: { id }, data: input, omit: { @@ -124,14 +127,19 @@ export class SongRepository implements ISongRepository { async softDelete(id: SongId, tx?: TxClient) { const client = tx ?? this.prisma; - await client.song.update({ where: { id }, data: { deletedAt: new Date() } }); + await this.query("db.song.softDelete", () => + client.song.update({ + where: { id }, + data: { deletedAt: new Date() }, + }) + ); } async createLyrics(id: SongId, input: SongLyricsCreateRequestDto, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.lyrics.create({ + return this.query("db.song.createLyrics", () => + client.lyrics.create({ data: { songId: id, ...input, @@ -146,8 +154,8 @@ export class SongRepository implements ISongRepository { async getLyricsBySongId(id: SongId, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.lyrics.findMany({ + return this.query("db.song.getLyricsBySongId", () => + client.lyrics.findMany({ where: { songId: id, deletedAt: null }, omit: { deletedAt: true, songId: true }, }) @@ -157,8 +165,8 @@ export class SongRepository implements ISongRepository { async getLyricById(lyricId: number, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.lyrics.findFirst({ + return this.query("db.song.getLyricById", () => + client.lyrics.findFirst({ where: { id: lyricId, deletedAt: null }, omit: { deletedAt: true, songId: true }, }) @@ -168,8 +176,8 @@ export class SongRepository implements ISongRepository { async updateLyric(lyricId: number, input: SongLyricsUpdateRequestDto, tx?: TxClient) { const client = tx ?? this.prisma; - return transformPrismaResult( - await client.lyrics.update({ + return this.query("db.song.updateLyric", () => + client.lyrics.update({ where: { id: lyricId }, data: input, omit: { deletedAt: true, songId: true }, @@ -180,9 +188,11 @@ export class SongRepository implements ISongRepository { async softDeleteLyric(lyricId: number, tx?: TxClient) { const client = tx ?? this.prisma; - await client.lyrics.update({ - where: { id: lyricId }, - data: { deletedAt: new Date() }, - }); + await this.query("db.song.softDeleteLyric", () => + client.lyrics.update({ + where: { id: lyricId }, + data: { deletedAt: new Date() }, + }) + ); } } diff --git a/packages/core/src/modules/catalog/song/service.ts b/packages/core/src/modules/catalog/song/service.ts index 11657bb..711a7a3 100644 --- a/packages/core/src/modules/catalog/song/service.ts +++ b/packages/core/src/modules/catalog/song/service.ts @@ -1,5 +1,6 @@ import type { OutboxService } from "@cvsa/core/internal"; import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal"; +import { prisma } from "@cvsa/db"; import type { SongDetailsResponseDto, SongId, @@ -12,8 +13,6 @@ import type { SongLyricsListResponseDto, } from "./dto"; import type { ISongRepository } from "./repository.interface"; -import { traceTask } from "@cvsa/observability"; -import { prisma } from "@cvsa/db"; export class SongService implements IServiceWithGetDetails { constructor( @@ -22,30 +21,29 @@ export class SongService implements IServiceWithGetDetails { - const result = await this.repository.getDetailsById(id); - if (result === null) { - throw new AppError("error.song.notfound", "NOT_FOUND", 404); - } - return result; - }); + const result = await this.repository.getDetailsById(id); + if (result === null) { + throw new AppError("error.song.notfound", "NOT_FOUND", 404); + } + return result; } async create(input: CreateSongRequestDto): Promise { - return traceTask("db create song", async () => { - return await prisma.$transaction(async (tx) => { - const result = await this.repository.create(input, tx); - await this.outbox.createEntry( - { - aggregateType: "song", - aggregateId: result.id, - eventType: "song.created", - }, - tx - ); - return result; - }); + const { song, entry } = await prisma.$transaction(async (tx) => { + const song = await this.repository.create(input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: song.id, + eventType: "song.created", + }, + tx + ); + return { song, entry }; }); + + await this.outbox.enqueue(entry); + return song; } async update(id: SongId, input: UpdateSongRequestDto): Promise { @@ -53,20 +51,22 @@ export class SongService implements IServiceWithGetDetails { - return await prisma.$transaction(async (tx) => { - const result = await this.repository.update(id, input, tx); - await this.outbox.createEntry( - { - aggregateType: "song", - aggregateId: id, - eventType: "song.updated", - }, - tx - ); - return result; - }); + + const { song, entry } = await prisma.$transaction(async (tx) => { + const song = await this.repository.update(id, input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.updated", + }, + tx + ); + return { song, entry }; }); + + await this.outbox.enqueue(entry); + return song; } async delete(id: SongId): Promise { @@ -74,19 +74,20 @@ export class SongService implements IServiceWithGetDetails { - await prisma.$transaction(async (tx) => { - await this.repository.softDelete(id, tx); - await this.outbox.createEntry( - { - aggregateType: "song", - aggregateId: id, - eventType: "song.deleted", - }, - tx - ); - }); + + const entry = await prisma.$transaction(async (tx) => { + await this.repository.softDelete(id, tx); + return await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.deleted", + }, + tx + ); }); + + await this.outbox.enqueue(entry); } async listLyrics(id: SongId): Promise { @@ -94,9 +95,7 @@ export class SongService implements IServiceWithGetDetails { - return await this.repository.getLyricsBySongId(id); - }); + return this.repository.getLyricsBySongId(id); } async getLyric(id: SongId, lyricId: number): Promise { @@ -104,9 +103,7 @@ export class SongService implements IServiceWithGetDetails { - return await this.repository.getLyricById(lyricId); - }); + const lyric = await this.repository.getLyricById(lyricId); if (lyric === null) { throw new AppError("error.lyric.notfound", "NOT_FOUND", 404); } @@ -121,20 +118,22 @@ export class SongService implements IServiceWithGetDetails { - return await prisma.$transaction(async (tx) => { - const result = await this.repository.createLyrics(id, input, tx); - await this.outbox.createEntry( - { - aggregateType: "song", - aggregateId: id, - eventType: "song.lyric_created", - }, - tx - ); - return result; - }); + + const { lyric, entry } = await prisma.$transaction(async (tx) => { + const lyric = await this.repository.createLyrics(id, input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.lyric_created", + }, + tx + ); + return { lyric, entry }; }); + + await this.outbox.enqueue(entry); + return lyric; } async updateLyric( @@ -150,20 +149,22 @@ export class SongService implements IServiceWithGetDetails { - return await prisma.$transaction(async (tx) => { - const result = await this.repository.updateLyric(lyricId, input, tx); - await this.outbox.createEntry( - { - aggregateType: "song", - aggregateId: id, - eventType: "song.lyric_updated", - }, - tx - ); - return result; - }); + + const { lyric: updated, entry } = await prisma.$transaction(async (tx) => { + const lyric = await this.repository.updateLyric(lyricId, input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.lyric_updated", + }, + tx + ); + return { lyric, entry }; }); + + await this.outbox.enqueue(entry); + return updated; } async deleteLyric(id: SongId, lyricId: number): Promise { @@ -175,18 +176,19 @@ export class SongService implements IServiceWithGetDetails { - await prisma.$transaction(async (tx) => { - await this.repository.softDeleteLyric(lyricId, tx); - await this.outbox.createEntry( - { - aggregateType: "song", - aggregateId: id, - eventType: "song.lyric_deleted", - }, - tx - ); - }); + + const entry = await prisma.$transaction(async (tx) => { + await this.repository.softDeleteLyric(lyricId, tx); + return await this.outbox.createEntry( + { + aggregateType: "song", + aggregateId: id, + eventType: "song.lyric_deleted", + }, + tx + ); }); + + await this.outbox.enqueue(entry); } } diff --git a/packages/core/src/modules/outbox/service.ts b/packages/core/src/modules/outbox/service.ts index a97d2a6..fde4181 100644 --- a/packages/core/src/modules/outbox/service.ts +++ b/packages/core/src/modules/outbox/service.ts @@ -3,20 +3,29 @@ import type { CreateOutboxEntryDto, OutboxEntryDto, PendingOutboxQueryDto } from import { Prisma, type TxClient } from "@cvsa/db"; import { outboxQueue } from "../../outbox/queue"; import { appLogger } from "@cvsa/logger"; +import { traceTask } from "@cvsa/observability"; const MAX_RETRIES = 5; export class OutboxService { constructor(private readonly repository: IOutboxRepository) {} - async createEntry(input: CreateOutboxEntryDto, tx?: TxClient): Promise { - const entry = await this.repository.create(input, tx); - await outboxQueue.add(`outbox-${entry.aggregateType}-${entry.aggregateId}`, entry, { - jobId: `outbox-${entry.id}`, - attempts: MAX_RETRIES, - backoff: { type: "exponential", delay: 1000 }, + async createEntry(input: CreateOutboxEntryDto, tx: TxClient): Promise { + return traceTask("outbox.createEntry", () => this.repository.create(input, tx)); + } + + async enqueue(entry: OutboxEntryDto): Promise { + await traceTask("outbox.enqueue", async () => { + await outboxQueue.add( + `outbox-${entry.aggregateType}-${entry.aggregateId}`, + entry, + { + jobId: `outbox-${entry.id}`, + attempts: MAX_RETRIES, + backoff: { type: "exponential", delay: 1000 }, + } + ); }); - return entry; } async processEntry( diff --git a/packages/core/src/utils/BaseRepository.ts b/packages/core/src/utils/BaseRepository.ts new file mode 100644 index 0000000..74cffe3 --- /dev/null +++ b/packages/core/src/utils/BaseRepository.ts @@ -0,0 +1,11 @@ +import { transformPrismaResult, type Serialized } from "@cvsa/db"; +import { traceTask } from "@cvsa/observability"; + +export abstract class BaseRepository { + protected async query( + name: string, + fn: () => Promise + ): Promise> { + return traceTask(name, async () => transformPrismaResult(await fn())); + } +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index a681d76..c03d97a 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,2 +1,3 @@ export * from "./randomId"; export * from "./deepEqualUnordered"; +export * from "./BaseRepository"; diff --git a/packages/core/tests/unit/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts index eb55c97..2334a45 100644 --- a/packages/core/tests/unit/SongService.test.ts +++ b/packages/core/tests/unit/SongService.test.ts @@ -83,6 +83,7 @@ describe("SongService", () => { createdAt: new Date().toISOString(), processedAt: null, })), + enqueue: mock(async () => {}), processEntry: mock(async () => {}), recoverStaleEntries: mock(async () => {}), }; From 184db31e2a3cd741f189b274125a560bd9b30d64 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sat, 25 Apr 2026 02:55:40 +0800 Subject: [PATCH 6/8] test(core): add artist/engine tests, fix search sync Add unit and integration tests for artist and engine modules. Add search index cleanup script for test isolation. Refactor catalog module imports to avoid barrel dependency issues. --- package.json | 6 +- packages/core/package.json | 2 + packages/core/scripts/cleanUpSearchIndex.ts | 13 ++ .../src/modules/catalog/artist/repository.ts | 2 +- .../src/modules/catalog/artist/service.ts | 5 +- .../src/modules/catalog/engine/repository.ts | 2 +- .../src/modules/catalog/engine/service.ts | 3 +- .../src/modules/catalog/song/repository.ts | 2 +- packages/core/src/search/catalog/song.ts | 11 +- packages/core/src/search/manager.ts | 33 ++- .../integration/ArtistRepository.test.ts | 148 ++++++++++++++ .../integration/EngineRepository.test.ts | 138 +++++++++++++ .../integration/SongSearchService.test.ts | 94 ++------- .../core/tests/unit/ArtistService.test.ts | 193 ++++++++++++++++++ .../core/tests/unit/EngineService.test.ts | 124 +++++++++++ .../core/tests/unit/SearchManager.test.ts | 8 +- .../core/tests/unit/SongSearchService.test.ts | 5 +- turbo.json | 6 + 18 files changed, 690 insertions(+), 105 deletions(-) create mode 100644 packages/core/scripts/cleanUpSearchIndex.ts create mode 100644 packages/core/tests/integration/ArtistRepository.test.ts create mode 100644 packages/core/tests/integration/EngineRepository.test.ts create mode 100644 packages/core/tests/unit/ArtistService.test.ts create mode 100644 packages/core/tests/unit/EngineService.test.ts diff --git a/package.json b/package.json index 4d457f5..94e7b5e 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,8 @@ "lint:fix": "bunx --bun biome check --write --unsafe .", "format": "bunx --bun biome format --write .", "ci": "bunx --bun biome ci .", - "test": "bunx --bun turbo run @cvsa/db#test:reset && bunx --bun turbo run test", - "test:backend": "bunx --bun turbo run test --filter=@cvsa/backend", - "test:core": "bunx --bun turbo run test --filter=@cvsa/core", - "test:coverage": "bunx --bun turbo run test:coverage", + "test": "bunx --bun turbo run test:reset && bunx --bun turbo run test", + "test:coverage": "bunx --bun turbo run test:reset && bunx --bun turbo run test:coverage", "typecheck": "bunx --bun turbo typecheck", "setup": "bunx --bun turbo setup", "setup:ci": "bunx --bun turbo setup:ci" diff --git a/packages/core/package.json b/packages/core/package.json index 4785050..92923f3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,6 +7,8 @@ "scripts": { "lint": "bunx --bun biome lint", "test": "bun test --concurrent", + "test:reset": "NODE_ENV=test bun scripts/cleanUpSearchIndex.ts", + "test:safe": "bun clear-search-index && bun run test", "test:coverage": "bun test --coverage", "test:unit": "bun test tests/unit --concurrent", "test:integration": "bun test tests/integration --concurrent", diff --git a/packages/core/scripts/cleanUpSearchIndex.ts b/packages/core/scripts/cleanUpSearchIndex.ts new file mode 100644 index 0000000..b6f1405 --- /dev/null +++ b/packages/core/scripts/cleanUpSearchIndex.ts @@ -0,0 +1,13 @@ +import { searchManager } from "../src"; +import { INDEX_SETTINGS } from "../src/search/config"; + +await searchManager.clearAllIndex(); +const langs = ["en", "zh", "ja"]; +const keys = Object.keys(INDEX_SETTINGS); +for (const lang of langs) { + for (const key of keys) { + await searchManager.createIndex(`${key}_${lang}`); + } +} + +process.exit(0); diff --git a/packages/core/src/modules/catalog/artist/repository.ts b/packages/core/src/modules/catalog/artist/repository.ts index 7704c4c..279796d 100644 --- a/packages/core/src/modules/catalog/artist/repository.ts +++ b/packages/core/src/modules/catalog/artist/repository.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from "@cvsa/db"; import type { TxClient } from "@cvsa/db"; -import { BaseRepository } from "@cvsa/core/internal"; +import { BaseRepository } from "../../../utils/BaseRepository"; import type { CreateArtistRequestDto, ArtistId, diff --git a/packages/core/src/modules/catalog/artist/service.ts b/packages/core/src/modules/catalog/artist/service.ts index 4e0c977..8f9dd26 100644 --- a/packages/core/src/modules/catalog/artist/service.ts +++ b/packages/core/src/modules/catalog/artist/service.ts @@ -1,5 +1,6 @@ -import type { OutboxService } from "@cvsa/core/internal"; -import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal"; +import type { OutboxService } from "../../outbox/service"; +import { AppError } from "../../../error/AppError"; +import type { IServiceWithGetDetails } from "../../../types/service"; import { prisma } from "@cvsa/db"; import type { ArtistDetailsResponseDto, diff --git a/packages/core/src/modules/catalog/engine/repository.ts b/packages/core/src/modules/catalog/engine/repository.ts index 0882c3d..27358f8 100644 --- a/packages/core/src/modules/catalog/engine/repository.ts +++ b/packages/core/src/modules/catalog/engine/repository.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from "@cvsa/db"; import type { TxClient } from "@cvsa/db"; -import { BaseRepository } from "@cvsa/core/internal"; +import { BaseRepository } from "../../../utils/BaseRepository"; import type { CreateEngineRequestDto, EngineId, diff --git a/packages/core/src/modules/catalog/engine/service.ts b/packages/core/src/modules/catalog/engine/service.ts index 6f46ea5..c463249 100644 --- a/packages/core/src/modules/catalog/engine/service.ts +++ b/packages/core/src/modules/catalog/engine/service.ts @@ -1,4 +1,5 @@ -import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal"; +import { AppError } from "../../../error/AppError"; +import type { IServiceWithGetDetails } from "../../../types/service"; import type { EngineDetailsResponseDto, EngineId, diff --git a/packages/core/src/modules/catalog/song/repository.ts b/packages/core/src/modules/catalog/song/repository.ts index d44667d..3de15aa 100644 --- a/packages/core/src/modules/catalog/song/repository.ts +++ b/packages/core/src/modules/catalog/song/repository.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from "@cvsa/db"; import type { TxClient } from "@cvsa/db"; -import { BaseRepository } from "@cvsa/core/internal"; +import { BaseRepository } from "../../../utils/BaseRepository"; import type { CreateSongRequestDto, SongId, diff --git a/packages/core/src/search/catalog/song.ts b/packages/core/src/search/catalog/song.ts index 95f3975..5449fb6 100644 --- a/packages/core/src/search/catalog/song.ts +++ b/packages/core/src/search/catalog/song.ts @@ -130,12 +130,15 @@ Artists: ${getArtists().join(", ")} const embeddingResponse = await this.embeddingManager.embeddings.post({ texts: [query], }); + const embeddingAvailable = (embeddingResponse?.data?.embeddings[0]?.length ?? 0) > 0; return index.search(query, { vector: embeddingResponse?.data?.embeddings[0], - hybrid: { - embedder: "potion-multilingual-128M", - semanticRatio: 0.25, - }, + hybrid: embeddingAvailable + ? { + embedder: "potion-multilingual-128M", + semanticRatio: 0.25, + } + : undefined, showRankingScore: true, }); } diff --git a/packages/core/src/search/manager.ts b/packages/core/src/search/manager.ts index d9ab328..96c72af 100644 --- a/packages/core/src/search/manager.ts +++ b/packages/core/src/search/manager.ts @@ -1,4 +1,4 @@ -import { MeiliSearch, type Settings, type RecordAny } from "meilisearch"; +import { MeiliSearch, type Settings, type RecordAny, IndexOptions } from "meilisearch"; import { env } from "@cvsa/env"; import { INDEX_SETTINGS } from "./config"; import { deepEqualUnordered } from "../utils"; @@ -43,15 +43,15 @@ export class SearchManager { }); const adminKey = await SearchManager.getAdminKey(masterClient); const searchKey = await SearchManager.getSearchKey(masterClient); - (this as unknown as { client: MeiliSearch }).client = new MeiliSearch({ + this.client = new MeiliSearch({ host: env.MEILI_API_URL, apiKey: searchKey, }); - (this as unknown as { adminClient: MeiliSearch }).adminClient = new MeiliSearch({ + this.adminClient = new MeiliSearch({ host: env.MEILI_API_URL, apiKey: adminKey, }); - this.syncAllSettings(); + await this.syncAllSettings(); } catch (e) { appLogger.warn("Cannot initialize SearchManager clients."); appLogger.error(Bun.inspect(e)); @@ -214,10 +214,31 @@ export class SearchManager { if (deepEqualUnordered(value, currentSettings[key as keyof Settings])) continue; // These settings do not trigger a full reindex if (["displayedAttributes", "rankingRules"].includes(key)) continue; - await index.resetSettings(); - await index.updateSettings(settings); + const resetTask = await index.resetSettings(); + await this.waitForTask(resetTask.taskUid); + const updateTask = await index.updateSettings(settings); + await this.waitForTask(updateTask.taskUid); break; } } } + + public async clearAllIndex() { + if (this.adminClient === undefined) { + return; + } + + const { results: indexes } = await this.listIndexes(); + for (const index of indexes) { + await this.adminClient.deleteIndexIfExists(index.uid); + appLogger.info(`Deleted search index ${index.uid}`); + } + } + + public async createIndex(uid: string, options?: IndexOptions) { + if (this.adminClient === undefined) { + return; + } + return this.adminClient.createIndex(uid, options); + } } diff --git a/packages/core/tests/integration/ArtistRepository.test.ts b/packages/core/tests/integration/ArtistRepository.test.ts new file mode 100644 index 0000000..c38f3a9 --- /dev/null +++ b/packages/core/tests/integration/ArtistRepository.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test"; +import { artistRepository, type CreateArtistRequestDto, type UpdateArtistRequestDto } from "@cvsa/core"; + +const repository = artistRepository; + +describe("ArtistRepository Integration Tests", () => { + describe("create", () => { + test("should create an artist with all fields", async () => { + const input: CreateArtistRequestDto = { + name: "月华P", + localizedNames: { ja: "月華P", en: "Gekka P" }, + language: "zh", + aliases: ["月光P", "Moonlight"], + description: "A famous composer in the Chinese vocal synth community", + localizedDescriptions: { + en: "A famous composer", + ja: "有名な作曲家", + }, + }; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.name).toBe("月华P"); + expect(result.localizedNames).toEqual({ ja: "月華P", en: "Gekka P" }); + expect(result.language).toBe("zh"); + expect(result.aliases).toEqual(["月光P", "Moonlight"]); + expect(result.description).toBe("A famous composer in the Chinese vocal synth community"); + expect(result.localizedDescriptions).toEqual({ + en: "A famous composer", + ja: "有名な作曲家", + }); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result.deletedAt).not.toBeDefined(); + }); + + test("should create an artist with minimal fields", async () => { + const input: CreateArtistRequestDto = { + name: "无名P", + }; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.name).toBe("无名P"); + expect(result.localizedNames).toBeNull(); + // language has default value "zh" in database + expect(result.language).toBe("zh"); + // aliases is String[] with default empty array + expect(result.aliases).toEqual([]); + expect(result.description).toBeNull(); + expect(result.localizedDescriptions).toBeNull(); + }); + }); + + describe("getById", () => { + test("should return artist when exists", async () => { + const created = await repository.create({ name: "赤羽P" }); + const result = await repository.getById(created.id); + + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.name).toBe("赤羽P"); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result?.deletedAt).not.toBeDefined(); + }); + + test("should return null when artist does not exist", async () => { + const result = await repository.getById(999999); + expect(result).toBeNull(); + }); + }); + + describe("getDetailsById", () => { + test("should return artist details when exists", async () => { + const created = await repository.create({ + name: "星尘P", + description: "Composer of 星尘 series", + }); + const result = await repository.getDetailsById(created.id); + + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.name).toBe("星尘P"); + expect(result?.description).toBe("Composer of 星尘 series"); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result?.deletedAt).not.toBeDefined(); + }); + + test("should return null when artist does not exist", async () => { + const result = await repository.getDetailsById(999999); + expect(result).toBeNull(); + }); + }); + + describe("update", () => { + test("should update all fields", async () => { + const created = await repository.create({ + name: "Old Name", + language: "zh", + aliases: ["old"], + }); + + const input: UpdateArtistRequestDto = { + name: "New Name", + language: "en", + aliases: ["new", "alias"], + description: "Updated description", + }; + + const result = await repository.update(created.id, input); + + expect(result.name).toBe("New Name"); + expect(result.language).toBe("en"); + expect(result.aliases).toEqual(["new", "alias"]); + expect(result.description).toBe("Updated description"); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result.deletedAt).not.toBeDefined(); + }); + + test("should update only specified fields", async () => { + const created = await repository.create({ + name: "Original Name", + language: "zh", + description: "Original description", + }); + + const result = await repository.update(created.id, { name: "Updated Name" }); + + expect(result.name).toBe("Updated Name"); + expect(result.language).toBe("zh"); + expect(result.description).toBe("Original description"); + }); + }); + + describe("softDelete", () => { + test("should soft delete an artist", async () => { + const created = await repository.create({ name: "Artist to Delete" }); + + await repository.softDelete(created.id); + + const result = await repository.getById(created.id); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/tests/integration/EngineRepository.test.ts b/packages/core/tests/integration/EngineRepository.test.ts new file mode 100644 index 0000000..da45528 --- /dev/null +++ b/packages/core/tests/integration/EngineRepository.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { engineRepository, type CreateEngineRequestDto, type UpdateEngineRequestDto } from "@cvsa/core"; + +const repository = engineRepository; + +describe("EngineRepository Integration Tests", () => { + describe("create", () => { + test("should create an engine with all fields", async () => { + const input: CreateEngineRequestDto = { + name: "VOCALOID", + description: "The latest version of VOCALOID synthesis engine", + localizedDescriptions: { + en: "The latest version of VOCALOID", + ja: "VOCALOIDの最新版", + zh: "VOCALOID最新版", + }, + }; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.name).toBe("VOCALOID"); + expect(result.description).toBe("The latest version of VOCALOID synthesis engine"); + expect(result.localizedDescriptions).toEqual({ + en: "The latest version of VOCALOID", + ja: "VOCALOIDの最新版", + zh: "VOCALOID最新版", + }); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result.deletedAt).not.toBeDefined(); + }); + + test("should create an engine with minimal fields (name only)", async () => { + const input: CreateEngineRequestDto = { + name: "Synthesizer V", + }; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.name).toBe("Synthesizer V"); + expect(result.description).toBeNull(); + expect(result.localizedDescriptions).toBeNull(); + }); + }); + + describe("getById", () => { + test("should return engine when exists", async () => { + const created = await repository.create({ name: "UTAU" }); + const result = await repository.getById(created.id); + + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.name).toBe("UTAU"); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result?.deletedAt).not.toBeDefined(); + }); + + test("should return null when engine does not exist", async () => { + const result = await repository.getById(999999); + expect(result).toBeNull(); + }); + }); + + describe("getDetailsById", () => { + test("should return engine details when exists", async () => { + const created = await repository.create({ + name: "ACE Studio", + description: "AI-powered singing synthesis engine", + }); + const result = await repository.getDetailsById(created.id); + + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.name).toBe("ACE Studio"); + expect(result?.description).toBe("AI-powered singing synthesis engine"); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result?.deletedAt).not.toBeDefined(); + }); + + test("should return null when engine does not exist", async () => { + const result = await repository.getDetailsById(999999); + expect(result).toBeNull(); + }); + }); + + describe("update", () => { + test("should update all fields", async () => { + const created = await repository.create({ + name: "Old Engine", + description: "Old description", + }); + + const input: UpdateEngineRequestDto = { + name: "Updated Engine", + description: "Updated description", + localizedDescriptions: { + en: "Updated", + }, + }; + + const result = await repository.update(created.id, input); + + expect(result.name).toBe("Updated Engine"); + expect(result.description).toBe("Updated description"); + expect(result.localizedDescriptions).toEqual({ en: "Updated" }); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result.deletedAt).not.toBeDefined(); + }); + + test("should update only specified fields", async () => { + const created = await repository.create({ + name: "Original Engine", + description: "Original description", + localizedDescriptions: { zh: "原始描述" }, + }); + + const result = await repository.update(created.id, { name: "Renamed Engine" }); + + expect(result.name).toBe("Renamed Engine"); + expect(result.description).toBe("Original description"); + expect(result.localizedDescriptions).toEqual({ zh: "原始描述" }); + }); + }); + + describe("softDelete", () => { + test("should soft delete an engine", async () => { + const created = await repository.create({ name: "Engine to Delete" }); + + await repository.softDelete(created.id); + + const result = await repository.getById(created.id); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/tests/integration/SongSearchService.test.ts b/packages/core/tests/integration/SongSearchService.test.ts index da42d5e..238e88a 100644 --- a/packages/core/tests/integration/SongSearchService.test.ts +++ b/packages/core/tests/integration/SongSearchService.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { songRepository } from "@cvsa/core"; import { prisma } from "@cvsa/db"; import { SongSearchService } from "../../src/search/catalog/song"; @@ -10,66 +10,22 @@ import { MeiliSearch } from "meilisearch"; const MEILI_HOST = env.MEILI_API_URL ?? "http://127.0.0.1:7700"; const MEILI_MASTER_KEY = env.MEILI_MASTER_KEY ?? ""; -const waitForTask = async (client: MeiliSearch, taskUid: number) => { - return client.tasks.waitForTask(taskUid, { timeout: 10000, interval: 100 }); -}; - -const cleanupIndex = async (client: MeiliSearch, indexName: string) => { - try { - const task = await client.deleteIndex(indexName); - await waitForTask(client, task.taskUid); - } catch { - // Index may not exist - } -}; - -const createIndexIfNotExists = async (client: MeiliSearch, indexName: string) => { - try { - await client.getIndex(indexName); - } catch { - const task = await client.createIndex(indexName, { primaryKey: "id" }); - await waitForTask(client, task.taskUid); - } -}; +const client = new MeiliSearch({ host: MEILI_HOST, apiKey: MEILI_MASTER_KEY }); +const searchManager = await SearchManager.create(); +const mockEmbeddingManager: EmbeddingAppApi = { + embeddings: { + post: async ({ texts }: { texts: string[] }) => { + const dimensions = 256; + const embeddings = texts.map(() => + Array.from({ length: dimensions }, () => Math.random()) + ); + return { data: { embeddings } } as never; + }, + }, +} as never; +const service = new SongSearchService(songRepository, searchManager, mockEmbeddingManager); describe("SongSearchService Integration Tests", () => { - const client = new MeiliSearch({ host: MEILI_HOST, apiKey: MEILI_MASTER_KEY }); - let searchManager: SearchManager; - let mockEmbeddingManager: EmbeddingAppApi; - - beforeAll(async () => { - await prisma.$connect(); - await cleanupIndex(client, "song_zh"); - await cleanupIndex(client, "song_en"); - await createIndexIfNotExists(client, "song_zh"); - await createIndexIfNotExists(client, "song_en"); - - searchManager = await SearchManager.create(); - - mockEmbeddingManager = { - embeddings: { - post: async ({ texts }: { texts: string[] }) => { - const dimensions = 256; - const embeddings = texts.map(() => - Array.from({ length: dimensions }, () => Math.random()) - ); - return { data: { embeddings } } as never; - }, - }, - } as never; - }); - - beforeEach(async () => { - const indexZh = client.index("song_zh"); - const indexEn = client.index("song_en"); - try { - await indexZh.deleteAllDocuments(); - await indexEn.deleteAllDocuments(); - } catch { - // Ignore - } - }); - describe("sync", () => { test("syncs song to search index", async () => { const singer = await prisma.singer.create({ @@ -91,11 +47,6 @@ describe("SongSearchService Integration Tests", () => { creations: [{ artistId: artist.id, roleId: artistRole.id }], }); - const service = new SongSearchService( - songRepository, - searchManager, - mockEmbeddingManager - ); await service.sync(song.id); const index = client.index("song_zh"); @@ -110,11 +61,6 @@ describe("SongSearchService Integration Tests", () => { test("deletes song from search index when song is removed", async () => { const song = await songRepository.create({ name: "待删除歌曲" }); - const service = new SongSearchService( - songRepository, - searchManager, - mockEmbeddingManager - ); await service.sync(song.id); const index = client.index("song_zh"); @@ -139,11 +85,6 @@ describe("SongSearchService Integration Tests", () => { localizedDescriptions: { en: "English description" }, }); - const service = new SongSearchService( - songRepository, - searchManager, - mockEmbeddingManager - ); await service.sync(song.id); const zhIndex = client.index("song_zh"); @@ -170,11 +111,6 @@ describe("SongSearchService Integration Tests", () => { description: "用于测试混合搜索功能", }); - const service = new SongSearchService( - songRepository, - searchManager, - mockEmbeddingManager - ); await service.sync(song.id); const result = await service.search("混合搜索", "zh"); diff --git a/packages/core/tests/unit/ArtistService.test.ts b/packages/core/tests/unit/ArtistService.test.ts new file mode 100644 index 0000000..821c88e --- /dev/null +++ b/packages/core/tests/unit/ArtistService.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, mock, test } from "bun:test"; +import { prisma } from "@cvsa/db"; +import { AppError } from "../../src/error/AppError"; +import { ArtistService } from "../../src/modules/catalog/artist/service"; +import type { ArtistDetailsResponseDto } from "../../src/modules/catalog/artist/dto"; +import type { IArtistRepository } from "../../src/modules/catalog/artist/repository.interface"; +import type { OutboxService } from "../../src/modules/outbox/service"; +import { createMockRepository } from "../utils"; + +(prisma as unknown as { $transaction: (fn: (tx: unknown) => Promise) => Promise }).$transaction = mock( + async (fn: (tx: unknown) => Promise) => fn(prisma) +); + +const mockArtistDetails: ArtistDetailsResponseDto = { + id: 1, + name: "Test Artist", + localizedNames: null, + language: "zh", + aliases: [], + description: "A test artist", + localizedDescriptions: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: null, +}; + +const mockOutboxEntry = { + id: 1, + aggregateType: "artist" as const, + aggregateId: 1, + eventType: "artist.created" as const, + payload: null, + status: "PENDING" as const, + retryCount: 0, + lastError: null, + nextRetryAt: null, + createdAt: new Date().toISOString(), + processedAt: null, +}; + +describe("ArtistService", () => { + const mockRepository = createMockRepository({ + getById: async (id: number) => { + if (id === 1) { + return mockArtistDetails; + } + return null; + }, + getDetailsById: async (id: number) => { + if (id === 1) { + return mockArtistDetails; + } + return null; + }, + create: async () => mockArtistDetails, + update: async () => mockArtistDetails, + softDelete: async () => {}, + }); + + const mockOutboxService = { + createEntry: mock(async () => mockOutboxEntry), + enqueue: mock(async () => {}), + processEntry: mock(async () => {}), + recoverStaleEntries: mock(async () => {}), + }; + + const artistService = new ArtistService( + mockRepository as unknown as IArtistRepository, + mockOutboxService as unknown as OutboxService + ); + + describe("getDetails", () => { + test("returns artist details when artist exists", async () => { + const result = await artistService.getDetails(1); + + expect(result).toEqual(mockArtistDetails); + expect(mockRepository.getDetailsById).toHaveBeenCalledWith(1); + }); + + test("throws NOT_FOUND error when artist does not exist", async () => { + mockRepository.getDetailsById.mockResolvedValueOnce(null); + + expect(artistService.getDetails(999)).rejects.toThrow(AppError); + expect(artistService.getDetails(999)).rejects.toThrow("error.artist.notfound"); + expect(artistService.getDetails(999)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); + + describe("create", () => { + const createInput = { + name: "New Artist", + language: "ja", + }; + + test("creates artist and calls outbox.createEntry and outbox.enqueue", async () => { + const result = await artistService.create(createInput); + + expect(result).toMatchObject({ + name: "Test Artist", + language: "zh", + }); + expect(mockRepository.create).toHaveBeenCalledWith( + createInput, + expect.anything() + ); + expect(mockOutboxService.createEntry).toHaveBeenCalledWith( + { + aggregateType: "artist", + aggregateId: mockArtistDetails.id, + eventType: "artist.created", + }, + expect.anything() + ); + expect(mockOutboxService.enqueue).toHaveBeenCalledWith(mockOutboxEntry); + }); + }); + + describe("update", () => { + const updateInput = { name: "Updated Artist" }; + + test("updates artist and calls outbox.createEntry and outbox.enqueue on success", async () => { + const result = await artistService.update(1, updateInput); + + expect(result).toMatchObject({ + name: "Test Artist", + language: "zh", + }); + expect(mockRepository.getById).toHaveBeenCalledWith(1); + expect(mockRepository.update).toHaveBeenCalledWith( + 1, + updateInput, + expect.anything() + ); + expect(mockOutboxService.createEntry).toHaveBeenCalledWith( + { + aggregateType: "artist", + aggregateId: 1, + eventType: "artist.updated", + }, + expect.anything() + ); + expect(mockOutboxService.enqueue).toHaveBeenCalledWith(mockOutboxEntry); + }); + + test("throws NOT_FOUND error when artist does not exist", async () => { + mockRepository.getById.mockResolvedValueOnce(null); + + expect(artistService.update(999, updateInput)).rejects.toThrow(AppError); + expect(artistService.update(999, updateInput)).rejects.toThrow( + "error.artist.notfound" + ); + expect(artistService.update(999, updateInput)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); + + describe("delete", () => { + test("soft deletes artist and calls outbox.createEntry and outbox.enqueue on success", async () => { + await artistService.delete(1); + + expect(mockRepository.getById).toHaveBeenCalledWith(1); + expect(mockRepository.softDelete).toHaveBeenCalledWith( + 1, + expect.anything() + ); + expect(mockOutboxService.createEntry).toHaveBeenCalledWith( + { + aggregateType: "artist", + aggregateId: 1, + eventType: "artist.deleted", + }, + expect.anything() + ); + expect(mockOutboxService.enqueue).toHaveBeenCalledWith(mockOutboxEntry); + }); + + test("throws NOT_FOUND error when artist does not exist", async () => { + mockRepository.getById.mockResolvedValueOnce(null); + + expect(artistService.delete(999)).rejects.toThrow(AppError); + expect(artistService.delete(999)).rejects.toThrow("error.artist.notfound"); + expect(artistService.delete(999)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); +}); diff --git a/packages/core/tests/unit/EngineService.test.ts b/packages/core/tests/unit/EngineService.test.ts new file mode 100644 index 0000000..210b7a4 --- /dev/null +++ b/packages/core/tests/unit/EngineService.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { AppError } from "../../src/error/AppError"; +import { EngineService } from "../../src/modules/catalog/engine/service"; +import type { EngineDetailsResponseDto } from "../../src/modules/catalog/engine/dto"; +import type { IEngineRepository } from "../../src/modules/catalog/engine/repository.interface"; +import { createMockRepository } from "../utils"; + +const mockEngineDetails: EngineDetailsResponseDto = { + id: 1, + name: "Test Engine", + description: "A test engine", + localizedDescriptions: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +describe("EngineService", () => { + const mockRepository = createMockRepository({ + getById: async (id: number) => { + if (id === 1) { + return mockEngineDetails; + } + return null; + }, + getDetailsById: async (id: number) => { + if (id === 1) { + return mockEngineDetails; + } + return null; + }, + create: async () => mockEngineDetails, + update: async () => mockEngineDetails, + softDelete: async () => {}, + }); + + const engineService = new EngineService( + mockRepository as unknown as IEngineRepository + ); + + describe("getDetails", () => { + test("returns engine details when engine exists", async () => { + const result = await engineService.getDetails(1); + + expect(result).toEqual(mockEngineDetails); + expect(mockRepository.getDetailsById).toHaveBeenCalledWith(1); + }); + + test("throws NOT_FOUND error when engine does not exist", async () => { + mockRepository.getDetailsById.mockResolvedValueOnce(null); + + expect(engineService.getDetails(999)).rejects.toThrow(AppError); + expect(engineService.getDetails(999)).rejects.toThrow("error.engine.notfound"); + expect(engineService.getDetails(999)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); + + describe("create", () => { + const createInput = { + name: "New Engine", + description: "A new test engine", + }; + + test("creates engine and returns result", async () => { + const result = await engineService.create(createInput); + + expect(result).toMatchObject({ + name: "Test Engine", + description: "A test engine", + }); + expect(mockRepository.create).toHaveBeenCalledWith(createInput); + }); + }); + + describe("update", () => { + const updateInput = { name: "Updated Engine" }; + + test("updates engine on success", async () => { + const result = await engineService.update(1, updateInput); + + expect(result).toMatchObject({ + name: "Test Engine", + description: "A test engine", + }); + expect(mockRepository.getById).toHaveBeenCalledWith(1); + expect(mockRepository.update).toHaveBeenCalledWith(1, updateInput); + }); + + test("throws NOT_FOUND error when engine does not exist", async () => { + mockRepository.getById.mockResolvedValueOnce(null); + + expect(engineService.update(999, updateInput)).rejects.toThrow(AppError); + expect(engineService.update(999, updateInput)).rejects.toThrow( + "error.engine.notfound" + ); + expect(engineService.update(999, updateInput)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); + + describe("delete", () => { + test("soft deletes engine on success", async () => { + await engineService.delete(1); + + expect(mockRepository.getById).toHaveBeenCalledWith(1); + expect(mockRepository.softDelete).toHaveBeenCalledWith(1); + }); + + test("throws NOT_FOUND error when engine does not exist", async () => { + mockRepository.getById.mockResolvedValueOnce(null); + + expect(engineService.delete(999)).rejects.toThrow(AppError); + expect(engineService.delete(999)).rejects.toThrow("error.engine.notfound"); + expect(engineService.delete(999)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); +}); diff --git a/packages/core/tests/unit/SearchManager.test.ts b/packages/core/tests/unit/SearchManager.test.ts index c502d47..9ac34a0 100644 --- a/packages/core/tests/unit/SearchManager.test.ts +++ b/packages/core/tests/unit/SearchManager.test.ts @@ -19,8 +19,12 @@ const mockGetIndexes = mock(); const mockWaitForTask = mock(); const mockGetTask = mock(); const mockIndexGetSettings = mock(); -const mockIndexResetSettings = mock(); -const mockIndexUpdateSettings = mock(); +const mockIndexResetSettings = mock(() => { + return { taskUid: 1 }; +}); +const mockIndexUpdateSettings = mock(() => { + return { taskUid: 1 }; +}); const mockIndexInstance = { getSettings: mockIndexGetSettings, diff --git a/packages/core/tests/unit/SongSearchService.test.ts b/packages/core/tests/unit/SongSearchService.test.ts index f2b5f0c..fd51fa1 100644 --- a/packages/core/tests/unit/SongSearchService.test.ts +++ b/packages/core/tests/unit/SongSearchService.test.ts @@ -301,10 +301,7 @@ describe("SongSearchService", () => { expect(mockSearchIndex.search).toHaveBeenCalledWith("test", { vector: undefined, - hybrid: { - embedder: "potion-multilingual-128M", - semanticRatio: 0.25, - }, + hybrid: undefined, showRankingScore: true, }); }); diff --git a/turbo.json b/turbo.json index 05c8754..31beb68 100644 --- a/turbo.json +++ b/turbo.json @@ -51,6 +51,12 @@ }, "@cvsa/db#test:reset": { "cache": false + }, + "@cvsa/core#test:reset": { + "cache": false + }, + "//test:reset": { + "dependsOn": ["@cvsa/db#test:reset", "@cvsa/core#test:reset"] } } } From d215be7df693bd241ebff20c25ae76960ac25784 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sat, 25 Apr 2026 18:39:04 +0800 Subject: [PATCH 7/8] test(core): add outbox/search tests, refactor processor Refactor outbox processor into a DI-based factory and inject queue into OutboxService to enable unit testing. Cover outbox service, processor, repository, artist search, and search manager with unit/integration tests. --- apps/backend/src/index.ts | 6 +- packages/core/bunfig.toml | 2 + packages/core/package.json | 2 +- packages/core/src/index.ts | 1 + packages/core/src/internal.ts | 1 - packages/core/src/modules/outbox/service.ts | 14 +- packages/core/src/outbox/processor.ts | 63 ++-- packages/core/src/search/manager.ts | 14 +- .../integration/OutboxRepository.test.ts | 330 ++++++++++++++++++ .../tests/unit/ArtistSearchService.test.ts | 240 +++++++++++++ .../core/tests/unit/OutboxProcessor.test.ts | 116 ++++++ .../core/tests/unit/OutboxService.test.ts | 234 +++++++++++++ .../core/tests/unit/SearchManager.test.ts | 96 +++++ 13 files changed, 1087 insertions(+), 32 deletions(-) create mode 100644 packages/core/tests/integration/OutboxRepository.test.ts create mode 100644 packages/core/tests/unit/ArtistSearchService.test.ts create mode 100644 packages/core/tests/unit/OutboxProcessor.test.ts create mode 100644 packages/core/tests/unit/OutboxService.test.ts diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index e12a825..d8407cd 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -10,9 +10,9 @@ import { opentelemetry } from "@elysiajs/opentelemetry"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { devHandler } from "./handlers"; -import { createOutboxWorker, closeOutboxInfrastructure } from "@cvsa/core/internal"; -import { processOutboxEntry } from "@cvsa/core/internal"; -import { outboxService } from "@cvsa/core/internal"; +import { createOutboxWorker, closeOutboxInfrastructure } from "@cvsa/core"; +import { processOutboxEntry } from "@cvsa/core"; +import { outboxService } from "@cvsa/core"; import { appLogger } from "@cvsa/logger"; const [host, port] = getBindingInfo(); diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml index 3fc94a4..b97d83e 100644 --- a/packages/core/bunfig.toml +++ b/packages/core/bunfig.toml @@ -8,6 +8,8 @@ coveragePathIgnorePatterns = [ "src/error/**", "**/**.interface.ts", "src/utils/randomId.ts", + "src/utils/BaseRepository.ts", "src/types/**", + "src/outbox/queue.ts", "**/observability/src/**", ] \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 92923f3..2458fff 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,7 @@ "lint": "bunx --bun biome lint", "test": "bun test --concurrent", "test:reset": "NODE_ENV=test bun scripts/cleanUpSearchIndex.ts", - "test:safe": "bun clear-search-index && bun run test", + "test:safe": "bun run test:reset && bun run test", "test:coverage": "bun test --coverage", "test:unit": "bun test tests/unit --concurrent", "test:integration": "bun test tests/integration --concurrent", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae044e3..a57b424 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from "./search"; export * from "./utils"; export * from "./types"; export * from "./error"; +export * from "./outbox"; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index 072c8a9..7001a41 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,6 +1,5 @@ export * from "./modules"; export * from "./search"; -export * from "./outbox"; export * from "./types"; export * from "./utils"; export * from "./error"; diff --git a/packages/core/src/modules/outbox/service.ts b/packages/core/src/modules/outbox/service.ts index fde4181..2062354 100644 --- a/packages/core/src/modules/outbox/service.ts +++ b/packages/core/src/modules/outbox/service.ts @@ -1,3 +1,4 @@ +import type { Queue } from "bullmq"; import type { IOutboxRepository } from "./repository.interface"; import type { CreateOutboxEntryDto, OutboxEntryDto, PendingOutboxQueryDto } from "./dto"; import { Prisma, type TxClient } from "@cvsa/db"; @@ -8,7 +9,14 @@ import { traceTask } from "@cvsa/observability"; const MAX_RETRIES = 5; export class OutboxService { - constructor(private readonly repository: IOutboxRepository) {} + private readonly queue: Queue; + + constructor( + private readonly repository: IOutboxRepository, + queue?: Queue, + ) { + this.queue = queue ?? outboxQueue; + } async createEntry(input: CreateOutboxEntryDto, tx: TxClient): Promise { return traceTask("outbox.createEntry", () => this.repository.create(input, tx)); @@ -16,7 +24,7 @@ export class OutboxService { async enqueue(entry: OutboxEntryDto): Promise { await traceTask("outbox.enqueue", async () => { - await outboxQueue.add( + await this.queue.add( `outbox-${entry.aggregateType}-${entry.aggregateId}`, entry, { @@ -64,7 +72,7 @@ export class OutboxService { const query: PendingOutboxQueryDto = { limit }; const entries = await this.repository.findPending(query); for (const entry of entries) { - await outboxQueue.add(`outbox-recover-${entry.id}`, entry, { + await this.queue.add(`outbox-recover-${entry.id}`, entry, { jobId: `outbox-recover-${entry.id}`, attempts: MAX_RETRIES, backoff: { type: "exponential", delay: 1000 }, diff --git a/packages/core/src/outbox/processor.ts b/packages/core/src/outbox/processor.ts index f71e466..7470b80 100644 --- a/packages/core/src/outbox/processor.ts +++ b/packages/core/src/outbox/processor.ts @@ -1,31 +1,48 @@ import type { Job } from "bullmq"; -import type { OutboxEntryDto } from "@cvsa/core/internal"; +import type { OutboxEntryDto, OutboxService } from "@cvsa/core/internal"; import { songSearchService, artistSearchService } from "@cvsa/core/internal"; import { outboxService } from "../modules/outbox/container"; import { appLogger } from "@cvsa/logger"; -export async function processOutboxEntry(job: Job): Promise { - const entry = job.data; +export interface OutboxProcessorDeps { + outboxService: OutboxService; + searchServices: Record }>; +} + +export function createOutboxProcessor( + deps: OutboxProcessorDeps, +): (job: Job) => Promise { + return async function processOutboxEntry(job: Job): Promise { + const entry = job.data; + + await deps.outboxService.processEntry( + entry.id, + async (aggregateType, aggregateId, eventType) => { + appLogger.debug( + `processing outbox entry with ID ${aggregateId}, type ${aggregateType}`, + { + aggregateId, + aggregateType, + eventType, + }, + ); - await outboxService.processEntry(entry.id, async (aggregateType, aggregateId, eventType) => { - appLogger.debug( - `processing outbox entry with ID ${aggregateId}, type ${aggregateType}`, - { - aggregateId, - aggregateType, - eventType, - } + const searchService = deps.searchServices[aggregateType]; + if (!searchService) { + appLogger.warn(`Unknown aggregate type: ${aggregateType}`); + throw new Error(`Unknown aggregate type: ${aggregateType}`); + } + + await searchService.sync(aggregateId); + }, ); - switch (aggregateType) { - case "song": - await songSearchService.sync(aggregateId); - break; - case "artist": - await artistSearchService.sync(aggregateId); - break; - default: - appLogger.warn(`Unknown aggregate type: ${aggregateType}`); - throw new Error(`Unknown aggregate type: ${aggregateType}`); - } - }); + }; } + +export const processOutboxEntry = createOutboxProcessor({ + outboxService, + searchServices: { + song: songSearchService, + artist: artistSearchService, + }, +}); diff --git a/packages/core/src/search/manager.ts b/packages/core/src/search/manager.ts index 96c72af..b252098 100644 --- a/packages/core/src/search/manager.ts +++ b/packages/core/src/search/manager.ts @@ -1,4 +1,4 @@ -import { MeiliSearch, type Settings, type RecordAny, IndexOptions } from "meilisearch"; +import { MeiliSearch, type Settings, type RecordAny, type IndexOptions } from "meilisearch"; import { env } from "@cvsa/env"; import { INDEX_SETTINGS } from "./config"; import { deepEqualUnordered } from "../utils"; @@ -223,6 +223,11 @@ export class SearchManager { } } + /** + * Deletes all search indexes in the MeiliSearch instance. + * This is a destructive operation that removes all indexes and their data. + * Safely no-ops if the admin client is not initialized. + */ public async clearAllIndex() { if (this.adminClient === undefined) { return; @@ -235,6 +240,13 @@ export class SearchManager { } } + /** + * Creates a new search index with the given UID and optional configuration. + * Safely no-ops if the admin client is not initialized. + * @param uid - The unique identifier for the index + * @param options - Optional index configuration (primary key, etc.) + * @returns A promise that resolves to the created index task, or undefined if not initialized + */ public async createIndex(uid: string, options?: IndexOptions) { if (this.adminClient === undefined) { return; diff --git a/packages/core/tests/integration/OutboxRepository.test.ts b/packages/core/tests/integration/OutboxRepository.test.ts new file mode 100644 index 0000000..763dce8 --- /dev/null +++ b/packages/core/tests/integration/OutboxRepository.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, test } from "bun:test"; +import { + outboxRepository, + type CreateOutboxEntryDto, + type PendingOutboxQueryDto, +} from "@cvsa/core"; + +const repository = outboxRepository; + +describe("OutboxRepository Integration Tests", () => { + describe("create", () => { + test("should create an outbox entry with all fields", async () => { + const input: CreateOutboxEntryDto = { + aggregateType: "Song", + aggregateId: 1, + eventType: "SongCreated", + payload: { title: "测试歌曲", artist: "测试歌手" }, + }; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.aggregateType).toBe("Song"); + expect(result.aggregateId).toBe(1); + expect(result.eventType).toBe("SongCreated"); + expect(result.payload).toEqual({ title: "测试歌曲", artist: "测试歌手" }); + expect(result.status).toBe("PENDING"); + expect(result.retryCount).toBe(0); + expect(result.lastError).toBeNull(); + expect(result.nextRetryAt).toBeNull(); + expect(result.createdAt).toBeDefined(); + expect(result.processedAt).toBeNull(); + }); + + test("should create an outbox entry with minimal fields (no payload)", async () => { + const input: CreateOutboxEntryDto = { + aggregateType: "Artist", + aggregateId: 2, + eventType: "ArtistUpdated", + }; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.aggregateType).toBe("Artist"); + expect(result.aggregateId).toBe(2); + expect(result.eventType).toBe("ArtistUpdated"); + expect(result.payload).toBeNull(); + expect(result.status).toBe("PENDING"); + expect(result.retryCount).toBe(0); + }); + + test("should default status to PENDING for new entries", async () => { + const result = await repository.create({ + aggregateType: "Album", + aggregateId: 3, + eventType: "AlbumCreated", + }); + + expect(result.status).toBe("PENDING"); + }); + }); + + describe("findPending", () => { + test("should return entries with PENDING status whose nextRetryAt is null or in the past", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 10, + eventType: "SongCreated", + }); + + const query: PendingOutboxQueryDto = { limit: 100 }; + const results = await repository.findPending(query); + + expect(results.length).toBeGreaterThanOrEqual(1); + const found = results.find((r) => r.id === created.id); + expect(found).toBeDefined(); + expect(found?.status).toBe("PENDING"); + }); + + test("should respect the limit parameter", async () => { + await repository.create({ + aggregateType: "Song", + aggregateId: 100, + eventType: "SongCreated", + }); + await repository.create({ + aggregateType: "Song", + aggregateId: 101, + eventType: "SongCreated", + }); + await repository.create({ + aggregateType: "Song", + aggregateId: 102, + eventType: "SongCreated", + }); + + const query: PendingOutboxQueryDto = { limit: 2 }; + const results = await repository.findPending(query); + + expect(results.length).toBeLessThanOrEqual(2); + }); + + test("should not return entries that are PROCESSED", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 200, + eventType: "SongCreated", + }); + await repository.markProcessed(created.id); + + const query: PendingOutboxQueryDto = { limit: 100 }; + const results = await repository.findPending(query); + + const found = results.find((r) => r.id === created.id); + expect(found).toBeUndefined(); + }); + + test("should not return entries that are PROCESSING", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 300, + eventType: "SongCreated", + }); + await repository.markProcessing(created.id); + + const query: PendingOutboxQueryDto = { limit: 100 }; + const results = await repository.findPending(query); + + const found = results.find((r) => r.id === created.id); + expect(found).toBeUndefined(); + }); + + test("should not return FAILED entries regardless of nextRetryAt", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 400, + eventType: "SongCreated", + }); + const pastRetry = new Date(Date.now() - 10000); + await repository.markFailed(created.id, "Test error", pastRetry); + + const query: PendingOutboxQueryDto = { limit: 100 }; + const results = await repository.findPending(query); + + const found = results.find((r) => r.id === created.id); + expect(found).toBeUndefined(); + }); + }); + + describe("markProcessing", () => { + test("should change status from PENDING to PROCESSING", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 20, + eventType: "SongCreated", + }); + + const result = await repository.markProcessing(created.id); + + expect(result).toBeDefined(); + expect(result.id).toBe(created.id); + expect(result.status).toBe("PROCESSING"); + }); + + test("should update an existing FAILED entry to PROCESSING", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 21, + eventType: "SongCreated", + }); + await repository.markFailed( + created.id, + "Previous error", + new Date(Date.now() - 1000) + ); + + const result = await repository.markProcessing(created.id); + + expect(result.status).toBe("PROCESSING"); + }); + }); + + describe("markProcessed", () => { + test("should change status to PROCESSED and set processedAt", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 30, + eventType: "SongCreated", + }); + + const result = await repository.markProcessed(created.id); + + expect(result).toBeDefined(); + expect(result.id).toBe(created.id); + expect(result.status).toBe("PROCESSED"); + expect(result.processedAt).toBeDefined(); + expect(result.processedAt).not.toBeNull(); + }); + + test("should not appear in findPending after being processed", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 31, + eventType: "SongCreated", + }); + await repository.markProcessed(created.id); + + const query: PendingOutboxQueryDto = { limit: 100 }; + const results = await repository.findPending(query); + + const found = results.find((r) => r.id === created.id); + expect(found).toBeUndefined(); + }); + }); + + describe("markFailed", () => { + test("should set status to FAILED with lastError and nextRetryAt", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 40, + eventType: "SongCreated", + }); + const nextRetryAt = new Date(Date.now() + 60000); + + const result = await repository.markFailed( + created.id, + "Processing failed due to network error", + nextRetryAt + ); + + expect(result).toBeDefined(); + expect(result.id).toBe(created.id); + expect(result.status).toBe("FAILED"); + expect(result.lastError).toBe("Processing failed due to network error"); + expect(result.nextRetryAt).toBeDefined(); + expect(result.nextRetryAt).not.toBeNull(); + }); + + test("should increment retryCount when marking as failed", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 41, + eventType: "SongCreated", + }); + + const firstFail = await repository.markFailed( + created.id, + "First failure", + new Date(Date.now() + 60000) + ); + expect(firstFail.retryCount).toBe(1); + + const secondFail = await repository.markFailed( + created.id, + "Second failure", + new Date(Date.now() + 120000) + ); + expect(secondFail.retryCount).toBe(2); + }); + + test("should retain lastError from latest failure", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 42, + eventType: "SongCreated", + }); + + await repository.markFailed( + created.id, + "First error", + new Date(Date.now() + 60000) + ); + const result = await repository.markFailed( + created.id, + "Second error", + new Date(Date.now() + 120000) + ); + + expect(result.lastError).toBe("Second error"); + }); + }); + + describe("full lifecycle", () => { + test("should support PENDING -> PROCESSING -> PROCESSED lifecycle", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 50, + eventType: "SongCreated", + }); + + expect(created.status).toBe("PENDING"); + + const processing = await repository.markProcessing(created.id); + expect(processing.status).toBe("PROCESSING"); + + const processed = await repository.markProcessed(created.id); + expect(processed.status).toBe("PROCESSED"); + expect(processed.processedAt).toBeDefined(); + }); + + test("should support PENDING -> PROCESSING -> FAILED -> PROCESSING -> PROCESSED lifecycle", async () => { + const created = await repository.create({ + aggregateType: "Song", + aggregateId: 51, + eventType: "SongCreated", + }); + + const processing = await repository.markProcessing(created.id); + expect(processing.status).toBe("PROCESSING"); + + const failed = await repository.markFailed( + created.id, + "Transient error", + new Date(Date.now() - 1000) + ); + expect(failed.status).toBe("FAILED"); + expect(failed.retryCount).toBe(1); + + const retryProcessing = await repository.markProcessing(created.id); + expect(retryProcessing.status).toBe("PROCESSING"); + + const processed = await repository.markProcessed(created.id); + expect(processed.status).toBe("PROCESSED"); + expect(processed.processedAt).toBeDefined(); + }); + }); +}); diff --git a/packages/core/tests/unit/ArtistSearchService.test.ts b/packages/core/tests/unit/ArtistSearchService.test.ts new file mode 100644 index 0000000..a92f98e --- /dev/null +++ b/packages/core/tests/unit/ArtistSearchService.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, mock, test, beforeEach, afterAll } from "bun:test"; +import type { ArtistDetailsResponseDto } from "../../src/modules"; +import type { IRepositoryWithGetDetails } from "../../src/types/repository"; + +const mockGetLocalizedIndexesOfEntity = mock(); +const mockGetAdminIndex = mock(); +const mockGetSearchIndex = mock(); + +const mockAdminIndex = { + deleteDocument: mock(), + addDocuments: mock(), + search: mock(), +}; + +const mockSearchIndex = { + search: mock(), +}; + +const mockSearchManager = { + getLocalizedIndexesOfEntity: mockGetLocalizedIndexesOfEntity, + getAdminIndex: mockGetAdminIndex, + getSearchIndex: mockGetSearchIndex, + waitForTask: mock().mockResolvedValue(undefined), +}; + +const mockEmbeddingsPost = mock(); +const mockEmbeddingManager = { + embeddings: { + post: mockEmbeddingsPost, + }, +}; + +const { ArtistSearchService } = await import("../../src/search/catalog/artist"); + +const mockArtistDetails: ArtistDetailsResponseDto = { + id: 1, + name: "Test Artist", + language: "zh", + aliases: ["Alias1", "Alias2"], + description: "A test artist", + localizedNames: { en: "Test Artist EN", ja: "テストアーティスト" }, + localizedDescriptions: { en: "A test artist in English" }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: null, +}; + +describe("ArtistSearchService", () => { + let service: InstanceType; + let mockRepository: IRepositoryWithGetDetails; + + beforeEach(() => { + mockGetLocalizedIndexesOfEntity.mockClear(); + mockGetAdminIndex.mockClear(); + mockGetSearchIndex.mockClear(); + mockAdminIndex.deleteDocument.mockClear(); + mockAdminIndex.addDocuments.mockClear(); + mockAdminIndex.search.mockClear(); + mockSearchIndex.search.mockClear(); + mockEmbeddingsPost.mockClear(); + + mockGetAdminIndex.mockResolvedValue(mockAdminIndex); + mockGetSearchIndex.mockResolvedValue(mockSearchIndex); + mockAdminIndex.deleteDocument.mockResolvedValue({ taskUid: 1 }); + mockAdminIndex.addDocuments.mockResolvedValue({ taskUid: 1 }); + mockEmbeddingsPost.mockResolvedValue({ + data: { embeddings: [[0.1, 0.2, 0.3]] }, + }); + + mockRepository = { + getDetailsById: mock(), + }; + + service = new ArtistSearchService( + mockRepository, + mockSearchManager as never, + mockEmbeddingManager as never, + ); + }); + + afterAll(() => { + mock.restore(); + mock.clearAllMocks(); + }); + + describe("sync", () => { + test("syncs artist to all language indexes", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockArtistDetails, + ); + + await service.sync(1); + + expect(mockRepository.getDetailsById).toHaveBeenCalledWith(1); + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + const callArgs = mockAdminIndex.addDocuments.mock.calls[0] as unknown[]; + expect(callArgs[1]).toEqual({ primaryKey: "id" }); + }); + + test("deletes document from all indexes when artist not found", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue(null); + mockGetLocalizedIndexesOfEntity.mockResolvedValue(["artist_zh", "artist_en"]); + + await service.sync(999); + + expect(mockGetLocalizedIndexesOfEntity).toHaveBeenCalledWith("artist"); + expect(mockGetAdminIndex).toHaveBeenCalledTimes(2); + expect(mockAdminIndex.deleteDocument).toHaveBeenCalledTimes(2); + expect(mockAdminIndex.deleteDocument).toHaveBeenCalledWith(999); + }); + + test("handles missing search manager gracefully", async () => { + const serviceWithoutManager = new ArtistSearchService( + mockRepository, + undefined as never, + mockEmbeddingManager as never, + ); + + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockArtistDetails, + ); + + await serviceWithoutManager.sync(1); + + expect(mockRepository.getDetailsById).not.toHaveBeenCalled(); + }); + + test("builds document with localized content", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockArtistDetails, + ); + + await service.sync(1); + + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + const allCalls = mockAdminIndex.addDocuments.mock.calls as unknown as [ + { id: number; name: string }[], + unknown, + ][]; + const enCall = allCalls.find((call) => call[0][0].name === "Test Artist EN"); + expect(enCall).toBeDefined(); + }); + + test("handles artist with no localized content", async () => { + const artistWithoutLocalization = { + ...mockArtistDetails, + localizedNames: {}, + localizedDescriptions: {}, + }; + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + artistWithoutLocalization, + ); + + await service.sync(1); + + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + }); + + test("handles embedding generation failure", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockArtistDetails, + ); + mockEmbeddingsPost.mockResolvedValue({ + data: null, + }); + + await service.sync(1); + + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + const doc = ((mockAdminIndex.addDocuments.mock.calls[0] as unknown[])[0] as { _vectors: { "potion-multilingual-128M": unknown } }[])[0]; + expect(doc._vectors).toEqual({ + "potion-multilingual-128M": null, + }); + }); + }); + + describe("search", () => { + test("performs hybrid search with embedding", async () => { + const mockSearchResult = { + hits: [{ id: 1, name: "Test Artist" }], + query: "test", + processingTimeMs: 30, + offset: 1, + limit: 1, + estimatedTotalHits: 1, + }; + mockSearchIndex.search.mockResolvedValue(mockSearchResult); + + const result = await service.search("test query", "zh"); + + expect(mockGetSearchIndex).toHaveBeenCalledWith("artist_zh"); + expect(mockEmbeddingsPost).toHaveBeenCalledWith({ texts: ["test query"] }); + expect(mockSearchIndex.search).toHaveBeenCalledWith("test query", { + vector: [0.1, 0.2, 0.3], + hybrid: { + embedder: "potion-multilingual-128M", + semanticRatio: 0.25, + }, + showRankingScore: true, + }); + expect(result).toEqual(mockSearchResult); + }); + + test("uses default language when not specified", async () => { + mockSearchIndex.search.mockResolvedValue({ hits: [] }); + + await service.search("test"); + + expect(mockGetSearchIndex).toHaveBeenCalledWith("artist_zh"); + }); + + test("throws when search manager not available", async () => { + const serviceWithoutManager = new ArtistSearchService( + mockRepository, + undefined as never, + mockEmbeddingManager as never, + ); + + expect(serviceWithoutManager.search("test")).rejects.toThrow( + "Search or embedding service not available", + ); + }); + + test("handles missing embedding response gracefully", async () => { + mockEmbeddingsPost.mockResolvedValue({ data: null }); + mockSearchIndex.search.mockResolvedValue({ hits: [] }); + + await service.search("test", "zh"); + + expect(mockSearchIndex.search).toHaveBeenCalledWith("test", { + vector: undefined, + hybrid: { + embedder: "potion-multilingual-128M", + semanticRatio: 0.25, + }, + showRankingScore: true, + }); + }); + }); +}); diff --git a/packages/core/tests/unit/OutboxProcessor.test.ts b/packages/core/tests/unit/OutboxProcessor.test.ts new file mode 100644 index 0000000..7927068 --- /dev/null +++ b/packages/core/tests/unit/OutboxProcessor.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, mock, test, beforeEach } from "bun:test"; +import { createOutboxProcessor, type OutboxProcessorDeps } from "../../src/outbox/processor"; +import type { OutboxEntryDto } from "@cvsa/core/internal"; + +function makeEntry(overrides?: Partial): OutboxEntryDto { + return { + id: 1, + aggregateType: "song", + aggregateId: 42, + eventType: "SongCreated", + payload: null, + status: "PENDING", + retryCount: 0, + lastError: null, + nextRetryAt: null, + createdAt: new Date().toISOString(), + processedAt: null, + ...overrides, + }; +} + +function makeJob(entry: OutboxEntryDto) { + return { data: entry } as never; +} + +describe("createOutboxProcessor", () => { + let capturedProcessorId: number | undefined; + let capturedCallback: ( + aggregateType: string, + aggregateId: number, + eventType: string + ) => Promise; + + const mockOutboxService = { + processEntry: mock( + async ( + id: number, + processor: ( + aggregateType: string, + aggregateId: number, + eventType: string + ) => Promise + ) => { + capturedProcessorId = id; + capturedCallback = processor; + } + ), + }; + + const mockSongSearch = { sync: mock(async () => {}) }; + const mockArtistSearch = { sync: mock(async () => {}) }; + + const deps: OutboxProcessorDeps = { + outboxService: mockOutboxService as unknown as never, + searchServices: { + song: mockSongSearch, + artist: mockArtistSearch, + }, + }; + + const processor = createOutboxProcessor(deps); + + beforeEach(() => { + mockOutboxService.processEntry.mockClear(); + mockSongSearch.sync.mockClear(); + mockArtistSearch.sync.mockClear(); + capturedProcessorId = undefined; + }); + + test("should call outboxService.processEntry with entry.id from job", async () => { + const entry = makeEntry({ id: 7 }); + const job = makeJob(entry); + + await processor(job); + + expect(capturedProcessorId).toBe(7); + expect(mockOutboxService.processEntry).toHaveBeenCalledTimes(1); + }); + + test("should dispatch to song search service for aggregateType 'song'", async () => { + const entry = makeEntry({ aggregateType: "song", aggregateId: 42 }); + const job = makeJob(entry); + + await processor(job); + + await capturedCallback("song", 42, "SongCreated"); + + expect(mockSongSearch.sync).toHaveBeenCalledWith(42); + expect(mockArtistSearch.sync).not.toHaveBeenCalled(); + }); + + test("should dispatch to artist search service for aggregateType 'artist'", async () => { + const entry = makeEntry({ aggregateType: "artist", aggregateId: 99 }); + const job = makeJob(entry); + + await processor(job); + + await capturedCallback("artist", 99, "ArtistUpdated"); + + expect(mockArtistSearch.sync).toHaveBeenCalledWith(99); + expect(mockSongSearch.sync).not.toHaveBeenCalled(); + }); + + test("should throw for unknown aggregateType", async () => { + const entry = makeEntry({ aggregateType: "unknown", aggregateId: 1 }); + const job = makeJob(entry); + + await processor(job); + + const promise = capturedCallback("unknown", 1, "UnknownEvent"); + + expect(promise).rejects.toThrow("Unknown aggregate type: unknown"); + expect(mockSongSearch.sync).not.toHaveBeenCalled(); + expect(mockArtistSearch.sync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/unit/OutboxService.test.ts b/packages/core/tests/unit/OutboxService.test.ts new file mode 100644 index 0000000..5cd0d0e --- /dev/null +++ b/packages/core/tests/unit/OutboxService.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, mock, test, beforeEach } from "bun:test"; +import { OutboxService } from "@cvsa/core/internal"; +import { Prisma } from "@cvsa/db"; +import type { CreateOutboxEntryDto, OutboxEntryDto } from "@cvsa/core"; + +const mockOutboxEntry = (overrides?: Partial): OutboxEntryDto => ({ + id: 1, + aggregateType: "Song", + aggregateId: 42, + eventType: "SongCreated", + payload: null, + status: "PENDING", + retryCount: 0, + lastError: null, + nextRetryAt: null, + createdAt: new Date().toISOString(), + processedAt: null, + ...overrides, +}); + +function prismaNotFoundError(): Prisma.PrismaClientKnownRequestError { + return new Prisma.PrismaClientKnownRequestError("Not found", { + code: "P2025", + clientVersion: "test", + }); +} + +describe("OutboxService", () => { + const mockRepository = { + create: mock(async () => mockOutboxEntry()), + findPending: mock(async () => [] as OutboxEntryDto[]), + markProcessing: mock(async (id: number) => mockOutboxEntry({ id, status: "PROCESSING" })), + markProcessed: mock(async (id: number) => + mockOutboxEntry({ id, status: "PROCESSED", processedAt: new Date().toISOString() }) + ), + markFailed: mock( + async (id: number, lastError: string, nextRetryAt: Date) => + mockOutboxEntry({ + id, + status: "FAILED", + lastError, + nextRetryAt: nextRetryAt.toISOString(), + retryCount: 1, + }) + ), + }; + + const mockQueue = { + add: mock(async () => {}), + }; + + const service = new OutboxService(mockRepository as unknown as never, mockQueue as unknown as never); + + beforeEach(() => { + mockRepository.create.mockClear(); + mockRepository.findPending.mockClear(); + mockRepository.markProcessing.mockClear(); + mockRepository.markProcessed.mockClear(); + mockRepository.markFailed.mockClear(); + mockQueue.add.mockClear(); + }); + + describe("createEntry", () => { + test("should delegate to repository.create with input and tx", async () => { + const input: CreateOutboxEntryDto = { + aggregateType: "Song", + aggregateId: 42, + eventType: "SongCreated", + }; + const mockTx = {} as never; + + const result = await service.createEntry(input, mockTx); + + expect(mockRepository.create).toHaveBeenCalledWith(input, mockTx); + expect(result.id).toBe(1); + expect(result.status).toBe("PENDING"); + }); + + test("should pass payload when provided", async () => { + const input: CreateOutboxEntryDto = { + aggregateType: "Artist", + aggregateId: 10, + eventType: "ArtistUpdated", + payload: { name: "test" }, + }; + const mockTx = {} as never; + + await service.createEntry(input, mockTx); + + expect(mockRepository.create).toHaveBeenCalledWith(input, mockTx); + }); + }); + + describe("enqueue", () => { + test("should add entry to queue with correct job name and options", async () => { + const entry = mockOutboxEntry(); + + await service.enqueue(entry); + + expect(mockQueue.add).toHaveBeenCalledTimes(1); + const callArgs = mockQueue.add.mock.calls[0] as unknown[]; + expect(callArgs[0]).toBe("outbox-Song-42"); + expect(callArgs[1]).toBe(entry); + expect(callArgs[2]).toEqual({ + jobId: "outbox-1", + attempts: 5, + backoff: { type: "exponential", delay: 1000 }, + }); + }); + }); + + describe("processEntry", () => { + test("should mark processing, call processor, then mark processed on success", async () => { + const processor = mock(async () => {}); + + await service.processEntry(1, processor); + + expect(mockRepository.markProcessing).toHaveBeenCalledWith(1); + expect(processor).toHaveBeenCalledWith("Song", 42, "SongCreated"); + expect(mockRepository.markProcessed).toHaveBeenCalledWith(1); + expect(mockRepository.markFailed).not.toHaveBeenCalled(); + }); + + test("should rethrow processor error when retryCount is below max retries", async () => { + mockRepository.markProcessing.mockResolvedValueOnce( + mockOutboxEntry({ id: 1, status: "PROCESSING", retryCount: 2 }) + ); + const processorError = new Error("Processing failed"); + const processor = mock(async () => { + throw processorError; + }); + + const promise = service.processEntry(1, processor); + + expect(promise).rejects.toThrow("Processing failed"); + await promise.catch(() => {}); + expect(mockRepository.markProcessed).not.toHaveBeenCalled(); + expect(mockRepository.markFailed).not.toHaveBeenCalled(); + }); + + test("should mark failed when retryCount reaches max retries", async () => { + mockRepository.markProcessing.mockResolvedValueOnce( + mockOutboxEntry({ id: 1, status: "PROCESSING", retryCount: 4 }) + ); + const processor = mock(async () => { + throw new Error("Final failure"); + }); + + await service.processEntry(1, processor); + + expect(mockRepository.markFailed).toHaveBeenCalledTimes(1); + const failArgs = mockRepository.markFailed.mock.calls[0] as unknown[]; + expect(failArgs[0]).toBe(1); + expect(failArgs[1]).toBe("Final failure"); + expect(mockRepository.markProcessed).not.toHaveBeenCalled(); + }); + + test("should convert non-Error throws to string message", async () => { + mockRepository.markProcessing.mockResolvedValueOnce( + mockOutboxEntry({ id: 1, status: "PROCESSING", retryCount: 4 }) + ); + const processor = mock(async () => { + throw "raw string error"; + }); + + await service.processEntry(1, processor); + + expect(mockRepository.markFailed).toHaveBeenCalledTimes(1); + const failArgs = mockRepository.markFailed.mock.calls[0] as unknown[]; + expect(failArgs[1]).toBe("raw string error"); + }); + + test("should silently skip when entry is not found (P2025)", async () => { + mockRepository.markProcessing.mockRejectedValueOnce(prismaNotFoundError()); + const processor = mock(async () => {}); + + const promise = service.processEntry(1, processor); + + expect(promise).resolves.toBeUndefined(); + await promise; + expect(processor).not.toHaveBeenCalled(); + }); + + test("should rethrow non-P2025 errors from markProcessing", async () => { + const otherError = new Error("DB connection failed"); + mockRepository.markProcessing.mockRejectedValueOnce(otherError); + const processor = mock(async () => {}); + + const promise = service.processEntry(1, processor); + + expect(promise).rejects.toThrow("DB connection failed"); + await promise.catch(() => {}); + expect(processor).not.toHaveBeenCalled(); + }); + }); + + describe("recoverStaleEntries", () => { + test("should find pending entries and enqueue each one", async () => { + const entries = [ + mockOutboxEntry({ id: 1 }), + mockOutboxEntry({ id: 2 }), + mockOutboxEntry({ id: 3 }), + ]; + mockRepository.findPending.mockResolvedValueOnce(entries); + + await service.recoverStaleEntries(50); + + expect(mockRepository.findPending).toHaveBeenCalledWith({ limit: 50 }); + expect(mockQueue.add).toHaveBeenCalledTimes(3); + const firstCall = mockQueue.add.mock.calls[0] as unknown as [string, OutboxEntryDto, unknown]; + const secondCall = mockQueue.add.mock.calls[1] as unknown as [string, OutboxEntryDto, unknown]; + const thirdCall = mockQueue.add.mock.calls[2] as unknown as [string, OutboxEntryDto, unknown]; + expect(firstCall[0]).toBe("outbox-recover-1"); + expect(secondCall[0]).toBe("outbox-recover-2"); + expect(thirdCall[0]).toBe("outbox-recover-3"); + }); + + test("should use default limit of 100", async () => { + mockRepository.findPending.mockResolvedValueOnce([]); + + await service.recoverStaleEntries(); + + expect(mockRepository.findPending).toHaveBeenCalledWith({ limit: 100 }); + }); + + test("should handle empty pending entries", async () => { + mockRepository.findPending.mockResolvedValueOnce([]); + + await service.recoverStaleEntries(); + + expect(mockQueue.add).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/tests/unit/SearchManager.test.ts b/packages/core/tests/unit/SearchManager.test.ts index 9ac34a0..8af3876 100644 --- a/packages/core/tests/unit/SearchManager.test.ts +++ b/packages/core/tests/unit/SearchManager.test.ts @@ -25,6 +25,8 @@ const mockIndexResetSettings = mock(() => { const mockIndexUpdateSettings = mock(() => { return { taskUid: 1 }; }); +const mockAdminDeleteIndexIfExists = mock(); +const mockAdminCreateIndex = mock(); const mockIndexInstance = { getSettings: mockIndexGetSettings, @@ -66,6 +68,8 @@ const createMockClient = (): { client: MockClient; adminClient: MockClient } => getIndexes: mockGetIndexes, getKeys: mockGetKeys, createKey: mockCreateKey, + deleteIndexIfExists: mockAdminDeleteIndexIfExists, + createIndex: mockAdminCreateIndex, tasks: { waitForTask: mockWaitForTask, getTask: mockGetTask, @@ -84,6 +88,8 @@ describe("SearchManager", () => { mockIndexUpdateSettings.mockClear(); mockWaitForTask.mockClear(); mockGetTask.mockClear(); + mockAdminDeleteIndexIfExists.mockClear(); + mockAdminCreateIndex.mockClear(); }); describe("create with custom clients", () => { @@ -386,4 +392,94 @@ describe("SearchManager", () => { expect(task).toEqual(mockTask); }); }); + + describe("clearAllIndex", () => { + test("deletes all existing indexes", async () => { + const { client, adminClient } = createMockClient(); + + mockGetKeys.mockResolvedValue({ + results: [ + { key: "admin-key", actions: ["*"], indexes: ["*"] }, + { key: "search-key", actions: ["search"], indexes: ["*"] }, + ], + }); + mockGetIndexes.mockResolvedValue({ + results: [ + createMockIndex("song_zh-CN"), + createMockIndex("artist_en"), + ], + }); + + const manager = await SearchManager.create( + client as unknown as MeiliSearchType, + adminClient as unknown as MeiliSearchType + ); + await manager.clearAllIndex(); + + expect(mockAdminDeleteIndexIfExists).toHaveBeenCalledTimes(2); + expect(mockAdminDeleteIndexIfExists).toHaveBeenCalledWith("song_zh-CN"); + expect(mockAdminDeleteIndexIfExists).toHaveBeenCalledWith("artist_en"); + }); + + test("handles empty index list gracefully", async () => { + const { client, adminClient } = createMockClient(); + + mockGetKeys.mockResolvedValue({ + results: [ + { key: "admin-key", actions: ["*"], indexes: ["*"] }, + { key: "search-key", actions: ["search"], indexes: ["*"] }, + ], + }); + mockGetIndexes.mockResolvedValue({ results: [] }); + + const manager = await SearchManager.create( + client as unknown as MeiliSearchType, + adminClient as unknown as MeiliSearchType + ); + await manager.clearAllIndex(); + + expect(mockAdminDeleteIndexIfExists).not.toHaveBeenCalled(); + }); + }); + + describe("createIndex", () => { + test("creates a new index via admin client", async () => { + const { client, adminClient } = createMockClient(); + + mockGetKeys.mockResolvedValue({ + results: [ + { key: "admin-key", actions: ["*"], indexes: ["*"] }, + { key: "search-key", actions: ["search"], indexes: ["*"] }, + ], + }); + + const manager = await SearchManager.create( + client as unknown as MeiliSearchType, + adminClient as unknown as MeiliSearchType + ); + await manager.createIndex("album_zh"); + + expect(mockAdminCreateIndex).toHaveBeenCalledWith("album_zh", undefined); + }); + + test("passes options to createIndex when provided", async () => { + const { client, adminClient } = createMockClient(); + + mockGetKeys.mockResolvedValue({ + results: [ + { key: "admin-key", actions: ["*"], indexes: ["*"] }, + { key: "search-key", actions: ["search"], indexes: ["*"] }, + ], + }); + + const manager = await SearchManager.create( + client as unknown as MeiliSearchType, + adminClient as unknown as MeiliSearchType + ); + const options = { primaryKey: "id" }; + await manager.createIndex("album_zh", options); + + expect(mockAdminCreateIndex).toHaveBeenCalledWith("album_zh", options); + }); + }); }); From e7f0b58b5e0df35b7300ac9ebb426bf5c38388b3 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sun, 26 Apr 2026 17:17:35 +0800 Subject: [PATCH 8/8] format: reformat with Biome --- .../src/handlers/catalog/artist/index.ts | 2 +- apps/backend/tests/artist.test.ts | 1 - apps/backend/tests/engine.test.ts | 1 - .../src/modules/catalog/artist/service.ts | 2 +- packages/core/src/modules/outbox/service.ts | 16 +++----- packages/core/src/outbox/processor.ts | 6 +-- packages/core/src/search/catalog/artist.ts | 5 ++- packages/core/src/search/config.ts | 8 +--- packages/core/src/utils/BaseRepository.ts | 5 +-- .../integration/ArtistRepository.test.ts | 10 ++++- .../integration/EngineRepository.test.ts | 6 ++- .../integration/OutboxRepository.test.ts | 12 +----- .../tests/unit/ArtistSearchService.test.ts | 24 ++++++----- .../core/tests/unit/ArtistService.test.ts | 28 +++++-------- .../core/tests/unit/EngineService.test.ts | 8 +--- .../core/tests/unit/OutboxService.test.ts | 40 +++++++++++++------ .../core/tests/unit/SearchManager.test.ts | 5 +-- packages/core/tests/unit/SongService.test.ts | 12 +++++- 18 files changed, 95 insertions(+), 96 deletions(-) diff --git a/apps/backend/src/handlers/catalog/artist/index.ts b/apps/backend/src/handlers/catalog/artist/index.ts index 40a9ec8..4adfb99 100644 --- a/apps/backend/src/handlers/catalog/artist/index.ts +++ b/apps/backend/src/handlers/catalog/artist/index.ts @@ -10,4 +10,4 @@ export const artistHandler = new Elysia({ name: "artistHandler" }) .use(artistCreateHandler) .use(artistUpdateHandler) .use(artistDeleteHandler) - .use(artistSearchHandler) + .use(artistSearchHandler); diff --git a/apps/backend/tests/artist.test.ts b/apps/backend/tests/artist.test.ts index 9cab0d8..36a1841 100644 --- a/apps/backend/tests/artist.test.ts +++ b/apps/backend/tests/artist.test.ts @@ -6,7 +6,6 @@ import { prisma } from "@cvsa/db"; const api = treaty(app); describe("Artist E2E Tests", () => { - async function getAuthToken() { const signup = await api.v2.user.post({ username: `${Math.random()}`, diff --git a/apps/backend/tests/engine.test.ts b/apps/backend/tests/engine.test.ts index 1971aac..54f1f29 100644 --- a/apps/backend/tests/engine.test.ts +++ b/apps/backend/tests/engine.test.ts @@ -6,7 +6,6 @@ import { prisma } from "@cvsa/db"; const api = treaty(app); describe("Engine E2E Tests", () => { - async function getAuthToken() { const signup = await api.v2.user.post({ username: `${Math.random()}`, diff --git a/packages/core/src/modules/catalog/artist/service.ts b/packages/core/src/modules/catalog/artist/service.ts index 8f9dd26..854c873 100644 --- a/packages/core/src/modules/catalog/artist/service.ts +++ b/packages/core/src/modules/catalog/artist/service.ts @@ -15,7 +15,7 @@ export class ArtistService implements IServiceWithGetDetails, + queue?: Queue ) { this.queue = queue ?? outboxQueue; } @@ -24,15 +24,11 @@ export class OutboxService { async enqueue(entry: OutboxEntryDto): Promise { await traceTask("outbox.enqueue", async () => { - await this.queue.add( - `outbox-${entry.aggregateType}-${entry.aggregateId}`, - entry, - { - jobId: `outbox-${entry.id}`, - attempts: MAX_RETRIES, - backoff: { type: "exponential", delay: 1000 }, - } - ); + await this.queue.add(`outbox-${entry.aggregateType}-${entry.aggregateId}`, entry, { + jobId: `outbox-${entry.id}`, + attempts: MAX_RETRIES, + backoff: { type: "exponential", delay: 1000 }, + }); }); } diff --git a/packages/core/src/outbox/processor.ts b/packages/core/src/outbox/processor.ts index 7470b80..de224e7 100644 --- a/packages/core/src/outbox/processor.ts +++ b/packages/core/src/outbox/processor.ts @@ -10,7 +10,7 @@ export interface OutboxProcessorDeps { } export function createOutboxProcessor( - deps: OutboxProcessorDeps, + deps: OutboxProcessorDeps ): (job: Job) => Promise { return async function processOutboxEntry(job: Job): Promise { const entry = job.data; @@ -24,7 +24,7 @@ export function createOutboxProcessor( aggregateId, aggregateType, eventType, - }, + } ); const searchService = deps.searchServices[aggregateType]; @@ -34,7 +34,7 @@ export function createOutboxProcessor( } await searchService.sync(aggregateId); - }, + } ); }; } diff --git a/packages/core/src/search/catalog/artist.ts b/packages/core/src/search/catalog/artist.ts index 10b2e61..edc5e7d 100644 --- a/packages/core/src/search/catalog/artist.ts +++ b/packages/core/src/search/catalog/artist.ts @@ -26,7 +26,7 @@ export class ArtistSearchService extends ISearchService = { }, }, artist: { - searchableAttributes: [ - "name", - "description", - "aliases", - ], + searchableAttributes: ["name", "description", "aliases"], rankingRules: ["attribute", "words", "proximity", "exactness", "typo", "sort"], embedders: { "potion-multilingual-128M": { source: "userProvided", dimensions: 256, }, - } + }, }, }; diff --git a/packages/core/src/utils/BaseRepository.ts b/packages/core/src/utils/BaseRepository.ts index 74cffe3..fe9ef54 100644 --- a/packages/core/src/utils/BaseRepository.ts +++ b/packages/core/src/utils/BaseRepository.ts @@ -2,10 +2,7 @@ import { transformPrismaResult, type Serialized } from "@cvsa/db"; import { traceTask } from "@cvsa/observability"; export abstract class BaseRepository { - protected async query( - name: string, - fn: () => Promise - ): Promise> { + protected async query(name: string, fn: () => Promise): Promise> { return traceTask(name, async () => transformPrismaResult(await fn())); } } diff --git a/packages/core/tests/integration/ArtistRepository.test.ts b/packages/core/tests/integration/ArtistRepository.test.ts index c38f3a9..5337078 100644 --- a/packages/core/tests/integration/ArtistRepository.test.ts +++ b/packages/core/tests/integration/ArtistRepository.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { artistRepository, type CreateArtistRequestDto, type UpdateArtistRequestDto } from "@cvsa/core"; +import { + artistRepository, + type CreateArtistRequestDto, + type UpdateArtistRequestDto, +} from "@cvsa/core"; const repository = artistRepository; @@ -26,7 +30,9 @@ describe("ArtistRepository Integration Tests", () => { expect(result.localizedNames).toEqual({ ja: "月華P", en: "Gekka P" }); expect(result.language).toBe("zh"); expect(result.aliases).toEqual(["月光P", "Moonlight"]); - expect(result.description).toBe("A famous composer in the Chinese vocal synth community"); + expect(result.description).toBe( + "A famous composer in the Chinese vocal synth community" + ); expect(result.localizedDescriptions).toEqual({ en: "A famous composer", ja: "有名な作曲家", diff --git a/packages/core/tests/integration/EngineRepository.test.ts b/packages/core/tests/integration/EngineRepository.test.ts index da45528..21efcfd 100644 --- a/packages/core/tests/integration/EngineRepository.test.ts +++ b/packages/core/tests/integration/EngineRepository.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { engineRepository, type CreateEngineRequestDto, type UpdateEngineRequestDto } from "@cvsa/core"; +import { + engineRepository, + type CreateEngineRequestDto, + type UpdateEngineRequestDto, +} from "@cvsa/core"; const repository = engineRepository; diff --git a/packages/core/tests/integration/OutboxRepository.test.ts b/packages/core/tests/integration/OutboxRepository.test.ts index 763dce8..972601b 100644 --- a/packages/core/tests/integration/OutboxRepository.test.ts +++ b/packages/core/tests/integration/OutboxRepository.test.ts @@ -171,11 +171,7 @@ describe("OutboxRepository Integration Tests", () => { aggregateId: 21, eventType: "SongCreated", }); - await repository.markFailed( - created.id, - "Previous error", - new Date(Date.now() - 1000) - ); + await repository.markFailed(created.id, "Previous error", new Date(Date.now() - 1000)); const result = await repository.markProcessing(created.id); @@ -268,11 +264,7 @@ describe("OutboxRepository Integration Tests", () => { eventType: "SongCreated", }); - await repository.markFailed( - created.id, - "First error", - new Date(Date.now() + 60000) - ); + await repository.markFailed(created.id, "First error", new Date(Date.now() + 60000)); const result = await repository.markFailed( created.id, "Second error", diff --git a/packages/core/tests/unit/ArtistSearchService.test.ts b/packages/core/tests/unit/ArtistSearchService.test.ts index a92f98e..c3fdeaf 100644 --- a/packages/core/tests/unit/ArtistSearchService.test.ts +++ b/packages/core/tests/unit/ArtistSearchService.test.ts @@ -74,7 +74,7 @@ describe("ArtistSearchService", () => { service = new ArtistSearchService( mockRepository, mockSearchManager as never, - mockEmbeddingManager as never, + mockEmbeddingManager as never ); }); @@ -86,7 +86,7 @@ describe("ArtistSearchService", () => { describe("sync", () => { test("syncs artist to all language indexes", async () => { (mockRepository.getDetailsById as ReturnType).mockResolvedValue( - mockArtistDetails, + mockArtistDetails ); await service.sync(1); @@ -113,11 +113,11 @@ describe("ArtistSearchService", () => { const serviceWithoutManager = new ArtistSearchService( mockRepository, undefined as never, - mockEmbeddingManager as never, + mockEmbeddingManager as never ); (mockRepository.getDetailsById as ReturnType).mockResolvedValue( - mockArtistDetails, + mockArtistDetails ); await serviceWithoutManager.sync(1); @@ -127,7 +127,7 @@ describe("ArtistSearchService", () => { test("builds document with localized content", async () => { (mockRepository.getDetailsById as ReturnType).mockResolvedValue( - mockArtistDetails, + mockArtistDetails ); await service.sync(1); @@ -148,7 +148,7 @@ describe("ArtistSearchService", () => { localizedDescriptions: {}, }; (mockRepository.getDetailsById as ReturnType).mockResolvedValue( - artistWithoutLocalization, + artistWithoutLocalization ); await service.sync(1); @@ -158,7 +158,7 @@ describe("ArtistSearchService", () => { test("handles embedding generation failure", async () => { (mockRepository.getDetailsById as ReturnType).mockResolvedValue( - mockArtistDetails, + mockArtistDetails ); mockEmbeddingsPost.mockResolvedValue({ data: null, @@ -167,7 +167,11 @@ describe("ArtistSearchService", () => { await service.sync(1); expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); - const doc = ((mockAdminIndex.addDocuments.mock.calls[0] as unknown[])[0] as { _vectors: { "potion-multilingual-128M": unknown } }[])[0]; + const doc = ( + (mockAdminIndex.addDocuments.mock.calls[0] as unknown[])[0] as { + _vectors: { "potion-multilingual-128M": unknown }; + }[] + )[0]; expect(doc._vectors).toEqual({ "potion-multilingual-128M": null, }); @@ -213,11 +217,11 @@ describe("ArtistSearchService", () => { const serviceWithoutManager = new ArtistSearchService( mockRepository, undefined as never, - mockEmbeddingManager as never, + mockEmbeddingManager as never ); expect(serviceWithoutManager.search("test")).rejects.toThrow( - "Search or embedding service not available", + "Search or embedding service not available" ); }); diff --git a/packages/core/tests/unit/ArtistService.test.ts b/packages/core/tests/unit/ArtistService.test.ts index 821c88e..d9f4905 100644 --- a/packages/core/tests/unit/ArtistService.test.ts +++ b/packages/core/tests/unit/ArtistService.test.ts @@ -7,9 +7,11 @@ import type { IArtistRepository } from "../../src/modules/catalog/artist/reposit import type { OutboxService } from "../../src/modules/outbox/service"; import { createMockRepository } from "../utils"; -(prisma as unknown as { $transaction: (fn: (tx: unknown) => Promise) => Promise }).$transaction = mock( - async (fn: (tx: unknown) => Promise) => fn(prisma) -); +( + prisma as unknown as { + $transaction: (fn: (tx: unknown) => Promise) => Promise; + } +).$transaction = mock(async (fn: (tx: unknown) => Promise) => fn(prisma)); const mockArtistDetails: ArtistDetailsResponseDto = { id: 1, @@ -102,10 +104,7 @@ describe("ArtistService", () => { name: "Test Artist", language: "zh", }); - expect(mockRepository.create).toHaveBeenCalledWith( - createInput, - expect.anything() - ); + expect(mockRepository.create).toHaveBeenCalledWith(createInput, expect.anything()); expect(mockOutboxService.createEntry).toHaveBeenCalledWith( { aggregateType: "artist", @@ -129,11 +128,7 @@ describe("ArtistService", () => { language: "zh", }); expect(mockRepository.getById).toHaveBeenCalledWith(1); - expect(mockRepository.update).toHaveBeenCalledWith( - 1, - updateInput, - expect.anything() - ); + expect(mockRepository.update).toHaveBeenCalledWith(1, updateInput, expect.anything()); expect(mockOutboxService.createEntry).toHaveBeenCalledWith( { aggregateType: "artist", @@ -149,9 +144,7 @@ describe("ArtistService", () => { mockRepository.getById.mockResolvedValueOnce(null); expect(artistService.update(999, updateInput)).rejects.toThrow(AppError); - expect(artistService.update(999, updateInput)).rejects.toThrow( - "error.artist.notfound" - ); + expect(artistService.update(999, updateInput)).rejects.toThrow("error.artist.notfound"); expect(artistService.update(999, updateInput)).rejects.toMatchObject({ code: "NOT_FOUND", statusCode: 404, @@ -164,10 +157,7 @@ describe("ArtistService", () => { await artistService.delete(1); expect(mockRepository.getById).toHaveBeenCalledWith(1); - expect(mockRepository.softDelete).toHaveBeenCalledWith( - 1, - expect.anything() - ); + expect(mockRepository.softDelete).toHaveBeenCalledWith(1, expect.anything()); expect(mockOutboxService.createEntry).toHaveBeenCalledWith( { aggregateType: "artist", diff --git a/packages/core/tests/unit/EngineService.test.ts b/packages/core/tests/unit/EngineService.test.ts index 210b7a4..7f47005 100644 --- a/packages/core/tests/unit/EngineService.test.ts +++ b/packages/core/tests/unit/EngineService.test.ts @@ -33,9 +33,7 @@ describe("EngineService", () => { softDelete: async () => {}, }); - const engineService = new EngineService( - mockRepository as unknown as IEngineRepository - ); + const engineService = new EngineService(mockRepository as unknown as IEngineRepository); describe("getDetails", () => { test("returns engine details when engine exists", async () => { @@ -92,9 +90,7 @@ describe("EngineService", () => { mockRepository.getById.mockResolvedValueOnce(null); expect(engineService.update(999, updateInput)).rejects.toThrow(AppError); - expect(engineService.update(999, updateInput)).rejects.toThrow( - "error.engine.notfound" - ); + expect(engineService.update(999, updateInput)).rejects.toThrow("error.engine.notfound"); expect(engineService.update(999, updateInput)).rejects.toMatchObject({ code: "NOT_FOUND", statusCode: 404, diff --git a/packages/core/tests/unit/OutboxService.test.ts b/packages/core/tests/unit/OutboxService.test.ts index 5cd0d0e..4d0d8ba 100644 --- a/packages/core/tests/unit/OutboxService.test.ts +++ b/packages/core/tests/unit/OutboxService.test.ts @@ -33,15 +33,14 @@ describe("OutboxService", () => { markProcessed: mock(async (id: number) => mockOutboxEntry({ id, status: "PROCESSED", processedAt: new Date().toISOString() }) ), - markFailed: mock( - async (id: number, lastError: string, nextRetryAt: Date) => - mockOutboxEntry({ - id, - status: "FAILED", - lastError, - nextRetryAt: nextRetryAt.toISOString(), - retryCount: 1, - }) + markFailed: mock(async (id: number, lastError: string, nextRetryAt: Date) => + mockOutboxEntry({ + id, + status: "FAILED", + lastError, + nextRetryAt: nextRetryAt.toISOString(), + retryCount: 1, + }) ), }; @@ -49,7 +48,10 @@ describe("OutboxService", () => { add: mock(async () => {}), }; - const service = new OutboxService(mockRepository as unknown as never, mockQueue as unknown as never); + const service = new OutboxService( + mockRepository as unknown as never, + mockQueue as unknown as never + ); beforeEach(() => { mockRepository.create.mockClear(); @@ -207,9 +209,21 @@ describe("OutboxService", () => { expect(mockRepository.findPending).toHaveBeenCalledWith({ limit: 50 }); expect(mockQueue.add).toHaveBeenCalledTimes(3); - const firstCall = mockQueue.add.mock.calls[0] as unknown as [string, OutboxEntryDto, unknown]; - const secondCall = mockQueue.add.mock.calls[1] as unknown as [string, OutboxEntryDto, unknown]; - const thirdCall = mockQueue.add.mock.calls[2] as unknown as [string, OutboxEntryDto, unknown]; + const firstCall = mockQueue.add.mock.calls[0] as unknown as [ + string, + OutboxEntryDto, + unknown, + ]; + const secondCall = mockQueue.add.mock.calls[1] as unknown as [ + string, + OutboxEntryDto, + unknown, + ]; + const thirdCall = mockQueue.add.mock.calls[2] as unknown as [ + string, + OutboxEntryDto, + unknown, + ]; expect(firstCall[0]).toBe("outbox-recover-1"); expect(secondCall[0]).toBe("outbox-recover-2"); expect(thirdCall[0]).toBe("outbox-recover-3"); diff --git a/packages/core/tests/unit/SearchManager.test.ts b/packages/core/tests/unit/SearchManager.test.ts index 8af3876..8225f66 100644 --- a/packages/core/tests/unit/SearchManager.test.ts +++ b/packages/core/tests/unit/SearchManager.test.ts @@ -404,10 +404,7 @@ describe("SearchManager", () => { ], }); mockGetIndexes.mockResolvedValue({ - results: [ - createMockIndex("song_zh-CN"), - createMockIndex("artist_en"), - ], + results: [createMockIndex("song_zh-CN"), createMockIndex("artist_en")], }); const manager = await SearchManager.create( diff --git a/packages/core/tests/unit/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts index 2334a45..4a1529a 100644 --- a/packages/core/tests/unit/SongService.test.ts +++ b/packages/core/tests/unit/SongService.test.ts @@ -257,7 +257,11 @@ describe("SongService", () => { language: "zh", plainText: "Test lyrics", }); - expect(mockRepository.createLyrics).toHaveBeenCalledWith(1, createInput, expect.anything()); + expect(mockRepository.createLyrics).toHaveBeenCalledWith( + 1, + createInput, + expect.anything() + ); }); test("throws NOT_FOUND error when song does not exist", async () => { @@ -288,7 +292,11 @@ describe("SongService", () => { const result = await songService.updateLyric(1, 1, updateInput); expect(result.plainText).toBe("Updated lyrics"); - expect(mockRepository.updateLyric).toHaveBeenCalledWith(1, updateInput, expect.anything()); + expect(mockRepository.updateLyric).toHaveBeenCalledWith( + 1, + updateInput, + expect.anything() + ); }); test("throws NOT_FOUND error when song does not exist", async () => {