From 9fe30feec575e5e7b213fd9769eb35222656ae2e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 12:24:17 +0200 Subject: [PATCH] feat(server): Drizzle + PostgreSQL schema + Open Trivia DB seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drizzle (postgres-js) setup: schema (quiz_category, quiz_question, quiz_played, game_history), client (nullable — no DATABASE_URL → null), drizzle.config, initial migration, migrate script - Open Trivia DB seed: curated geek categories, mcq + truefalse, base64 decode (incl. type/difficulty), 1 req/5s rate limit, idempotent (prompt unique); also seeds the FR in-code bank as source 'manual' - quiz wired to the DB: per-room pool preloaded at game start (loadQuizPool excludes already-played), records quiz_played for cross-session anti-repeat; falls back to the in-code bank when no DB - docker-compose for local Postgres; db:* scripts; DATABASE_URL in env Roadmap V1 step 5. Verified against a real Postgres: migrate + seed (270 OpenTDB + 10 manual), DB-backed game serves OpenTDB questions and fills quiz_played; no-DB fallback still serves the in-code bank. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/.env.example | 2 + apps/server/drizzle.config.ts | 10 + .../drizzle/0000_loose_doctor_spectrum.sql | 39 +++ apps/server/drizzle/meta/0000_snapshot.json | 263 ++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 13 + apps/server/package.json | 9 +- apps/server/src/db/index.ts | 21 ++ apps/server/src/db/migrate.ts | 18 ++ apps/server/src/db/quiz-repo.ts | 71 +++++ apps/server/src/db/schema.ts | 72 +++++ apps/server/src/db/seed.ts | 193 +++++++++++++ apps/server/src/env.ts | 2 + apps/server/src/game/modes/index.ts | 10 + apps/server/src/game/modes/quiz/pool.ts | 61 ++++ apps/server/src/game/modes/quiz/quiz-round.ts | 31 +-- apps/server/src/handlers.ts | 12 +- bun.lock | 125 +++++++++ docker-compose.yml | 24 ++ 18 files changed, 950 insertions(+), 26 deletions(-) create mode 100644 apps/server/drizzle.config.ts create mode 100644 apps/server/drizzle/0000_loose_doctor_spectrum.sql create mode 100644 apps/server/drizzle/meta/0000_snapshot.json create mode 100644 apps/server/drizzle/meta/_journal.json create mode 100644 apps/server/src/db/index.ts create mode 100644 apps/server/src/db/migrate.ts create mode 100644 apps/server/src/db/quiz-repo.ts create mode 100644 apps/server/src/db/schema.ts create mode 100644 apps/server/src/db/seed.ts create mode 100644 apps/server/src/game/modes/quiz/pool.ts create mode 100644 docker-compose.yml diff --git a/apps/server/.env.example b/apps/server/.env.example index 4680059..e928b54 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -2,3 +2,5 @@ NODE_ENV=development HOST=0.0.0.0 PORT=3001 CORS_ORIGINS=http://localhost:5173 +# Laisser vide pour jouer sans DB (banque de questions en dur). +DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts new file mode 100644 index 0000000..1ed9364 --- /dev/null +++ b/apps/server/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit" + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/schema.ts", + out: "./drizzle", + dbCredentials: { + url: process.env.DATABASE_URL ?? "", + }, +}) diff --git a/apps/server/drizzle/0000_loose_doctor_spectrum.sql b/apps/server/drizzle/0000_loose_doctor_spectrum.sql new file mode 100644 index 0000000..ec13ca3 --- /dev/null +++ b/apps/server/drizzle/0000_loose_doctor_spectrum.sql @@ -0,0 +1,39 @@ +CREATE TABLE "game_history" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "room_code" text NOT NULL, + "played_at" timestamp with time zone DEFAULT now() NOT NULL, + "modes" jsonb, + "results" jsonb +); +--> statement-breakpoint +CREATE TABLE "quiz_category" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "quiz_category_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "quiz_played" ( + "question_id" uuid NOT NULL, + "room_code" text NOT NULL, + "played_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "quiz_played_question_id_room_code_pk" PRIMARY KEY("question_id","room_code") +); +--> statement-breakpoint +CREATE TABLE "quiz_question" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "category_id" uuid, + "format" text NOT NULL, + "prompt" text NOT NULL, + "difficulty" integer DEFAULT 1 NOT NULL, + "source" text NOT NULL, + "choices" jsonb, + "correct_index" integer, + "accepted_answers" jsonb, + "image_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "quiz_question_prompt_unique" UNIQUE("prompt") +); +--> statement-breakpoint +ALTER TABLE "quiz_played" ADD CONSTRAINT "quiz_played_question_id_quiz_question_id_fk" FOREIGN KEY ("question_id") REFERENCES "public"."quiz_question"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "quiz_question" ADD CONSTRAINT "quiz_question_category_id_quiz_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."quiz_category"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0000_snapshot.json b/apps/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..c5eb80f --- /dev/null +++ b/apps/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,263 @@ +{ + "id": "c68e2f59-3d1b-4fca-8443-17fe243e2919", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.game_history": { + "name": "game_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "played_at": { + "name": "played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modes": { + "name": "modes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "results": { + "name": "results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_category": { + "name": "quiz_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quiz_category_name_unique": { + "name": "quiz_category_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_played": { + "name": "quiz_played", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "played_at": { + "name": "played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_played_question_id_quiz_question_id_fk": { + "name": "quiz_played_question_id_quiz_question_id_fk", + "tableFrom": "quiz_played", + "tableTo": "quiz_question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_played_question_id_room_code_pk": { + "name": "quiz_played_question_id_room_code_pk", + "columns": [ + "question_id", + "room_code" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question": { + "name": "quiz_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "choices": { + "name": "choices", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correct_index": { + "name": "correct_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accepted_answers": { + "name": "accepted_answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_question_category_id_quiz_category_id_fk": { + "name": "quiz_question_category_id_quiz_category_id_fk", + "tableFrom": "quiz_question", + "tableTo": "quiz_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quiz_question_prompt_unique": { + "name": "quiz_question_prompt_unique", + "nullsNotDistinct": false, + "columns": [ + "prompt" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..4a441f0 --- /dev/null +++ b/apps/server/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781085893179, + "tag": "0000_loose_doctor_spectrum", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 34534ed..31db470 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,18 +10,25 @@ "build": "bun build ./src/index.ts --target bun --outdir dist", "lint": "eslint", "format": "prettier --write \"**/*.ts\"", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "bun src/db/migrate.ts", + "db:seed": "bun src/db/seed.ts", + "db:studio": "drizzle-kit studio" }, "dependencies": { "@fastify/cors": "^11", "@nerdware/shared": "workspace:*", + "drizzle-orm": "^0.45", "fastify": "^5", + "postgres": "^3.4", "socket.io": "^4.8.1" }, "devDependencies": { "@eslint/js": "^10", "@types/bun": "latest", "@types/node": "^24", + "drizzle-kit": "^0.31", "eslint": "^10", "globals": "^17", "pino-pretty": "^13", diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts new file mode 100644 index 0000000..a6712e1 --- /dev/null +++ b/apps/server/src/db/index.ts @@ -0,0 +1,21 @@ +// Client Drizzle/PostgreSQL. Optionnel : sans DATABASE_URL, `db` vaut null et +// le jeu retombe sur la banque de questions en dur (dev sans infra). + +import { drizzle } from "drizzle-orm/postgres-js" +import postgres from "postgres" +import { env } from "../env" +import * as schema from "./schema" + +export type Database = ReturnType> + +function createDb(): Database | null { + if (!env.databaseUrl) { + return null + } + const client = postgres(env.databaseUrl) + return drizzle(client, { schema }) +} + +export const db = createDb() +export const hasDb = db !== null +export { schema } diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts new file mode 100644 index 0000000..6f708c4 --- /dev/null +++ b/apps/server/src/db/migrate.ts @@ -0,0 +1,18 @@ +// Applique les migrations générées (drizzle/*.sql) à la base. +// bun run db:migrate +import { drizzle } from "drizzle-orm/postgres-js" +import { migrate } from "drizzle-orm/postgres-js/migrator" +import postgres from "postgres" +import { env } from "../env" + +if (!env.databaseUrl) { + console.error("DATABASE_URL manquant — rien à migrer.") + process.exit(1) +} + +const client = postgres(env.databaseUrl, { max: 1 }) +const db = drizzle(client) + +await migrate(db, { migrationsFolder: "./drizzle" }) +console.log("Migrations appliquées.") +await client.end() diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts new file mode 100644 index 0000000..92eec3d --- /dev/null +++ b/apps/server/src/db/quiz-repo.ts @@ -0,0 +1,71 @@ +// Accès aux questions de quiz en base. No-op si pas de DB (db === null). + +import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm" +import { db } from "./index" +import { quizCategory, quizPlayed, quizQuestion } from "./schema" +import type { QuizQuestion } from "../game/modes/quiz/questions" + +/** + * Pool de questions mcq/truefalse non encore jouées par cette room, + * mélangées et limitées. Tableau vide si pas de DB ou rien à servir. + */ +export async function loadQuizPool( + roomCode: string, + limit: number +): Promise { + if (!db) { + return [] + } + const alreadyPlayed = db + .select({ id: quizPlayed.questionId }) + .from(quizPlayed) + .where(eq(quizPlayed.roomCode, roomCode)) + + const rows = await db + .select({ + id: quizQuestion.id, + format: quizQuestion.format, + prompt: quizQuestion.prompt, + choices: quizQuestion.choices, + correctIndex: quizQuestion.correctIndex, + difficulty: quizQuestion.difficulty, + category: quizCategory.name, + }) + .from(quizQuestion) + .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) + .where( + and( + inArray(quizQuestion.format, ["mcq", "truefalse"]), + isNotNull(quizQuestion.correctIndex), + notInArray(quizQuestion.id, alreadyPlayed) + ) + ) + .orderBy(sql`random()`) + .limit(limit) + + return rows + .filter((r) => r.choices && r.correctIndex !== null) + .map((r) => ({ + id: r.id, + format: r.format as QuizQuestion["format"], + prompt: r.prompt, + choices: r.choices as string[], + correctIndex: r.correctIndex as number, + category: r.category ?? "Quiz", + difficulty: r.difficulty, + })) +} + +/** Marque une question comme jouée par cette room (anti-répétition). */ +export async function markQuestionPlayed( + roomCode: string, + questionId: string +): Promise { + if (!db) { + return + } + await db + .insert(quizPlayed) + .values({ roomCode, questionId }) + .onConflictDoNothing() +} diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 0000000..668257f --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,72 @@ +// Schéma persistant (PostgreSQL via Drizzle). Ne stocke que le contenu quiz +// et l'historique — les rooms vivent en mémoire (voir ARCHITECTURE §3). + +import { sql } from "drizzle-orm" +import { + integer, + jsonb, + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core" + +export const quizCategory = pgTable("quiz_category", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), +}) + +export const quizQuestion = pgTable("quiz_question", { + id: uuid("id").primaryKey().defaultRandom(), + categoryId: uuid("category_id").references(() => quizCategory.id), + /** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */ + format: text("format").notNull(), + /** Unique → seed idempotent (onConflictDoNothing). */ + prompt: text("prompt").notNull().unique(), + /** 1 (facile) .. 3 (difficile). */ + difficulty: integer("difficulty").notNull().default(1), + /** 'opentdb' | 'manual' */ + source: text("source").notNull(), + // Champs selon le format (NULL si non applicable) : + choices: jsonb("choices").$type(), + correctIndex: integer("correct_index"), + acceptedAnswers: jsonb("accepted_answers").$type(), + imageUrl: text("image_url"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), +}) + +/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */ +export const quizPlayed = pgTable( + "quiz_played", + { + questionId: uuid("question_id") + .notNull() + .references(() => quizQuestion.id), + roomCode: text("room_code").notNull(), + playedAt: timestamp("played_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), + }, + (t) => [primaryKey({ columns: [t.questionId, t.roomCode] })] +) + +/** Historique de parties (stats / portfolio). */ +export const gameHistory = pgTable("game_history", { + id: uuid("id").primaryKey().defaultRandom(), + roomCode: text("room_code").notNull(), + playedAt: timestamp("played_at", { withTimezone: true }) + .notNull() + .default(sql`now()`), + modes: jsonb("modes").$type(), + results: jsonb("results"), +}) + +export type QuizQuestionRow = typeof quizQuestion.$inferSelect +export type NewQuizQuestion = typeof quizQuestion.$inferInsert +export type NewQuizCategory = typeof quizCategory.$inferInsert diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts new file mode 100644 index 0000000..019a499 --- /dev/null +++ b/apps/server/src/db/seed.ts @@ -0,0 +1,193 @@ +// Seed du contenu quiz. One-shot au déploiement : bun run db:seed +// - Open Trivia DB : catégories geek (mcq + truefalse), respect 1 req / 5 s. +// - Banque FR en dur : insérée comme source 'manual'. +// Idempotent : prompt unique → onConflictDoNothing. + +import { eq } from "drizzle-orm" +import { drizzle } from "drizzle-orm/postgres-js" +import postgres from "postgres" +import { env } from "../env" +import { + quizCategory, + quizQuestion, + type NewQuizQuestion, +} from "./schema" +import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions" + +if (!env.databaseUrl) { + console.error("DATABASE_URL manquant — impossible de seeder.") + process.exit(1) +} + +const client = postgres(env.databaseUrl, { max: 1 }) +const db = drizzle(client, { schema: { quizCategory, quizQuestion } }) + +// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché). +const OPENTDB_CATEGORIES = [ + { id: 15, name: "Jeux vidéo" }, + { id: 31, name: "Anime & Manga" }, + { id: 29, name: "Comics" }, + { id: 11, name: "Cinéma" }, + { id: 14, name: "Télévision" }, + { id: 18, name: "Informatique" }, +] +const AMOUNT = 30 +const RATE_LIMIT_MS = 5200 // OpenTDB : 1 requête / 5 s par IP + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const decode = (s: string) => Buffer.from(s, "base64").toString("utf8") +const difficultyOf = (d: string) => (d === "hard" ? 3 : d === "medium" ? 2 : 1) + +// Tous les champs string sont encodés en base64 (encode=base64). +interface OtdbQuestion { + type: string + difficulty: string + question: string + correct_answer: string + incorrect_answers: string[] +} + +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +async function getToken(): Promise { + try { + const res = await fetch("https://opentdb.com/api_token.php?command=request") + const json = (await res.json()) as { token?: string } + return json.token + } catch { + return undefined + } +} + +async function fetchBatch( + categoryId: number, + type: "multiple" | "boolean", + token: string | undefined +): Promise { + const url = + `https://opentdb.com/api.php?amount=${AMOUNT}&category=${categoryId}` + + `&type=${type}&encode=base64${token ? `&token=${token}` : ""}` + const res = await fetch(url) + const json = (await res.json()) as { + response_code: number + results: OtdbQuestion[] + } + if (json.response_code === 5) { + // Rate limit : on attend et on réessaie une fois. + await sleep(RATE_LIMIT_MS) + return fetchBatch(categoryId, type, token) + } + return json.response_code === 0 ? json.results : [] +} + +/** Upsert une catégorie et renvoie son id. */ +async function upsertCategory(name: string): Promise { + await db.insert(quizCategory).values({ name }).onConflictDoNothing() + const [row] = await db + .select({ id: quizCategory.id }) + .from(quizCategory) + .where(eq(quizCategory.name, name)) + return row.id +} + +function toQuestion( + q: OtdbQuestion, + categoryId: string +): NewQuizQuestion { + // Avec encode=base64, TOUS les champs sont encodés (type/difficulty inclus). + const prompt = decode(q.question) + const type = decode(q.type) + const difficulty = difficultyOf(decode(q.difficulty)) + if (type === "boolean") { + const correct = decode(q.correct_answer) // "True" | "False" + return { + categoryId, + format: "truefalse", + prompt, + difficulty, + source: "opentdb", + choices: ["True", "False"], + correctIndex: correct === "True" ? 0 : 1, + } + } + const correct = decode(q.correct_answer) + const choices = shuffle([ + correct, + ...q.incorrect_answers.map(decode), + ]) + return { + categoryId, + format: "mcq", + prompt, + difficulty, + source: "opentdb", + choices, + correctIndex: choices.indexOf(correct), + } +} + +async function insertQuestions(rows: NewQuizQuestion[]): Promise { + if (rows.length === 0) { + return 0 + } + const inserted = await db + .insert(quizQuestion) + .values(rows) + .onConflictDoNothing() + .returning({ id: quizQuestion.id }) + return inserted.length +} + +async function seedOpenTdb(): Promise { + const token = await getToken() + let total = 0 + let first = true + for (const cat of OPENTDB_CATEGORIES) { + const categoryId = await upsertCategory(cat.name) + for (const type of ["multiple", "boolean"] as const) { + if (!first) { + await sleep(RATE_LIMIT_MS) + } + first = false + const batch = await fetchBatch(cat.id, type, token) + const rows = batch.map((q) => toQuestion(q, categoryId)) + const n = await insertQuestions(rows) + total += n + console.log(` ${cat.name} (${type}): +${n}`) + } + } + return total +} + +async function seedManual(): Promise { + let total = 0 + for (const q of QUIZ_QUESTIONS) { + const categoryId = await upsertCategory(q.category) + total += await insertQuestions([ + { + categoryId, + format: q.format, + prompt: q.prompt, + difficulty: q.difficulty, + source: "manual", + choices: q.choices, + correctIndex: q.correctIndex, + }, + ]) + } + return total +} + +console.log("Seed Open Trivia DB (respect du 1 req / 5 s, patientez)…") +const otdb = await seedOpenTdb() +console.log("Seed banque FR (manual)…") +const manual = await seedManual() +console.log(`Terminé : ${otdb} questions OpenTDB + ${manual} manuelles.`) +await client.end() diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 5551908..169dbfa 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -10,6 +10,8 @@ export const env = { nodeEnv: process.env.NODE_ENV ?? "development", host: process.env.HOST ?? "0.0.0.0", port: num(process.env.PORT, 3001), + /** Connexion PostgreSQL. Vide → pas de DB, fallback banque en dur. */ + databaseUrl: process.env.DATABASE_URL ?? "", /** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */ corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173") .split(",") diff --git a/apps/server/src/game/modes/index.ts b/apps/server/src/game/modes/index.ts index d413056..684f1f1 100644 --- a/apps/server/src/game/modes/index.ts +++ b/apps/server/src/game/modes/index.ts @@ -2,3 +2,13 @@ // pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici. import "./quiz" + +import type { ServerRoom } from "../../rooms" +import { prepareQuizForRoom } from "./quiz/pool" + +/** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */ +export async function prepareRoom(room: ServerRoom): Promise { + if (room.settings.rounds.some((r) => r.type === "quiz")) { + await prepareQuizForRoom(room) + } +} diff --git a/apps/server/src/game/modes/quiz/pool.ts b/apps/server/src/game/modes/quiz/pool.ts new file mode 100644 index 0000000..5a065fc --- /dev/null +++ b/apps/server/src/game/modes/quiz/pool.ts @@ -0,0 +1,61 @@ +// File d'attente de questions par room. Préchargée au lancement de la partie +// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche. + +import { hasDb } from "../../../db" +import { loadQuizPool } from "../../../db/quiz-repo" +import type { ServerRoom } from "../../../rooms" +import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" + +interface RoomPool { + queue: QuizQuestion[] + /** true si les questions viennent de la DB (→ marquer jouées). */ + fromDb: boolean +} + +const poolByRoom = new WeakMap() + +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */ +export async function prepareQuizForRoom(room: ServerRoom): Promise { + const needed = room.settings.rounds.filter((r) => r.type === "quiz").length + let queue: QuizQuestion[] = [] + let fromDb = false + + if (hasDb) { + try { + queue = await loadQuizPool(room.code, Math.max(needed, 1)) + fromDb = queue.length > 0 + } catch (err) { + console.error("[quiz] chargement DB échoué, fallback banque en dur", err) + } + } + + if (queue.length === 0) { + queue = shuffle(QUIZ_QUESTIONS) + fromDb = false + } + + poolByRoom.set(room, { queue, fromDb }) +} + +/** Pioche la prochaine question. Fallback aléatoire si la file est vide. */ +export function takeQuestion(room: ServerRoom): { + question: QuizQuestion + fromDb: boolean +} { + const pool = poolByRoom.get(room) + if (pool && pool.queue.length > 0) { + return { question: pool.queue.shift()!, fromDb: pool.fromDb } + } + const question = + QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)] + return { question, fromDb: false } +} diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index 55116e8..e8f121e 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -8,9 +8,12 @@ import type { QuizRevealTruth, ScoreDelta, } from "@nerdware/shared" +import { hasDb } from "../../../db" +import { markQuestionPlayed } from "../../../db/quiz-repo" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { ServerRoom } from "../../../rooms" -import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" +import type { QuizQuestion } from "./questions" +import { takeQuestion } from "./pool" const BASE_POINTS = 100 const SPEED_BONUS_MAX = 100 @@ -22,26 +25,6 @@ interface QuizRoundData { votedAt: Map } -// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room. -const playedByRoom = new WeakMap>() - -function pickQuestion(room: ServerRoom): QuizQuestion { - let played = playedByRoom.get(room) - if (!played) { - played = new Set() - playedByRoom.set(room, played) - } - let pool = QUIZ_QUESTIONS.filter((q) => !played.has(q.id)) - if (pool.length === 0) { - // Banque épuisée sur cette partie : on autorise à nouveau les répétitions. - played.clear() - pool = QUIZ_QUESTIONS - } - const question = pool[Math.floor(Math.random() * pool.length)] - played.add(question.id) - return question -} - function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } { return ( typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number" @@ -54,7 +37,11 @@ export class QuizRound implements GameRound { constructor(private readonly now: () => number = Date.now) {} start(room: ServerRoom): RoundStart { - const question = pickQuestion(room) + const { question, fromDb } = takeQuestion(room) + // Anti-répétition cross-session : on enregistre la question jouée (DB only). + if (fromDb && hasDb) { + void markQuestionPlayed(room.code, question.id) + } const payload: QuizQuestionPayload = { format: question.format, prompt: question.prompt, diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index aebdf99..c52c227 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -12,6 +12,7 @@ import type { IoServer } from "./socket" import { RoomError, RoomManager, type ServerRoom } from "./rooms" import { GameEngine } from "./game/engine" import { hasRound } from "./game/registry" +import { prepareRoom } from "./game/modes" type AppSocket = Socket< ClientToServerEvents, @@ -121,10 +122,15 @@ export function registerRoomHandlers( return } ack({ ok: true, data: null }) - const engine = new GameEngine(io, rooms, room) - room.game = engine + // Précharge le contenu (pool quiz depuis la DB), puis lance la boucle. // Fire-and-forget : la boucle de jeu broadcast son propre état. - void engine.run().catch((err) => console.error("[game] run failed", err)) + void prepareRoom(room) + .then(() => { + const engine = new GameEngine(io, rooms, room) + room.game = engine + return engine.run() + }) + .catch((err) => console.error("[game] run failed", err)) }) socket.on("round:vote", (payload) => { diff --git a/bun.lock b/bun.lock index 5810c19..85c5245 100644 --- a/bun.lock +++ b/bun.lock @@ -17,13 +17,16 @@ "dependencies": { "@fastify/cors": "^11", "@nerdware/shared": "workspace:*", + "drizzle-orm": "^0.45", "fastify": "^5", + "postgres": "^3.4", "socket.io": "^4.8.1", }, "devDependencies": { "@eslint/js": "^10", "@types/bun": "latest", "@types/node": "^24", + "drizzle-kit": "^0.31", "eslint": "^10", "globals": "^17", "pino-pretty": "^13", @@ -171,6 +174,8 @@ "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.71.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -179,6 +184,10 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -657,6 +666,8 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -747,6 +758,10 @@ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], @@ -907,6 +922,8 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], @@ -1169,6 +1186,8 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1223,6 +1242,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], @@ -1293,6 +1314,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -1347,6 +1370,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -1445,6 +1470,8 @@ "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@fastify/ajv-compiler/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -1515,6 +1542,8 @@ "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -1539,6 +1568,50 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1563,6 +1636,58 @@ "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..08647a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +# Postgres de développement local pour NerdWare. +# docker compose up -d +# Puis depuis apps/server : bun run db:migrate && bun run db:seed +services: + postgres: + image: postgres:17-alpine + container_name: nerdware-postgres + restart: unless-stopped + environment: + POSTGRES_USER: nerdware + POSTGRES_PASSWORD: nerdware + POSTGRES_DB: nerdware + ports: + - "5432:5432" + volumes: + - nerdware-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nerdware -d nerdware"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + nerdware-pgdata: -- 2.45.3