From 3e406d7328a804a35547de838ef1bacd5d07b061 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 02:22:54 +0900 Subject: [PATCH 01/17] feat: add Dockerfile naive and optimized versions --- .dockerignore | 8 ++++++++ packages/api/Dockerfile | 31 +++++++++++++++++++++++++++++++ packages/api/Dockerfile.naive | 18 ++++++++++++++++++ packages/api/Dockerfile.optimized | 30 ++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 .dockerignore create mode 100644 packages/api/Dockerfile create mode 100644 packages/api/Dockerfile.naive create mode 100644 packages/api/Dockerfile.optimized diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0adb1a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore +.git +.env +dist +core diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile new file mode 100644 index 0000000..f75b3b6 --- /dev/null +++ b/packages/api/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +RUN chown node:node /app +USER node + +COPY --chown=node:node package*.json ./ +COPY --chown=node:node packages/api/package.json ./packages/api/ +COPY --chown=node:node packages/shared/package.json ./packages/shared/ +RUN npm ci + +COPY --chown=node:node . . + +RUN npm run build -w packages/api + +# --- +FROM node:20-alpine AS runner + +WORKDIR /app + +COPY --from=builder /app/packages/api/dist ./packages/api/dist +COPY --from=builder /app/packages/shared ./packages/shared +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +USER node + +EXPOSE 3000 + +ENTRYPOINT ["node"] +CMD ["packages/api/dist/index.js"] diff --git a/packages/api/Dockerfile.naive b/packages/api/Dockerfile.naive new file mode 100644 index 0000000..8416abe --- /dev/null +++ b/packages/api/Dockerfile.naive @@ -0,0 +1,18 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +COPY packages/api/package.json ./packages/api/ +COPY packages/shared/package.json ./packages/shared/ + +RUN npm ci + +COPY . . + +RUN npm run build -w packages/api + +EXPOSE 3000 + +ENTRYPOINT ["node"] +CMD ["packages/api/dist/index.js"] diff --git a/packages/api/Dockerfile.optimized b/packages/api/Dockerfile.optimized new file mode 100644 index 0000000..4d4d4f8 --- /dev/null +++ b/packages/api/Dockerfile.optimized @@ -0,0 +1,30 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +RUN chown node:node /app +USER node + +COPY --chown=node:node package*.json ./ +COPY --chown=node:node packages/api/package.json ./packages/api/ +COPY --chown=node:node packages/shared/package.json ./packages/shared/ +RUN npm ci + +COPY --chown=node:node . . + +RUN npm run build -w packages/api + +FROM node:20-alpine AS runner + +WORKDIR /app + +COPY --from=builder /app/packages/api/dist ./packages/api/dist +COPY --from=builder /app/packages/shared ./packages/shared +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +USER node + +EXPOSE 3000 + +ENTRYPOINT ["node"] +CMD ["packages/api/dist/index.js"] From e99c82ff3133dfa9080d730ec19cdf024a1c0fd9 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 02:22:57 +0900 Subject: [PATCH 02/17] docs: add docker build comparison notes --- docs/docker.md | 244 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/docker.md diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..1c5e8c2 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,244 @@ +# Dockerfile 비교 및 학습 기록 + +## 개요 + +`packages/api`의 Dockerfile을 두 가지 버전으로 작성하고, 직접 디버깅하며 Docker 동작 원리를 파악한다. + +| 항목 | Dockerfile.naive | Dockerfile.optimized | +|------|-----------------|----------------------| +| 빌드 방식 | 단일 스테이지 | multi-stage (builder + runner) | +| 보안 | root 실행 | non-root (USER node) | +| 이미지 크기 | 202 MB | 167 MB | +| devDependencies 포함 | ✅ (런타임에도 포함) | ❌ (빌드 후 제외) | + +--- + +## Dockerfile.naive + +```dockerfile +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +COPY packages/api/package.json ./packages/api/ +COPY packages/shared/package.json ./packages/shared/ + +RUN npm ci + +COPY . . + +RUN npm run build -w packages/api + +EXPOSE 3000 + +ENTRYPOINT ["node"] +CMD ["packages/api/dist/index.js"] +``` + +**의도적으로 생략한 것들:** +- `USER node` — root로 실행 (보안 취약) +- multi-stage — devDependencies가 런타임 이미지에 포함됨 + +--- + +## Dockerfile.optimized + +```dockerfile +FROM node:20-alpine AS builder + +WORKDIR /app +RUN chown node:node /app +USER node + +COPY --chown=node:node package*.json ./ +COPY --chown=node:node packages/api/package.json ./packages/api/ +COPY --chown=node:node packages/shared/package.json ./packages/shared/ +RUN npm ci + +COPY --chown=node:node . . + +RUN npm run build -w packages/api + +FROM node:20-alpine AS runner + +WORKDIR /app + +COPY --from=builder /app/packages/api/dist ./packages/api/dist +COPY --from=builder /app/packages/shared ./packages/shared +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +USER node + +EXPOSE 3000 + +ENTRYPOINT ["node"] +CMD ["packages/api/dist/index.js"] +``` + +--- + +## 디버깅 과정 + +### 1. npm ci 실패 — package-lock.json 없음 + +``` +npm error The `npm ci` command can only install with an existing package-lock.json +``` + +**원인:** `COPY package*.json ./`은 현재 디렉토리(루트)의 `package.json`, `package-lock.json`만 복사한다. +모노레포 워크스페이스 구조에서 각 패키지의 `package.json`은 별도로 명시해야 한다. + +**해결:** +```dockerfile +COPY package*.json ./ +COPY packages/api/package.json ./packages/api/ +COPY packages/shared/package.json ./packages/shared/ +``` + +**배운 점:** `npm ci`는 `package-lock.json` 기준으로 정확한 버전을 설치한다. +`npm install`과 달리 lock 파일을 수정하지 않아 CI/Docker 환경에 적합하다. + +--- + +### 2. EACCES: permission denied — USER 선언 순서 문제 + +``` +npm error EACCES: permission denied, mkdir '/app/node_modules' +``` + +**원인:** `USER node` 선언 이후 `WORKDIR`이 생성되거나 `COPY`가 실행되면 +파일 소유자가 root가 되어 node 유저가 쓰기 권한을 갖지 못한다. + +```dockerfile +# 잘못된 순서 +USER node # node 유저로 전환 +WORKDIR /app # root 소유로 /app 생성 +COPY . . # root 소유로 복사 +RUN npm ci # node 유저가 node_modules 생성 시도 → EACCES +``` + +**해결 (optimized):** `--chown` 옵션으로 복사 시 소유자를 명시한다. +```dockerfile +WORKDIR /app +RUN chown node:node /app +USER node +COPY --chown=node:node . . +RUN npm ci # node 유저가 자신 소유 파일에 쓰기 → 성공 +``` + +**해결 (naive):** `USER node`를 아예 제거한다. (보안 고려 안 하는 버전) + +**배운 점:** Docker 레이어는 순서가 중요하다. `USER`, `WORKDIR`, `COPY`의 실행 순서에 따라 +파일 소유권이 달라지며, 이후 명령의 실행 권한에 영향을 준다. + +--- + +### 3. tsc: not found — 모노레포 워크스페이스 구조 미인식 + +``` +sh: tsc: not found +``` + +**원인:** `COPY package*.json ./`가 루트 `package.json`만 복사하면 +`npm ci`가 워크스페이스 구조를 인식하지 못해 각 패키지의 `devDependencies`를 설치하지 않는다. +`typescript`는 `packages/api/package.json`의 `devDependencies`에 있으므로 설치되지 않는다. + +**해결:** 워크스페이스 패키지의 `package.json`을 먼저 복사해서 npm이 전체 워크스페이스 구조를 인식하게 한다. + +**배운 점:** npm workspaces는 루트에서 의존성을 통합 관리하지만, +각 패키지의 `package.json`이 있어야 워크스페이스 구조를 인식한다. + +--- + +### 4. dist 경로 오류 — 모노레포 빌드 결과물 위치 + +``` +Error: Cannot find module '/app/dist/index.js' +``` + +**원인:** `npm run build -w packages/api`는 `packages/api/` 안에서 빌드하므로 +결과물은 `/app/packages/api/dist/`에 생성된다. `/app/dist/`가 아니다. + +**해결:** +```dockerfile +CMD ["packages/api/dist/index.js"] # 실제 경로 +``` + +**배운 점:** 모노레포에서 빌드 컨텍스트와 워크스페이스 명령의 실행 위치를 명확히 구분해야 한다. + +--- + +### 5. multi-stage에서 shared 패키지 누락 + +**원인:** `node_modules/@devopsim/shared`는 `packages/shared/`를 심볼릭 링크로 가리킨다. +runner 스테이지에서 `node_modules`만 복사하면 링크 타겟이 없어 런타임에 모듈을 찾지 못한다. + +**해결:** +```dockerfile +COPY --from=builder /app/packages/shared ./packages/shared # 링크 타겟 포함 +COPY --from=builder /app/node_modules ./node_modules # 심볼릭 링크 +``` + +**배운 점:** npm workspaces의 로컬 패키지는 심볼릭 링크로 연결된다. +multi-stage 빌드에서 링크 타겟도 함께 복사해야 한다. + +--- + +## 빌드 결과 비교 + +환경: Apple M-series, Docker Desktop, 로컬 네트워크 (base image 캐시됨) + +### 최초 빌드 (--no-cache) + +| | naive | optimized | +|---|---|---| +| 빌드 시간 | ~4.8s | ~4.2s | +| 이미지 크기 | 202 MB | 167 MB | + +> base image(`node:alpine`)가 이미 로컬에 캐시된 상태라 절대 시간보다 크기 차이가 더 의미 있는 지표다. + +### 소스 코드 변경 후 재빌드 (캐시 활용) + +`packages/api/src/routes/health.ts` 수정 후 재빌드: + +| | naive | optimized | +|---|---|---| +| 재빌드 시간 | ~1.6s | ~1.9s | +| npm ci | CACHED ✅ | CACHED ✅ | +| 재실행 레이어 | COPY + tsc | COPY + tsc + runner COPY | + +**분석:** +- 두 버전 모두 `package*.json`을 소스보다 먼저 복사하는 레이어 캐시 전략을 사용하므로 + 소스 변경 시 `npm ci`는 캐시 히트된다. +- optimized는 runner 스테이지 COPY가 추가되어 재빌드가 약간 더 걸린다. +- 의존성 변경(`package.json` 수정) 시에는 두 버전 모두 `npm ci`부터 다시 실행된다. + +### 크기 차이 원인 + +| 구성 요소 | naive | optimized | +|-----------|-------|-----------| +| 소스코드 (src/) | ✅ 포함 | ❌ 제외 | +| devDependencies (typescript 등) | ✅ 포함 | ❌ 제외 | +| 빌드 결과물 (dist/) | ✅ 포함 | ✅ 포함 | +| production dependencies | ✅ 포함 | ✅ 포함 | + +35MB 차이의 대부분은 `typescript`, `ts-node`, `@types/*` 등 devDependencies다. + +--- + +## 빌드 명령어 + +```bash +# 루트에서 실행 (모노레포 컨텍스트 필요) + +# naive +docker build -f packages/api/Dockerfile.naive -t devopsim-api-naive . + +# optimized +docker build -f packages/api/Dockerfile.optimized -t devopsim-api-optimized . + +# 실행 +docker run -e DATABASE_URL=postgresql://dummy -p 3000:3000 devopsim-api-naive +``` From a202e8071f4f5d98caa2ef00e3f823b0824eabfd Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 17:56:38 +0900 Subject: [PATCH 03/17] feat: add shared package skeleton (logger, types) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pino 로거와 공유 타입(HealthResponse)을 shared 패키지로 분리. tsconfig.json 추가로 독립 빌드 가능하도록 구성. Co-Authored-By: Claude Sonnet 4.6 --- packages/shared/src/index.ts | 2 ++ packages/shared/src/logger.ts | 3 +++ packages/shared/src/types.ts | 3 +++ packages/shared/tsconfig.json | 8 ++++++++ 4 files changed, 16 insertions(+) create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/logger.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..5237b70 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export { logger } from './logger'; +export type { HealthResponse } from './types'; diff --git a/packages/shared/src/logger.ts b/packages/shared/src/logger.ts new file mode 100644 index 0000000..421d5d4 --- /dev/null +++ b/packages/shared/src/logger.ts @@ -0,0 +1,3 @@ +import pino from 'pino'; + +export const logger = pino({ level: 'info' }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..b033333 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,3 @@ +export interface HealthResponse { + status: 'ok' | 'error'; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..75eba9f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} From 9a8d403337bd29c0f191ff0bc4232515ac232b6c Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 17:56:42 +0900 Subject: [PATCH 04/17] feat: integrate shared logger and HealthResponse into api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fastify 기본 logger 대신 shared pino 로거를 주입하고, health 엔드포인트 응답에 HealthResponse 타입을 적용. Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/index.ts | 3 ++- packages/api/src/routes/health.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3fc602f..26e8fed 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,8 +1,9 @@ import Fastify from 'fastify' +import { logger } from '@devopsim/shared' import dbPlugin from './plugins/db' import healthRoute from './routes/health' -const app = Fastify({ logger: true }) +const app = Fastify({ loggerInstance: logger }) app.register(dbPlugin) app.register(healthRoute) diff --git a/packages/api/src/routes/health.ts b/packages/api/src/routes/health.ts index a9ba59b..a04d3f9 100644 --- a/packages/api/src/routes/health.ts +++ b/packages/api/src/routes/health.ts @@ -1,7 +1,8 @@ import { FastifyInstance } from 'fastify' +import type { HealthResponse } from '@devopsim/shared' export default async function healthRoute(app: FastifyInstance) { - app.get('/health', async () => { + app.get<{ Reply: HealthResponse }>('/health', async () => { return { status: 'ok' } }) } From 3a94e9726ed7c21f70afe59461ba95ed5f3557cf Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 17:56:47 +0900 Subject: [PATCH 05/17] feat: optimize Dockerfile with 3-stage build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deps 스테이지를 추가해 production node_modules(--omit=dev)와 빌드용 node_modules를 분리. shared도 dist만 복사하도록 개선. node_modules 48.6MB → 20.1MB, 이미지 212MB → 147MB. Co-Authored-By: Claude Sonnet 4.6 --- packages/api/Dockerfile | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index f75b3b6..c535015 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -1,3 +1,20 @@ +# Stage 1: production 의존성만 설치 +FROM node:20-alpine AS deps + +WORKDIR /app +RUN chown node:node /app +USER node + +COPY --chown=node:node package*.json ./ +COPY --chown=node:node packages/api/package.json ./packages/api/ +COPY --chown=node:node packages/shared/package.json ./packages/shared/ + +# devDependencies 없이 런타임에 필요한 패키지만 포함 +RUN npm ci --omit=dev + +# --- +# Stage 2: 전체 의존성 설치 + 빌드 +# typescript 등 devDependencies가 필요하므로 별도 스테이지에서 빌드 FROM node:20-alpine AS builder WORKDIR /app @@ -11,17 +28,27 @@ RUN npm ci COPY --chown=node:node . . +RUN npm run build -w packages/shared RUN npm run build -w packages/api # --- +# Stage 3: 런타임 이미지 +# node_modules(deps) + dist(builder) FROM node:20-alpine AS runner WORKDIR /app -COPY --from=builder /app/packages/api/dist ./packages/api/dist -COPY --from=builder /app/packages/shared ./packages/shared -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./ +# node_modules(심링크 포함) +COPY --from=deps --chown=node:node /app/node_modules ./node_modules + +# package.json -> 심링크 해석 +COPY --from=builder --chown=node:node /app/package.json ./ +COPY --from=builder --chown=node:node /app/packages/api/package.json ./packages/api/ +COPY --from=builder --chown=node:node /app/packages/shared/package.json ./packages/shared/ + +# dist만 +COPY --from=builder --chown=node:node /app/packages/api/dist ./packages/api/dist +COPY --from=builder --chown=node:node /app/packages/shared/dist ./packages/shared/dist USER node From b637764ba9086cbf84cf7e3915115f8646695247 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 17:56:52 +0900 Subject: [PATCH 06/17] docs: add 3-stage build analysis and shared package notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-stage 구조 설명, 실측 이미지 크기 비교(naive/optimized/prod), npm workspaces 심링크 동작 원리, packages/shared 사전 조건 추가. Co-Authored-By: Claude Sonnet 4.6 --- docs/docker.md | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/docs/docker.md b/docs/docker.md index 1c5e8c2..0d8afd4 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -242,3 +242,105 @@ docker build -f packages/api/Dockerfile.optimized -t devopsim-api-optimized . # 실행 docker run -e DATABASE_URL=postgresql://dummy -p 3000:3000 devopsim-api-naive ``` + +--- + +## Dockerfile (프로덕션) — 3-stage 구조 + +`Dockerfile.optimized`에서 두 가지 문제가 남아 있었다. + +1. **node_modules 전체 복사**: builder의 `node_modules`에는 `typescript`, `ts-node` 등 devDependencies가 섞여 있어 runner에서 그대로 복사하면 불필요한 패키지가 포함된다. +2. **packages/shared 전체 복사**: `packages/shared`의 소스 파일, tsconfig 등 런타임에 불필요한 파일이 최종 이미지에 들어간다. + +### 해결 구조: 3-stage 빌드 + +``` +deps → npm ci --omit=dev production 의존성만 설치 (runner에 복사할 node_modules) +builder → npm ci + tsc 빌드 전체 의존성 + shared/api 각각 빌드 (dist 생성) +runner → 조립 deps의 node_modules + builder의 dist만 복사 +``` + +**왜 deps와 builder를 분리하는가:** + +빌드에는 `typescript`가 필요하므로 `npm ci`(전체 설치)를 해야 한다. 그러면 builder의 `node_modules`에 devDependencies가 섞인다. 분리할 방법이 없기 때문에, 처음부터 `--omit=dev`로만 설치한 별도 스테이지(deps)를 만들어 runner에서 가져다 쓴다. + +### npm workspaces 심링크와 shared dist + +``` +node_modules/@devopsim/shared → (심링크) → packages/shared/ + package.json ← "main": "dist/index.js" + dist/index.js ← 실제 파일 +``` + +`node_modules/@devopsim/shared`는 파일이 없고 `packages/shared/`를 가리키는 심링크다. Node.js가 `require('@devopsim/shared')`를 만나면 심링크를 따라가 `packages/shared/package.json`의 `"main"` 필드를 읽고 `dist/index.js`를 로드한다. + +`deps` 스테이지에서는 `package.json`만 복사하고 소스가 없으므로 `packages/shared/dist/`가 존재하지 않는다. 따라서 builder에서 `npm run build -w packages/shared`로 dist를 생성한 뒤 runner에 복사해야 한다. + +runner에 필요한 파일: +- `node_modules/` (deps — production 패키지 + 심링크) +- `packages/shared/dist/` (builder — 심링크가 가리키는 실제 파일) +- `packages/shared/package.json` (builder — `"main"` 필드 해석용) +- `packages/api/dist/` (builder — 앱 실행 파일) + +--- + +## 3-stage 추가 후 실측 비교 + +환경: Apple M-series, Docker Desktop, base image 캐시됨 (2025-04) + +### 이미지 크기 + +| | naive | optimized | prod (3-stage) | +|---|---|---|---| +| 이미지 크기 | 212 MB | 175 MB | **147 MB** | +| naive 대비 | — | -37 MB (-17%) | **-65 MB (-31%)** | + +### node_modules 크기 + +| | naive | optimized | prod | +|---|---|---|---| +| node_modules | 48.6 MB | 48.6 MB | **20.1 MB** | + +`optimized`는 multi-stage로 소스를 분리했지만 `node_modules`는 builder 것을 그대로 복사해서 devDependencies가 그대로 포함된다. `prod`는 처음부터 `--omit=dev`로 설치한 것을 가져와 node_modules가 절반 이하다. + +### 빌드 시간 + +| | naive | optimized | prod (3-stage) | +|---|---|---|---| +| 빌드 시간 (캐시 활용) | 3.8s | 3.0s | 4.4s | + +prod는 스테이지가 하나 더 많아(deps) 빌드 시간이 소폭 증가한다. 그러나 `deps`와 `builder` 스테이지는 병렬 실행 가능하도록 Docker BuildKit이 최적화하므로 실제 차이는 작다. + +### 포함 내용 비교 + +| | naive | optimized | prod | +|---|---|---|---| +| 실행 유저 | root (보안 취약) | node | node | +| src/ 소스 파일 | ✅ 포함 | ❌ 제외 | ❌ 제외 | +| devDependencies (typescript 등) | ✅ 포함 | ✅ 포함 | ❌ 제외 | +| packages/shared 소스 | ✅ 포함 | ✅ 포함 | ❌ 제외 (dist만) | +| production dependencies | ✅ 포함 | ✅ 포함 | ✅ 포함 | +| dist/ 빌드 결과물 | ✅ 포함 | ✅ 포함 | ✅ 포함 | + +### 동작 확인 + +```bash +$ docker run -d -p 3001:3000 devopsim-prod +$ curl http://localhost:3001/health +{"status":"ok"} +``` + +--- + +## packages/shared 사전 조건 + +`Dockerfile`(3-stage)에서 `npm run build -w packages/shared`를 실행하므로 `packages/shared`에 빌드 구조가 필요하다. + +``` +packages/shared/ + tsconfig.json ← packages/api/tsconfig.json과 동일한 구조로 추가 + src/ + index.ts ← shared 유틸리티 진입점 +``` + +`tsconfig.json` 없이 빌드하면 루트 `tsconfig.json`이 `**/*`로 전체를 탐색해 `packages/api/src`까지 포함하려 해 `rootDir` 충돌 에러가 발생한다. From 60adc2baa3c0d2dcfd37e6dff4a1f5003dc3d4b6 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 18:24:41 +0900 Subject: [PATCH 07/17] chore: upgrade base image to node:24-alpine Co-Authored-By: Claude Sonnet 4.6 --- packages/api/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index c535015..3b8f19c 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: production 의존성만 설치 -FROM node:20-alpine AS deps +FROM node:24-alpine AS deps WORKDIR /app RUN chown node:node /app @@ -15,7 +15,7 @@ RUN npm ci --omit=dev # --- # Stage 2: 전체 의존성 설치 + 빌드 # typescript 등 devDependencies가 필요하므로 별도 스테이지에서 빌드 -FROM node:20-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /app RUN chown node:node /app @@ -34,7 +34,7 @@ RUN npm run build -w packages/api # --- # Stage 3: 런타임 이미지 # node_modules(deps) + dist(builder) -FROM node:20-alpine AS runner +FROM node:24-alpine AS runner WORKDIR /app From a37a63834d41578a5f0f586da714e69408c2a587 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 18:29:47 +0900 Subject: [PATCH 08/17] chore: pin node version to 24 Co-Authored-By: Claude Sonnet 4.6 --- .nvmrc | 1 + package.json | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/package.json b/package.json index 1a1cac0..8aa4cb2 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,8 @@ "private": true, "workspaces": [ "packages/*" - ] + ], + "engines": { + "node": ">=24" + } } From d1495cd90b527041bb38a67bc02fa55c55149cc3 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 4 Apr 2026 19:09:09 +0900 Subject: [PATCH 09/17] feat: add docker-compose with api and postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/db 로컬 개발환경 구성. service_healthy로 DB 준비 후 api 시작, healthcheck, env_file 분리. .env.example로 필요 변수 문서화. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + infra/docker/.env.example | 4 ++++ infra/docker/docker-compose.yaml | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 infra/docker/.env.example create mode 100644 infra/docker/docker-compose.yaml diff --git a/.gitignore b/.gitignore index 102e001..3232860 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ dist/ .env .env.* +!.env.example *.log .plan/ diff --git a/infra/docker/.env.example b/infra/docker/.env.example new file mode 100644 index 0000000..19b8ace --- /dev/null +++ b/infra/docker/.env.example @@ -0,0 +1,4 @@ +POSTGRES_DB=devopsim +POSTGRES_USER=devopsim +POSTGRES_PASSWORD=devopsim +DATABASE_URL=postgresql://devopsim:devopsim@db:5432/devopsim diff --git a/infra/docker/docker-compose.yaml b/infra/docker/docker-compose.yaml new file mode 100644 index 0000000..91a2824 --- /dev/null +++ b/infra/docker/docker-compose.yaml @@ -0,0 +1,38 @@ +services: + api: + build: + context: ../../ + dockerfile: packages/api/Dockerfile + ports: + - "3000:3000" + env_file: .env + restart: on-failure:3 + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 30s + start_interval: 5s + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16-alpine + env_file: .env + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: on-failure:3 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U devopsim"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 30s + start_interval: 5s + +volumes: + postgres_data: From e7b25e6c1e061d2c1db2b0f793c863abb034c238 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 01:05:36 +0900 Subject: [PATCH 10/17] feat: add domain layer and repository pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit domain/item.ts에 순수 계약(타입+인터페이스) 분리. AppError 중앙화, pgItemRepository는 domain 인터페이스 구현체로 분리. service는 domain 인터페이스만 의존해 DB 구현체와 결합 없음. Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/domain/item.ts | 26 ++++++++++++ packages/api/src/errors.ts | 9 ++++ packages/api/src/repositories/items.ts | 59 ++++++++++++++++++++++++++ packages/api/src/services/items.ts | 34 +++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 packages/api/src/domain/item.ts create mode 100644 packages/api/src/errors.ts create mode 100644 packages/api/src/repositories/items.ts create mode 100644 packages/api/src/services/items.ts diff --git a/packages/api/src/domain/item.ts b/packages/api/src/domain/item.ts new file mode 100644 index 0000000..04d200e --- /dev/null +++ b/packages/api/src/domain/item.ts @@ -0,0 +1,26 @@ +export interface Item { + id: number + name: string + description: string | null + view_count: number + created_at: Date + updated_at: Date +} + +export interface CreateItemDto { + name: string + description?: string +} + +export interface UpdateItemDto { + name?: string + description?: string +} + +export interface ItemRepository { + findAll(): Promise + findById(id: number): Promise + create(dto: CreateItemDto): Promise + update(id: number, dto: UpdateItemDto): Promise + remove(id: number): Promise +} diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts new file mode 100644 index 0000000..1577792 --- /dev/null +++ b/packages/api/src/errors.ts @@ -0,0 +1,9 @@ +export class AppError extends Error { + constructor( + public readonly statusCode: number, + message: string + ) { + super(message) + this.name = 'AppError' + } +} diff --git a/packages/api/src/repositories/items.ts b/packages/api/src/repositories/items.ts new file mode 100644 index 0000000..8e4b2bc --- /dev/null +++ b/packages/api/src/repositories/items.ts @@ -0,0 +1,59 @@ +import type { Pool } from 'pg' +import type { Item, ItemRepository } from '../domain/item' + +export function pgItemRepository(db: Pool): ItemRepository { + return { + async findAll() { + const { rows } = await db.query( + 'SELECT * FROM items ORDER BY created_at DESC' + ) + return rows + }, + + async findById(id) { + const { rows } = await db.query( + 'SELECT * FROM items WHERE id = $1', + [id] + ) + return rows[0] ?? null + }, + + async create(dto) { + const { rows } = await db.query( + 'INSERT INTO items (name, description) VALUES ($1, $2) RETURNING *', + [dto.name, dto.description ?? null] + ) + return rows[0] + }, + + async update(id, dto) { + const fields: string[] = [] + const values: unknown[] = [] + let idx = 1 + + if (dto.name !== undefined) { + fields.push(`name = $${idx++}`) + values.push(dto.name) + } + if (dto.description !== undefined) { + fields.push(`description = $${idx++}`) + values.push(dto.description) + } + fields.push(`updated_at = NOW()`) + values.push(id) + const { rows } = await db.query( + `UPDATE items SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ) + return rows[0] ?? null + }, + + async remove(id) { + const { rowCount } = await db.query( + 'DELETE FROM items WHERE id = $1', + [id] + ) + return (rowCount ?? 0) > 0 + }, + } +} diff --git a/packages/api/src/services/items.ts b/packages/api/src/services/items.ts new file mode 100644 index 0000000..69dc2ab --- /dev/null +++ b/packages/api/src/services/items.ts @@ -0,0 +1,34 @@ +import type { ItemRepository, CreateItemDto, UpdateItemDto } from '../domain/item' +import { AppError } from '../errors' + +export function itemService(repo: ItemRepository) { + return { + getAll() { + return repo.findAll() + }, + + async getOne(id: number) { + const item = await repo.findById(id) + if (!item) throw new AppError(404, 'Item not found') + return item + }, + + create(dto: CreateItemDto) { + return repo.create(dto) + }, + + async update(id: number, dto: UpdateItemDto) { + if (dto.name === undefined && dto.description === undefined) { + throw new AppError(400, 'No fields to update') + } + const item = await repo.update(id, dto) + if (!item) throw new AppError(404, 'Item not found') + return item + }, + + async remove(id: number) { + const deleted = await repo.remove(id) + if (!deleted) throw new AppError(404, 'Item not found') + }, + } +} From 2acc343a6f9e2d4eca45bca2e07e801e4fd40eeb Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 01:05:42 +0900 Subject: [PATCH 11/17] feat: add items CRUD routes with schema validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST/GET/PUT/DELETE /items 구현. Fastify JSON schema로 name(minLength:1, maxLength:255) validation, PUT 빈 body 거절(minProperties:1). schema는 schemas/ 디렉토리로 분리. Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/routes/items.ts | 42 ++++++++++++++++++++++++ packages/api/src/routes/schemas/items.ts | 23 +++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 packages/api/src/routes/items.ts create mode 100644 packages/api/src/routes/schemas/items.ts diff --git a/packages/api/src/routes/items.ts b/packages/api/src/routes/items.ts new file mode 100644 index 0000000..416a44e --- /dev/null +++ b/packages/api/src/routes/items.ts @@ -0,0 +1,42 @@ +import { FastifyInstance } from 'fastify' +import type { itemService } from '../services/items' +import { createItemSchema, updateItemSchema } from './schemas/items' + +type ItemService = ReturnType + +export default async function itemsRoute( + app: FastifyInstance, + opts: { service: ItemService } +) { + const { service } = opts + + app.get('/items', async () => { + return service.getAll() + }) + + app.get<{ Params: { id: string } }>('/items/:id', async (req) => { + return service.getOne(Number(req.params.id)) + }) + + app.post<{ Body: { name: string; description?: string } }>( + '/items', + { schema: createItemSchema }, + async (req, reply) => { + const item = await service.create(req.body) + reply.code(201).send(item) + } + ) + + app.put<{ Params: { id: string }; Body: { name?: string; description?: string } }>( + '/items/:id', + { schema: updateItemSchema }, + async (req) => { + return service.update(Number(req.params.id), req.body) + } + ) + + app.delete<{ Params: { id: string } }>('/items/:id', async (req, reply) => { + await service.remove(Number(req.params.id)) + reply.code(204).send() + }) +} diff --git a/packages/api/src/routes/schemas/items.ts b/packages/api/src/routes/schemas/items.ts new file mode 100644 index 0000000..854470d --- /dev/null +++ b/packages/api/src/routes/schemas/items.ts @@ -0,0 +1,23 @@ +export const createItemSchema = { + body: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 255 }, + description: { type: 'string' }, + }, + additionalProperties: false, + }, +} as const + +export const updateItemSchema = { + body: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 255 }, + description: { type: 'string' }, + }, + additionalProperties: false, + minProperties: 1, + }, +} as const From 346db95632f6b3608969a2157676c114e0e0af64 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 01:05:45 +0900 Subject: [PATCH 12/17] feat: add items table migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit id, name, description, view_count, created_at, updated_at 컬럼. Co-Authored-By: Claude Sonnet 4.6 --- packages/api/migrations/001_create_items.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/api/migrations/001_create_items.sql diff --git a/packages/api/migrations/001_create_items.sql b/packages/api/migrations/001_create_items.sql new file mode 100644 index 0000000..3b4825e --- /dev/null +++ b/packages/api/migrations/001_create_items.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + view_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); From a6809a7a6e4565b9d816ab4740b34ff44dd30d88 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 01:05:52 +0900 Subject: [PATCH 13/17] refactor: extract buildApp factory and wire dependencies in index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.ts에 buildApp 팩토리 분리(테스트 재사용 목적). index.ts는 listen만 담당. app.after()에서 pg Pool 꺼내 repo/service 조립. shared pino logger를 loggerInstance로 주입(타입 단언으로 호환). Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/app.ts | 39 +++++++++++++++++++++++++++++++++++++++ packages/api/src/index.ts | 10 ++-------- 2 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 packages/api/src/app.ts diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts new file mode 100644 index 0000000..cef0a3f --- /dev/null +++ b/packages/api/src/app.ts @@ -0,0 +1,39 @@ +import Fastify, { FastifyBaseLogger } from 'fastify' +import { logger } from '@devopsim/shared' +import { AppError } from './errors' +import dbPlugin from './plugins/db' +import healthRoute from './routes/health' +import itemsRoute from './routes/items' +import { pgItemRepository } from './repositories/items' +import { itemService } from './services/items' + +export function buildApp(opts: { logger?: boolean } = {}) { + // 테스트 시 logger: false, 그 외엔 shared pino logger 주입 + // pino.Logger와 FastifyBaseLogger는 런타임 호환이지만 타입이 다소 달라 단언 필요 + const app = opts.logger === false + ? Fastify({ logger: false }) + : Fastify({ loggerInstance: logger as unknown as FastifyBaseLogger }) + + app.setErrorHandler((error: Error & { statusCode?: number; validation?: unknown }, _req, reply) => { + if (error instanceof AppError) { + reply.code(error.statusCode).send({ message: error.message }) + } else if (error.validation) { + reply.code(400).send({ message: error.message }) + } else { + app.log.error(error) + reply.code(500).send({ message: 'Internal Server Error' }) + } + }) + + app.register(dbPlugin) + + app.after(() => { + const repo = pgItemRepository(app.pg.pool) + const service = itemService(repo) + app.register(itemsRoute, { service }) + }) + + app.register(healthRoute) + + return app +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 26e8fed..5b02e55 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,12 +1,6 @@ -import Fastify from 'fastify' -import { logger } from '@devopsim/shared' -import dbPlugin from './plugins/db' -import healthRoute from './routes/health' +import { buildApp } from './app' -const app = Fastify({ loggerInstance: logger }) - -app.register(dbPlugin) -app.register(healthRoute) +const app = buildApp() const start = async () => { try { From ff96c8e44615b7e888e2a8a140a4e884877dc3f8 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 01:05:57 +0900 Subject: [PATCH 14/17] test: add integration tests for items API (ECP/BVA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest + app.inject() 기반 통합 테스트 20케이스. POST/GET/PUT/DELETE 각 ECP 동치 클래스 + BVA 경계값(name 0/1/255/256자). beforeEach/afterEach로 테스트 격리, TRUNCATE로 DB 상태 초기화. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 1401 +++++++++++++++++++++++++-- packages/api/package.json | 10 +- packages/api/src/test/helpers.ts | 8 + packages/api/src/test/items.test.ts | 263 +++++ packages/api/vitest.config.ts | 12 + 5 files changed, 1626 insertions(+), 68 deletions(-) create mode 100644 packages/api/src/test/helpers.ts create mode 100644 packages/api/src/test/items.test.ts create mode 100644 packages/api/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index e4fb536..7c20ddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,10 @@ "name": "devopsim", "workspaces": [ "packages/*" - ] + ], + "engines": { + "node": ">=24" + } }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -30,6 +33,43 @@ "resolved": "packages/shared", "link": true }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -191,6 +231,25 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -200,12 +259,309 @@ "node": ">=8.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -234,6 +590,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -244,6 +636,131 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -316,6 +833,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -351,6 +878,23 @@ "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -380,6 +924,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -390,6 +944,33 @@ "node": ">=0.3.1" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -509,6 +1090,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -523,84 +1122,412 @@ "node": ">=20" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { - "dequal": "^2.0.3" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "url": "https://github.com/sponsors/ai" } ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], "license": "MIT" }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -610,6 +1537,13 @@ "node": ">=14.0.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -658,7 +1592,6 @@ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", - "peer": true, "engines": { "node": ">=4.0.0" } @@ -677,15 +1610,13 @@ "version": "1.13.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", - "peer": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -707,6 +1638,26 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -744,12 +1695,40 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -759,7 +1738,6 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -769,7 +1747,6 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -779,7 +1756,6 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", - "peer": true, "dependencies": { "xtend": "^4.0.0" }, @@ -865,6 +1841,40 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, "node_modules/safe-regex2": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", @@ -930,6 +1940,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -939,6 +1956,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -948,6 +1975,20 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -966,6 +2007,50 @@ "real-require": "^0.2.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", @@ -1019,6 +2104,14 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1047,12 +2140,188 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.4" } @@ -1080,8 +2349,10 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", "ts-node": "^10.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.1.2" } }, "packages/shared": { diff --git a/packages/api/package.json b/packages/api/package.json index 1026728..7f3a159 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -6,19 +6,23 @@ "scripts": { "build": "tsc", "dev": "ts-node src/index.ts", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@devopsim/shared": "*", "@fastify/postgres": "^6.0.0", - "fastify-plugin": "^5.0.0", "fastify": "^5.0.0", + "fastify-plugin": "^5.0.0", "pino": "^9.0.0", "prom-client": "^15.0.0" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", "ts-node": "^10.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.1.2" } } diff --git a/packages/api/src/test/helpers.ts b/packages/api/src/test/helpers.ts new file mode 100644 index 0000000..06b4316 --- /dev/null +++ b/packages/api/src/test/helpers.ts @@ -0,0 +1,8 @@ +import { buildApp } from '../app' +import type { FastifyInstance } from 'fastify' + +export async function createTestApp(): Promise { + const app = buildApp({ logger: false }) + await app.ready() + return app +} diff --git a/packages/api/src/test/items.test.ts b/packages/api/src/test/items.test.ts new file mode 100644 index 0000000..3ceb64c --- /dev/null +++ b/packages/api/src/test/items.test.ts @@ -0,0 +1,263 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import type { FastifyInstance } from 'fastify' +import { createTestApp } from './helpers' + +describe('POST /items', () => { + let app: FastifyInstance + + beforeEach(async () => { + app = await createTestApp() + await app.pg.query('TRUNCATE TABLE items RESTART IDENTITY') + }) + + afterEach(async () => { + await app.close() + }) + + // --- ECP: 정상 클래스 --- + + test('정상 요청 → 201 + 생성된 item 반환', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { name: '테스트 아이템', description: '설명' }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.id).toBeDefined() + expect(body.name).toBe('테스트 아이템') + expect(body.description).toBe('설명') + expect(body.view_count).toBe(0) + }) + + test('description 없이 name만 → 201', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { name: '이름만' }, + }) + expect(res.statusCode).toBe(201) + expect(res.json().description).toBeNull() + }) + + // --- ECP: 비정상 클래스 --- + + test('name 누락 → 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { description: '이름없음' }, + }) + expect(res.statusCode).toBe(400) + }) + + test('허용되지 않은 필드 포함 → Fastify가 strip 후 201 (additionalProperties 제거)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { name: '아이템', hack: true }, + }) + // Fastify 기본 동작: 추가 필드를 거절이 아닌 제거(strip)하고 처리 + expect(res.statusCode).toBe(201) + expect(res.json()).not.toHaveProperty('hack') + }) + + // --- BVA: name 경계값 --- + + test('name 0자(빈 문자열) → 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { name: '' }, + }) + expect(res.statusCode).toBe(400) + }) + + test('name 1자(최솟값) → 201', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { name: 'a' }, + }) + expect(res.statusCode).toBe(201) + }) + + test('name 255자(최댓값) → 201', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { name: 'a'.repeat(255) }, + }) + expect(res.statusCode).toBe(201) + }) + + test('name 256자(최댓값+1) → 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/items', + payload: { name: 'a'.repeat(256) }, + }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('GET /items/:id', () => { + let app: FastifyInstance + let createdId: number + + beforeEach(async () => { + app = await createTestApp() + await app.pg.query('TRUNCATE TABLE items RESTART IDENTITY') + const { rows } = await app.pg.query( + `INSERT INTO items (name) VALUES ('조회용 아이템') RETURNING id` + ) + createdId = rows[0].id + }) + + afterEach(async () => { + await app.close() + }) + + // --- ECP --- + + test('존재하는 id → 200 + item 반환', async () => { + const res = await app.inject({ method: 'GET', url: `/items/${createdId}` }) + expect(res.statusCode).toBe(200) + expect(res.json().id).toBe(createdId) + }) + + test('존재하지 않는 id → 404', async () => { + const res = await app.inject({ method: 'GET', url: '/items/999999' }) + expect(res.statusCode).toBe(404) + expect(res.json().message).toBe('Item not found') + }) +}) + +describe('PUT /items/:id', () => { + let app: FastifyInstance + let createdId: number + + beforeEach(async () => { + app = await createTestApp() + await app.pg.query('TRUNCATE TABLE items RESTART IDENTITY') + const { rows } = await app.pg.query( + `INSERT INTO items (name, description) VALUES ('원본', '원본설명') RETURNING id` + ) + createdId = rows[0].id + }) + + afterEach(async () => { + await app.close() + }) + + // --- ECP: 정상 클래스 --- + + test('name만 수정 → 200 + 변경 반영', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/items/${createdId}`, + payload: { name: '수정된 이름' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().name).toBe('수정된 이름') + expect(res.json().description).toBe('원본설명') + }) + + test('description만 수정 → 200', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/items/${createdId}`, + payload: { description: '수정된 설명' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().description).toBe('수정된 설명') + }) + + test('name + description 동시 수정 → 200', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/items/${createdId}`, + payload: { name: '새이름', description: '새설명' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().name).toBe('새이름') + expect(res.json().description).toBe('새설명') + }) + + // --- ECP: 비정상 클래스 --- + + test('빈 body → 400', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/items/${createdId}`, + payload: {}, + }) + expect(res.statusCode).toBe(400) + }) + + test('존재하지 않는 id → 404', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/items/999999', + payload: { name: '수정' }, + }) + expect(res.statusCode).toBe(404) + }) + + // --- BVA: name 경계값 --- + + test('name 1자 수정(최솟값) → 200', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/items/${createdId}`, + payload: { name: 'a' }, + }) + expect(res.statusCode).toBe(200) + }) + + test('name 255자 수정(최댓값) → 200', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/items/${createdId}`, + payload: { name: 'a'.repeat(255) }, + }) + expect(res.statusCode).toBe(200) + }) + + test('name 256자 수정(최댓값+1) → 400', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/items/${createdId}`, + payload: { name: 'a'.repeat(256) }, + }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('DELETE /items/:id', () => { + let app: FastifyInstance + let createdId: number + + beforeEach(async () => { + app = await createTestApp() + await app.pg.query('TRUNCATE TABLE items RESTART IDENTITY') + const { rows } = await app.pg.query( + `INSERT INTO items (name) VALUES ('삭제용') RETURNING id` + ) + createdId = rows[0].id + }) + + afterEach(async () => { + await app.close() + }) + + test('존재하는 id → 204', async () => { + const res = await app.inject({ method: 'DELETE', url: `/items/${createdId}` }) + expect(res.statusCode).toBe(204) + }) + + test('존재하지 않는 id → 404', async () => { + const res = await app.inject({ method: 'DELETE', url: '/items/999999' }) + expect(res.statusCode).toBe(404) + }) +}) diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 0000000..8e548db --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + env: { + DATABASE_URL: process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL ?? '', + }, + }, +}) From e588103b883023e72978f8511386be81df0dd121 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 01:07:30 +0900 Subject: [PATCH 15/17] docs: update CLAUDE.md with current api architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레이어 구조를 domain/repositories/services/routes로 업데이트. 의존성 방향, 에러 처리 방식 추가. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6225cf6..dbf6476 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,32 @@ test: 테스트 ## 레이어 구조 (api) ``` -routes/ → 요청/응답 처리 -services/ → 비즈니스 로직 -repositories/ → DB 접근 +src/ + domain/ → 도메인 타입 + 레포지토리 인터페이스 (순수 계약, import 없음) + repositories/ → DB 구현체 (domain 인터페이스 구현) + services/ → 비즈니스 로직 (domain 인터페이스만 의존) + routes/ → 요청/응답 처리 + 의존성 조립 + schemas/ → Fastify JSON 스키마 (validation) + plugins/ → Fastify 플러그인 (DB 등) + errors.ts → AppError (중앙화된 에러 클래스) + app.ts → buildApp 팩토리 함수 (테스트 재사용) + index.ts → listen만 담당 + test/ → Vitest 테스트 + +migrations/ → SQL 마이그레이션 파일 +``` + +### 의존성 방향 + ``` +routes → repositories (구현체 조립) +routes → services +services → domain (인터페이스) +repositories → domain (인터페이스 구현) +domain ← 아무것도 import 안 함 +``` + +### 에러 처리 + +- `AppError(statusCode, message)` throw → `setErrorHandler`에서 일괄 처리 +- Fastify schema validation 에러 → `error.validation` 체크 후 400 반환 From 7928ff38f98921608d86360e3e75e0ab1da57dce Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 02:33:43 +0900 Subject: [PATCH 16/17] feat: replace psql migration with node-pg-migrate - Add node-pg-migrate as runtime dependency - Add migrate script to package.json - Convert 001_create_items.sql to node-pg-migrate JS format - Copy migrations/ in Dockerfile runner stage for migrate container - Replace postgres:16-alpine + psql with api image + node-pg-migrate in docker-compose - Add service_completed_successfully condition so api starts after migration Co-Authored-By: Claude Sonnet 4.6 --- infra/docker/docker-compose.yaml | 10 + package-lock.json | 426 ++++++++++++++++++- packages/api/Dockerfile | 3 + packages/api/migrations/001_create_items.js | 17 + packages/api/migrations/001_create_items.sql | 8 - packages/api/package.json | 2 + 6 files changed, 455 insertions(+), 11 deletions(-) create mode 100644 packages/api/migrations/001_create_items.js delete mode 100644 packages/api/migrations/001_create_items.sql diff --git a/infra/docker/docker-compose.yaml b/infra/docker/docker-compose.yaml index 91a2824..8e89db0 100644 --- a/infra/docker/docker-compose.yaml +++ b/infra/docker/docker-compose.yaml @@ -14,6 +14,16 @@ services: retries: 3 start_period: 30s start_interval: 5s + depends_on: + migrate: + condition: service_completed_successfully + + migrate: + build: + context: ../../ + dockerfile: packages/api/Dockerfile + env_file: .env + command: ["node_modules/.bin/node-pg-migrate", "-m", "packages/api/migrations", "up"] depends_on: db: condition: service_healthy diff --git a/package-lock.json b/package-lock.json index 7c20ddc..e760200 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,6 +203,15 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -630,7 +639,7 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -640,7 +649,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -826,6 +835,30 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -872,12 +905,33 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -888,6 +942,38 @@ "node": ">=18" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -915,6 +1001,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -944,6 +1044,12 @@ "node": ">=0.3.1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -951,6 +1057,15 @@ "dev": true, "license": "MIT" }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1122,6 +1237,22 @@ "node": ">=20" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1137,6 +1268,39 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -1146,6 +1310,36 @@ "node": ">= 10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -1481,6 +1675,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1498,6 +1701,30 @@ "dev": true, "license": "ISC" }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1517,6 +1744,31 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-pg-migrate": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-8.0.4.tgz", + "integrity": "sha512-HTlJ6fOT/2xHhAUtsqSN85PGMAqSbfGJNRwQF8+ZwQ1+sVGNUTl/ZGEshPsOI3yV22tPIyHXrKXr3S0JxeYLrg==", + "license": "MIT", + "dependencies": { + "glob": "~11.1.0", + "yargs": "~17.7.0" + }, + "bin": { + "node-pg-migrate": "bin/node-pg-migrate.js" + }, + "engines": { + "node": ">=20.11.0" + }, + "peerDependencies": { + "@types/pg": ">=6.0.0 <9.0.0", + "pg": ">=4.3.0 <9.0.0" + }, + "peerDependenciesMeta": { + "@types/pg": { + "optional": true + } + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1537,6 +1789,37 @@ "node": ">=14.0.0" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1807,6 +2090,15 @@ "node": ">= 12.13.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1940,6 +2232,27 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1947,6 +2260,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -1989,6 +2314,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -2130,7 +2481,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -2300,6 +2651,21 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2317,6 +2683,23 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2326,6 +2709,42 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -2344,6 +2763,7 @@ "@fastify/postgres": "^6.0.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", + "node-pg-migrate": "^8.0.4", "pino": "^9.0.0", "prom-client": "^15.0.0" }, diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 3b8f19c..e122a34 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -50,6 +50,9 @@ COPY --from=builder --chown=node:node /app/packages/shared/package.json ./packag COPY --from=builder --chown=node:node /app/packages/api/dist ./packages/api/dist COPY --from=builder --chown=node:node /app/packages/shared/dist ./packages/shared/dist +# 마이그레이션 파일 +COPY --from=builder --chown=node:node /app/packages/api/migrations ./packages/api/migrations + USER node EXPOSE 3000 diff --git a/packages/api/migrations/001_create_items.js b/packages/api/migrations/001_create_items.js new file mode 100644 index 0000000..234b970 --- /dev/null +++ b/packages/api/migrations/001_create_items.js @@ -0,0 +1,17 @@ +// @ts-nocheck +/** @type {import('node-pg-migrate').MigrationBuilder} */ + +exports.up = (pgm) => { + pgm.createTable('items', { + id: { type: 'serial', primaryKey: true }, + name: { type: 'varchar(255)', notNull: true }, + description: { type: 'text' }, + view_count: { type: 'integer', notNull: true, default: 0 }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + }) +} + +exports.down = (pgm) => { + pgm.dropTable('items') +} diff --git a/packages/api/migrations/001_create_items.sql b/packages/api/migrations/001_create_items.sql deleted file mode 100644 index 3b4825e..0000000 --- a/packages/api/migrations/001_create_items.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE IF NOT EXISTS items ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT, - view_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/packages/api/package.json b/packages/api/package.json index 7f3a159..8eeadea 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -7,6 +7,7 @@ "build": "tsc", "dev": "ts-node src/index.ts", "start": "node dist/index.js", + "migrate": "node-pg-migrate -m migrations up", "test": "vitest run", "test:watch": "vitest" }, @@ -15,6 +16,7 @@ "@fastify/postgres": "^6.0.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", + "node-pg-migrate": "^8.0.4", "pino": "^9.0.0", "prom-client": "^15.0.0" }, From 69a47c851d9dd03a2d74e0eda239218410514255 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 02:33:57 +0900 Subject: [PATCH 17/17] docs: add docker-compose migrate service design notes - Compare entrypoint script vs separate container approach - Document depends_on condition types and startup order - Include measured metrics (migrate time, image sizes) - Note K8s Job/InitContainer mapping for future migration Co-Authored-By: Claude Sonnet 4.6 --- docs/docker-compose.md | 185 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/docker-compose.md diff --git a/docs/docker-compose.md b/docs/docker-compose.md new file mode 100644 index 0000000..42138db --- /dev/null +++ b/docs/docker-compose.md @@ -0,0 +1,185 @@ +# docker-compose migrate 서비스 추가 + +## 배경 + +초기 `docker-compose.yaml`은 `api`와 `db` 두 서비스만 존재했다. +이 상태에서 `docker compose up`을 실행하면 다음 문제가 발생한다. + +``` +api 컨테이너 기동 + → GET /items 호출 + → ERROR: relation "items" does not exist +``` + +테이블이 없는 상태에서 api가 먼저 뜨기 때문이다. 개발자가 직접 아래 명령을 실행해야 했다. + +```bash +psql $DATABASE_URL -f migrations/001_create_items.sql +``` + +이 문제를 해결하기 위해 `migrate` 서비스를 추가했다. + +--- + +## 마이그레이션 실행 방식 비교 + +마이그레이션을 자동화하는 방법은 크게 두 가지다. + +### 방식 1: 엔트리포인트 스크립트 (같은 컨테이너) + +```sh +#!/bin/sh +# entrypoint.sh +node-pg-migrate up # 마이그레이션 +exec node dist/index.js # 서버 실행 +``` + +```yaml +# docker-compose.yaml +api: + command: ["sh", "entrypoint.sh"] + depends_on: + db: + condition: service_healthy +``` + +**동작:** api 컨테이너 안에서 마이그레이션을 먼저 실행한 뒤 서버를 띄운다. + +**문제점 — 스케일 아웃 시 레이스 컨디션:** + +```yaml +api: + deploy: + replicas: 3 # 3개 컨테이너가 동시에 마이그레이션 실행 +``` + +3개 컨테이너가 동시에 `CREATE TABLE items`를 실행하면: + +``` +replica-1: CREATE TABLE items → 성공 +replica-2: CREATE TABLE items → ERROR: relation "items" already exists +replica-3: CREATE TABLE items → ERROR: relation "items" already exists +``` + +`IF NOT EXISTS`로 회피할 수 있지만, 복잡한 DDL(ALTER TABLE, 인덱스 생성 등)에서는 동시 실행 자체가 위험하다. + +--- + +### 방식 2: 별도 migrate 컨테이너 (현재 방식) + +```yaml +migrate: + build: + context: ../../ + dockerfile: packages/api/Dockerfile + command: ["node_modules/.bin/node-pg-migrate", "-m", "packages/api/migrations", "up"] + depends_on: + db: + condition: service_healthy + +api: + depends_on: + migrate: + condition: service_completed_successfully +``` + +**동작:** migrate 컨테이너가 완전히 종료(exit 0)된 후에만 api 컨테이너가 기동된다. + +스케일 아웃해도 migrate는 1개만 실행되므로 레이스 컨디션이 없다. + +--- + +## depends_on condition 비교 + +`depends_on`의 `condition`은 세 가지 값을 지원한다. + +| condition | 의미 | 언제 사용 | +|-----------|------|----------| +| `service_started` | 컨테이너 프로세스가 시작됨 | 종속성이 단순히 실행 중이어야 할 때 | +| `service_healthy` | healthcheck가 통과됨 | DB처럼 실제로 요청을 받을 준비가 된 상태가 필요할 때 | +| `service_completed_successfully` | 컨테이너가 exit code 0으로 종료됨 | 마이그레이션처럼 "완전히 끝났다"는 보장이 필요할 때 | + +이 프로젝트의 기동 순서: + +``` +db (service_healthy) + ↓ pg_isready 통과 후 +migrate (service_completed_successfully) + ↓ exit 0 후 +api +``` + +`service_started`나 `service_healthy`를 migrate에 적용하면 migrate가 실행 중일 때 api가 기동되어 테이블이 없는 상태에서 쿼리가 실행될 수 있다. + +--- + +## 실측 지표 + +환경: Apple M-series, Docker Desktop, 이미지 빌드 완료 상태 + +### migrate 컨테이너 실행 시간 + +| 실행 | 결과 | 소요 시간 | +|------|------|----------| +| 1회차 (최초, 볼륨 없음) | `001_create_items` 실행 | ~298ms | +| 2회차 (볼륨 유지) | `No migrations to run!` 스킵 | ~162ms | + +2회차에서 migrate가 `pgmigrations` 테이블을 확인하고 이미 실행된 파일은 건너뛴다. **마이그레이션은 항상 안전하게 재실행 가능하다.** + +### docker compose up 전체 시간 + +| 상태 | 소요 시간 | +|------|----------| +| 최초 기동 (볼륨 없음) | ~6.5s | +| 재기동 (볼륨 유지, 서비스 Running) | ~1.2s | + +### 이미지 크기 + +방식 1(엔트리포인트)은 migrate 전용 이미지가 없으므로 해당 없음. +방식 2에서 migrate 컨테이너는 api와 동일한 이미지를 재사용한다. + +| 이미지 | 크기 | 용도 | +|--------|------|------| +| `docker-api` (= `docker-migrate`) | 178 MB | api 실행 + 마이그레이션 | +| `postgres:16-alpine` | 272 MB | (구 방식: psql로 마이그레이션) | + +별도 postgres 이미지 대신 api 이미지를 재사용하므로 **migrate 전용 이미지가 추가로 필요 없다.** + +--- + +## 새 마이그레이션 추가 시 비교 + +### 구 방식 (psql 하드코딩) + +```yaml +# docker-compose.yaml 수정 필요 +command: > + sh -c "psql $DATABASE_URL -f /migrations/001_create_items.sql && + psql $DATABASE_URL -f /migrations/002_add_tags.sql" +``` + +파일이 늘어날수록 `docker-compose.yaml`도 함께 수정해야 한다. + +### 현재 방식 (node-pg-migrate) + +``` +migrations/ + 001_create_items.js ← 기존 + 002_add_tags.js ← 파일만 추가 +``` + +`docker-compose.yaml` 수정 없이 파일만 추가하면 된다. `node-pg-migrate`가 `pgmigrations` 테이블과 대조해 실행되지 않은 파일만 순서대로 실행한다. + +--- + +## K8s 전환 시 매핑 + +별도 컨테이너 방식은 Kubernetes의 Job / InitContainer 패턴과 자연스럽게 대응된다. + +| docker-compose | Kubernetes | +|----------------|-----------| +| migrate 서비스 | `Job` (한 번 실행 후 완료) | +| `service_completed_successfully` | `initContainers` (완료 후 메인 컨테이너 기동) | +| api 서비스 | `Deployment` | + +엔트리포인트 방식으로 구현했다면 K8s 전환 시 구조를 다시 설계해야 한다.