diff --git a/apps/backend/src/handlers/catalog/artist/index.ts b/apps/backend/src/handlers/catalog/artist/index.ts index 498c5f5..4adfb99 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/apps/backend/src/index.ts b/apps/backend/src/index.ts index 216dfd7..d8407cd 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"; +import { processOutboxEntry } from "@cvsa/core"; +import { outboxService } from "@cvsa/core"; +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/apps/backend/tests/artist.test.ts b/apps/backend/tests/artist.test.ts index 93f7f6f..36a1841 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,19 +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({ username: `${Math.random()}`, 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..54f1f29 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,19 +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({ username: `${Math.random()}`, 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/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 65f64a0..d987e80 100644 --- a/locale/modules/backend/messages/en.json +++ b/locale/modules/backend/messages/en.json @@ -9,5 +9,7 @@ "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.", + "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 52acc79..8159c29 100644 --- a/locale/modules/backend/messages/zh.json +++ b/locale/modules/backend/messages/zh.json @@ -9,5 +9,7 @@ "error.internal": "服务器内部错误", "error.validation": "输入信息有误", "error.username-taken": "此用户名已被注册", - "error.email-taken": "此邮箱已被注册" + "error.email-taken": "此邮箱已被注册", + "error.artist.notfound": "该创作者不存在", + "error.engine.notfound": "该引擎不存在" } diff --git a/package.json b/package.json index 5fbbf28..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 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/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 1e3e5af..2458fff 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 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", @@ -30,6 +32,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/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/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/modules/catalog/artist/container.ts b/packages/core/src/modules/catalog/artist/container.ts index f8c2c71..6199de6 100644 --- a/packages/core/src/modules/catalog/artist/container.ts +++ b/packages/core/src/modules/catalog/artist/container.ts @@ -1,6 +1,17 @@ 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"; +import { outboxService } from "../../outbox/container"; + +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, outboxService); diff --git a/packages/core/src/modules/catalog/artist/repository.ts b/packages/core/src/modules/catalog/artist/repository.ts index 0897f52..279796d 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 "../../../utils/BaseRepository"; 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 d688934..854c873 100644 --- a/packages/core/src/modules/catalog/artist/service.ts +++ b/packages/core/src/modules/catalog/artist/service.ts @@ -1,4 +1,7 @@ -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, ArtistId, @@ -7,25 +10,37 @@ import type { ArtistResponseDto, } from "./dto"; import type { IArtistRepository } from "./repository.interface"; -import { traceTask } from "@cvsa/observability"; export class ArtistService implements IServiceWithGetDetails { - constructor(private readonly repository: IArtistRepository) {} + constructor( + private readonly repository: IArtistRepository, + private readonly outbox: OutboxService + ) {} async getDetails(id: ArtistId) { - return traceTask("db findOne artist", async () => { - 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 this.repository.create(input); + 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 { @@ -33,9 +48,22 @@ export class ArtistService implements IServiceWithGetDetails { - return await this.repository.update(id, input); + + 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 { @@ -43,8 +71,19 @@ export class ArtistService implements IServiceWithGetDetails { - return await this.repository.softDelete(id); + + 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..27358f8 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 "../../../utils/BaseRepository"; 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/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/container.ts b/packages/core/src/modules/catalog/song/container.ts index ee6bdf9..86b9f0d 100644 --- a/packages/core/src/modules/catalog/song/container.ts +++ b/packages/core/src/modules/catalog/song/container.ts @@ -2,13 +2,16 @@ 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"; +import { outboxService } from "../../outbox/container"; + +const embeddingManager = treaty("localhost:14900"); + export const songRepository = new SongRepository(prisma); export const songSearchService = new SongSearchService( songRepository, 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/repository.ts b/packages/core/src/modules/catalog/song/repository.ts index 7ac77bc..3de15aa 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 "../../../utils/BaseRepository"; 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 8e9a6ba..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 { SongSearchService } from "@cvsa/core/internal"; +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,35 +13,37 @@ import type { SongLyricsListResponseDto, } from "./dto"; import type { ISongRepository } from "./repository.interface"; -import { traceTask } from "@cvsa/observability"; -import { appLogger } from "@cvsa/logger"; export class SongService implements IServiceWithGetDetails { constructor( private readonly repository: ISongRepository, - private readonly search: SongSearchService + private readonly outbox: OutboxService ) {} async getDetails(id: SongId) { - return traceTask("db findOne song", async () => { - 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 { - const result = await traceTask("db create song", async () => { - return await this.repository.create(input); + 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 traceTask("sync search index", async () => { - this.search.sync(result.id).catch((e) => { - appLogger.warn(Bun.inspect(e)); - }); - }); - return result; + + await this.outbox.enqueue(entry); + return song; } async update(id: SongId, input: UpdateSongRequestDto): Promise { @@ -48,16 +51,22 @@ 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)); - }); + + 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 }; }); - return result; + + await this.outbox.enqueue(entry); + return song; } async delete(id: SongId): Promise { @@ -65,14 +74,20 @@ 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)); - }); + + 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 { @@ -80,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 { @@ -90,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); } @@ -107,15 +118,22 @@ 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)); - }); + + 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 }; }); - return result; + + await this.outbox.enqueue(entry); + return lyric; } async updateLyric( @@ -131,15 +149,22 @@ 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)); - }); + + 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 }; }); - return result; + + await this.outbox.enqueue(entry); + return updated; } async deleteLyric(id: SongId, lyricId: number): Promise { @@ -151,13 +176,19 @@ 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)); - }); + + 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/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..14a30de --- /dev/null +++ b/packages/core/src/modules/outbox/service.ts @@ -0,0 +1,78 @@ +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"; +import { outboxQueue } from "../../outbox/queue"; +import { appLogger } from "@cvsa/logger"; +import { traceTask } from "@cvsa/observability"; + +const MAX_RETRIES = 5; + +export class OutboxService { + 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)); + } + + 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 }, + }); + }); + } + + 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 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/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..de224e7 --- /dev/null +++ b/packages/core/src/outbox/processor.ts @@ -0,0 +1,48 @@ +import type { Job } from "bullmq"; +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 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, + } + ); + + const searchService = deps.searchServices[aggregateType]; + if (!searchService) { + appLogger.warn(`Unknown aggregate type: ${aggregateType}`); + throw new Error(`Unknown aggregate type: ${aggregateType}`); + } + + await searchService.sync(aggregateId); + } + ); + }; +} + +export const processOutboxEntry = createOutboxProcessor({ + outboxService, + searchServices: { + song: songSearchService, + artist: artistSearchService, + }, +}); 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/src/search/catalog/artist.ts b/packages/core/src/search/catalog/artist.ts new file mode 100644 index 0000000..edc5e7d --- /dev/null +++ b/packages/core/src/search/catalog/artist.ts @@ -0,0 +1,105 @@ +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/catalog/song.ts b/packages/core/src/search/catalog/song.ts index d67ed7c..5449fb6 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, + }, }; } @@ -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/config.ts b/packages/core/src/search/config.ts index 2f8d436..410db0a 100644 --- a/packages/core/src/search/config.ts +++ b/packages/core/src/search/config.ts @@ -47,4 +47,14 @@ 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, + }, + }, + }, }; diff --git a/packages/core/src/search/manager.ts b/packages/core/src/search/manager.ts index d9ab328..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 } 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"; @@ -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,43 @@ 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; } } } + + /** + * 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; + } + + const { results: indexes } = await this.listIndexes(); + for (const index of indexes) { + await this.adminClient.deleteIndexIfExists(index.uid); + appLogger.info(`Deleted search index ${index.uid}`); + } + } + + /** + * 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; + } + return this.adminClient.createIndex(uid, options); + } } diff --git a/packages/core/src/utils/BaseRepository.ts b/packages/core/src/utils/BaseRepository.ts new file mode 100644 index 0000000..fe9ef54 --- /dev/null +++ b/packages/core/src/utils/BaseRepository.ts @@ -0,0 +1,8 @@ +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/integration/ArtistRepository.test.ts b/packages/core/tests/integration/ArtistRepository.test.ts new file mode 100644 index 0000000..5337078 --- /dev/null +++ b/packages/core/tests/integration/ArtistRepository.test.ts @@ -0,0 +1,154 @@ +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..21efcfd --- /dev/null +++ b/packages/core/tests/integration/EngineRepository.test.ts @@ -0,0 +1,142 @@ +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/OutboxRepository.test.ts b/packages/core/tests/integration/OutboxRepository.test.ts new file mode 100644 index 0000000..972601b --- /dev/null +++ b/packages/core/tests/integration/OutboxRepository.test.ts @@ -0,0 +1,322 @@ +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/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..238e88a 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 { describe, expect, test } from "bun:test"; import { songRepository } from "@cvsa/core"; import { prisma } from "@cvsa/db"; import { SongSearchService } from "../../src/search/catalog/song"; @@ -10,77 +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 - } - }); - - 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({ @@ -102,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"); @@ -121,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"); @@ -150,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"); @@ -181,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/ArtistSearchService.test.ts b/packages/core/tests/unit/ArtistSearchService.test.ts new file mode 100644 index 0000000..c3fdeaf --- /dev/null +++ b/packages/core/tests/unit/ArtistSearchService.test.ts @@ -0,0 +1,244 @@ +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/ArtistService.test.ts b/packages/core/tests/unit/ArtistService.test.ts new file mode 100644 index 0000000..d9f4905 --- /dev/null +++ b/packages/core/tests/unit/ArtistService.test.ts @@ -0,0 +1,183 @@ +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..7f47005 --- /dev/null +++ b/packages/core/tests/unit/EngineService.test.ts @@ -0,0 +1,120 @@ +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/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..4d0d8ba --- /dev/null +++ b/packages/core/tests/unit/OutboxService.test.ts @@ -0,0 +1,248 @@ +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 c502d47..8225f66 100644 --- a/packages/core/tests/unit/SearchManager.test.ts +++ b/packages/core/tests/unit/SearchManager.test.ts @@ -19,8 +19,14 @@ 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 mockAdminDeleteIndexIfExists = mock(); +const mockAdminCreateIndex = mock(); const mockIndexInstance = { getSettings: mockIndexGetSettings, @@ -62,6 +68,8 @@ const createMockClient = (): { client: MockClient; adminClient: MockClient } => getIndexes: mockGetIndexes, getKeys: mockGetKeys, createKey: mockCreateKey, + deleteIndexIfExists: mockAdminDeleteIndexIfExists, + createIndex: mockAdminCreateIndex, tasks: { waitForTask: mockWaitForTask, getTask: mockGetTask, @@ -80,6 +88,8 @@ describe("SearchManager", () => { mockIndexUpdateSettings.mockClear(); mockWaitForTask.mockClear(); mockGetTask.mockClear(); + mockAdminDeleteIndexIfExists.mockClear(); + mockAdminCreateIndex.mockClear(); }); describe("create with custom clients", () => { @@ -382,4 +392,91 @@ 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); + }); + }); }); diff --git a/packages/core/tests/unit/SongSearchService.test.ts b/packages/core/tests/unit/SongSearchService.test.ts index 29fb055..fd51fa1 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, + }); }); }); @@ -299,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/packages/core/tests/unit/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts index 370a328..4a1529a 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,28 @@ 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, + })), + enqueue: mock(async () => {}), + 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,22 +120,7 @@ describe("SongService", () => { duration: 200, }; - test("creates song and calls search.sync", async () => { - const result = await songService.create(createInput); - - expect(result).toMatchObject({ - name: "Test Song", - 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"); - }); - + test("creates song and calls outbox.createEntry", async () => { const result = await songService.create(createInput); expect(result).toMatchObject({ @@ -128,14 +128,14 @@ describe("SongService", () => { 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 +144,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 +157,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 +177,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 +257,11 @@ 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 +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(mockRepository.updateLyric).toHaveBeenCalledWith( + 1, + updateInput, + expect.anything() + ); }); test("throws NOT_FOUND error when song does not exist", async () => { @@ -350,7 +333,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/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/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/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/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, diff --git a/turbo.json b/turbo.json index a18aab5..31beb68 100644 --- a/turbo.json +++ b/turbo.json @@ -48,6 +48,15 @@ "tsconfig.json", "$TURBO_ROOT$/packages/typescript-config/base.json" ] + }, + "@cvsa/db#test:reset": { + "cache": false + }, + "@cvsa/core#test:reset": { + "cache": false + }, + "//test:reset": { + "dependsOn": ["@cvsa/db#test:reset", "@cvsa/core#test:reset"] } } }