diff --git a/.gitignore b/.gitignore index bc81b512..ad85bb6b 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,4 @@ playwright-report/ test-results/ uploads/ .release-dist/ +app/game-frontend/.release-build/ diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index e62987ed..992a9a0f 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "vite", "build": "vue-tsc && vite build", + "build:release": "vite build --outDir .release-build", + "typecheck:release": "vue-tsc --noEmit", "preview": "vite preview", "test": "pnpm test:unit", "test:unit": "node --test test/**/*.test.ts", diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 6f93338e..490f3fe1 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -49,13 +49,21 @@ export const buildTurboReleaseCommand = ( cacheAnchorRoot: string, packageNames: string[], env?: Record +): BuildCommand => buildTurboReleaseTaskCommand(workspaceRoot, cacheAnchorRoot, 'build', packageNames, env); + +export const buildTurboReleaseTaskCommand = ( + workspaceRoot: string, + cacheAnchorRoot: string, + taskName: string, + packageNames: string[], + env?: Record ): BuildCommand => ({ command: 'pnpm', args: [ 'exec', 'turbo', 'run', - 'build', + taskName, ...packageNames.map((packageName) => `--filter=${packageName}`), `--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`, `--concurrency=${resolveReleaseTurboConcurrency(env)}`, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 519694d3..f8eeff8d 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -25,6 +25,7 @@ import { isRecord } from '@sammo-ts/common'; import { buildTurboReleaseCommand, + buildTurboReleaseTaskCommand, type BuildCommand, type BuildProgressEvent, type BuildProgressObserver, @@ -530,7 +531,8 @@ const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): export const buildProfileFrontendCommands = ( workspaceRoot: string, profile: Pick, - env?: Record + env?: Record, + cacheAnchorRoot: string = workspaceRoot ): BuildCommand[] => { const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim(); const buildEnv = { @@ -540,17 +542,17 @@ export const buildProfileFrontendCommands = ( VITE_GAME_API_URL: `/${profile.profile}/api/trpc`, VITE_GAME_SSE_URL: `/${profile.profile}/api/events`, }; - const outDir = buildProfileFrontendOutDir(workspaceRoot, profile.profileName); return [ + buildTurboReleaseTaskCommand( + workspaceRoot, + cacheAnchorRoot, + 'build:release', + ['@sammo-ts/game-frontend'], + buildEnv + ), { - command: 'pnpm', - args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vue-tsc', '--noEmit'], - cwd: workspaceRoot, - env: buildEnv, - }, - { - command: 'pnpm', - args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vite', 'build', '--outDir', outDir], + command: 'node', + args: ['tools/build-scripts/materialize-profile-frontend.mjs', profile.profileName], cwd: workspaceRoot, env: buildEnv, }, @@ -561,7 +563,8 @@ export const buildWorkspaceCommands = ( workspaceRoot: string, needsInstall: boolean, env?: Record, - cacheAnchorRoot: string = workspaceRoot + cacheAnchorRoot: string = workspaceRoot, + packageNames: string[] = ['@sammo-ts/game-api', '@sammo-ts/gateway-api'] ): BuildCommand[] => { const commands: BuildCommand[] = []; if (needsInstall) { @@ -572,9 +575,7 @@ export const buildWorkspaceCommands = ( env, }); } - commands.push( - buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, ['@sammo-ts/game-api', '@sammo-ts/gateway-api'], env) - ); + commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, env)); return commands; }; @@ -1430,9 +1431,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { workspace.root, workspace.needsInstall, this.processConfig.baseEnv, + this.processConfig.workspaceRoot, + ['@sammo-ts/game-api'] + ), + ...buildProfileFrontendCommands( + workspace.root, + profile, + this.processConfig.baseEnv, this.processConfig.workspaceRoot ), - ...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv), ]; await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`); const result = await this.buildRunner.run(commands, this.buildProgress(operationId, 'build')); @@ -2004,7 +2011,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.processConfig.baseEnv, this.processConfig.workspaceRoot ), - ...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []), + ...(profile + ? buildProfileFrontendCommands( + workspace.root, + profile, + this.processConfig.baseEnv, + this.processConfig.workspaceRoot + ) + : []), ]; if (operationId) { await this.appendOperationLog( diff --git a/app/gateway-api/test/buildRunner.test.ts b/app/gateway-api/test/buildRunner.test.ts index 8094e4db..d0fb8763 100644 --- a/app/gateway-api/test/buildRunner.test.ts +++ b/app/gateway-api/test/buildRunner.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { buildTurboReleaseCommand, + buildTurboReleaseTaskCommand, MAX_BUILD_OUTPUT_CHARS, PnpmBuildRunner, resolveReleaseTurboCacheDir, @@ -58,6 +59,28 @@ describe('Turbo release build plan', () => { env: { NODE_ENV: 'production' }, }); }); + + it('uses the same bounded cache policy for a release-specific task', () => { + expect( + buildTurboReleaseTaskCommand( + '/srv/core/profile-worktrees/commit', + '/srv/core/repository', + 'build:release', + ['@sammo-ts/game-frontend'], + { VITE_APP_BASE_PATH: '/che' } + ).args + ).toEqual([ + 'exec', + 'turbo', + 'run', + 'build:release', + '--filter=@sammo-ts/game-frontend', + '--cache-dir=/srv/core/repository/.turbo/release-cache', + '--concurrency=1', + '--ui=stream', + '--output-logs=new-only', + ]); + }); }); describe('PnpmBuildRunner', () => { diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 4c0af273..11d013cd 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -315,6 +315,20 @@ describe('buildWorkspaceCommands', () => { expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true); }); + it('can limit a DB-preserving deploy to the game runtime server target', () => { + const commands = buildWorkspaceCommands( + '/srv/sammo/worktrees/0123456789abcdef', + false, + undefined, + '/srv/sammo/controller', + ['@sammo-ts/game-api'] + ); + + expect(commands[0]?.args).toContain('--filter=@sammo-ts/game-api'); + expect(commands[0]?.args).not.toContain('--filter=@sammo-ts/gateway-api'); + expect(commands[0]?.args).toContain('--concurrency=1'); + }); + it('deploys the game schema migration after building the selected workspace', () => { const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef'; const databaseUrl = 'postgresql://integration.invalid/sammo?schema=che'; @@ -347,6 +361,18 @@ describe('buildProfileFrontendCommands', () => { (command) => command.env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS === '--max-old-space-size=2048' ) ).toBe(true); + expect(commands[0]?.args).toEqual([ + 'exec', + 'turbo', + 'run', + 'build:release', + '--filter=@sammo-ts/game-frontend', + '--cache-dir=/srv/sammo/worktrees/0123456789abcdef/.turbo/release-cache', + '--concurrency=1', + '--ui=stream', + '--output-logs=new-only', + ]); + expect(commands[1]?.args).toEqual(['tools/build-scripts/materialize-profile-frontend.mjs', 'che:2']); }); it('keeps the shared Node heap when no frontend build override is configured', () => { diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index d61ceae7..1cb35a42 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -185,6 +185,15 @@ describe('profile DEPLOY operation', () => { expect(commandGroups).toHaveLength(2); expect(commandGroups[0]?.[0]?.args).toEqual(['install', '--frozen-lockfile']); + expect(commandGroups[0]?.[1]?.args).toContain('--filter=@sammo-ts/game-api'); + expect(commandGroups[0]?.[1]?.args).not.toContain('--filter=@sammo-ts/gateway-api'); + expect(commandGroups[0]?.[2]?.args).toContain('build:release'); + expect(commandGroups[0]?.[2]?.args).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache'); + expect(commandGroups[0]?.[2]?.args).toContain('--concurrency=1'); + expect(commandGroups[0]?.[3]?.args).toEqual([ + 'tools/build-scripts/materialize-profile-frontend.mjs', + 'che:1010', + ]); expect(commandGroups[1]?.map((command) => command.args)).toEqual([ ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], ]); diff --git a/app/gateway-api/test/profileFrontendMaterializer.test.ts b/app/gateway-api/test/profileFrontendMaterializer.test.ts new file mode 100644 index 00000000..26e77f62 --- /dev/null +++ b/app/gateway-api/test/profileFrontendMaterializer.test.ts @@ -0,0 +1,45 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const materializer = path.resolve(import.meta.dirname, '../../../tools/build-scripts/materialize-profile-frontend.mjs'); +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('profile frontend materializer', () => { + it('replaces an existing profile artifact from the cached release build', async () => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'profile-frontend-materializer-')); + temporaryRoots.push(workspaceRoot); + const source = path.join(workspaceRoot, 'app', 'game-frontend', '.release-build'); + const target = path.join(workspaceRoot, '.release-dist', 'che_2', 'game-frontend'); + await mkdir(source, { recursive: true }); + await mkdir(target, { recursive: true }); + await writeFile(path.join(source, 'index.html'), 'new release'); + await writeFile(path.join(target, 'index.html'), 'old release'); + + await execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot }); + + await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('new release'); + }); + + it('keeps an existing artifact untouched when the cached build is missing', async () => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'profile-frontend-materializer-')); + temporaryRoots.push(workspaceRoot); + const target = path.join(workspaceRoot, '.release-dist', 'che_2', 'game-frontend'); + await mkdir(target, { recursive: true }); + await writeFile(path.join(target, 'index.html'), 'old release'); + + await expect( + execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot }) + ).rejects.toThrow(); + await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('old release'); + }); +}); diff --git a/app/gateway-frontend/package.json b/app/gateway-frontend/package.json index 321b7c25..2db93970 100644 --- a/app/gateway-frontend/package.json +++ b/app/gateway-frontend/package.json @@ -10,6 +10,8 @@ "test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs", "test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs", "build": "vue-tsc && vite build", + "build:release": "vite build", + "typecheck:release": "vue-tsc --noEmit", "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index fd906721..92583e1c 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -5,6 +5,7 @@ import { stripVTControlCharacters } from 'node:util'; import { assertReleaseComponents, buildTurboReleaseCommand, + buildTurboReleaseTaskCommand, type BuildCommand, type BuildProgressEvent, type BuildRunner, @@ -43,10 +44,12 @@ const buildGatewayReleaseCommands = ( }; return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), - buildTurboReleaseCommand( + buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env), + buildTurboReleaseTaskCommand( workspaceRoot, config.workspaceRoot, - ['@sammo-ts/gateway-api', '@sammo-ts/gateway-frontend'], + 'build:release', + ['@sammo-ts/gateway-frontend'], env ), ]; diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 7f246912..3cd4a2b6 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -220,6 +220,11 @@ describe('GatewayReleaseController', () => { expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile'); expect(commandGroups[0]?.[1]).toContain('turbo run build'); expect(commandGroups[0]?.[1]).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache'); + expect(commandGroups[0]?.[1]).toContain('--filter=@sammo-ts/gateway-api'); + expect(commandGroups[0]?.[1]).not.toContain('--filter=@sammo-ts/gateway-frontend'); + expect(commandGroups[0]?.[2]).toContain('turbo run build:release'); + expect(commandGroups[0]?.[2]).toContain('--filter=@sammo-ts/gateway-frontend'); + expect(commandGroups[0]?.[2]).toContain('--concurrency=1'); expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']); expect([...running.keys()].sort()).toEqual([...gatewayNames].sort()); expect(harness.published).toEqual([ diff --git a/docs/release-operations.md b/docs/release-operations.md index 0753fe1a..077b13a3 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -51,15 +51,19 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 `.turbo/release-cache`이므로 commit별 worktree가 달라도 재사용됩니다. 별도 persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을 설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다. -- `NODE_ENV`와 Vite가 추론한 `VITE_*`는 build hash에 포함됩니다. 따라서 base path나 - API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 않습니다. +- Server build 뒤 frontend release는 `typecheck:release`와 Vite bundle을 별도 Turbo + task로 실행합니다. 타입검사 hash에는 frontend 자체와 직접 해석하는 common/infra/ + logic/game-engine/game-api/gateway-api source, Prisma schema와 기본 navigation resource가 + 들어가며, bundle hash에는 `NODE_ENV`와 모든 `VITE_*`가 포함됩니다. 따라서 내부 type + source, base path나 API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 + 않습니다. 일반 개발용 `pnpm --filter build`의 typecheck 계약은 그대로입니다. `NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로 build child에 전달됩니다. - `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을 turn-daemon PM2 process의 `NODE_OPTIONS`로만 덮어씁니다. API·frontend·worker는 공용 `NODE_OPTIONS`를 계속 사용합니다. Profile의 `vue-tsc`와 Vite build만 더 큰 heap이 필요하면 `PROFILE_FRONTEND_BUILD_NODE_OPTIONS`를 지정합니다. 이 값은 - profile frontend build child의 `NODE_OPTIONS`만 덮어쓰며 server package Turbo + profile frontend release task의 `NODE_OPTIONS`만 덮어쓰며 server package Turbo build와 배포 후 PM2 process의 heap은 바꾸지 않습니다. 전용 heap을 늘릴 때는 runtime container hard limit과 전체 process RSS를 먼저 확인합니다. - migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 @@ -80,7 +84,8 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중 `DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다. -1. 대상 commit의 game frontend, API, engine과 worker artifact를 빌드합니다. +1. 대상 commit의 game API target과 그 transitive engine/worker artifact를 빌드한 뒤, + profile frontend typecheck와 bundle을 공유 Turbo cache에서 복원하거나 생성합니다. 2. 기존 profile PM2 process를 정지합니다. 3. profile game schema에 `prisma migrate deploy`를 실행합니다. 4. Scenario seed를 실행하지 않고 frontend, API, daemon과 worker를 시작합니다. @@ -90,6 +95,15 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중 데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에 별도로 검토해 주세요. +Profile frontend bundle은 package의 `.release-build`에 고정 생성되어 profile base +path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를 +`.release-dist//game-frontend`에 staging directory를 거쳐 교체합니다. +따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지 +않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다. +`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼 +`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적 +server target을 `game-api`로 제한합니다. + Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습니다. 이때 이미 열린 Gateway 로비의 profile 상세 조회가 실패하면 로비는 10초 request timeout과 1·2·3·5·8·15초(이후 15초 상한) 재시도로 자동 복구를 시도합니다. 상세가 아직 @@ -208,7 +222,8 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면 1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다. 2. Release manifest의 protocol, component와 migration head를 검증합니다. -3. Gateway API와 frontend를 공유 Turbo cache로 빌드하고 gateway migration을 적용합니다. +3. Gateway API server build를 공유 Turbo cache에서 준비하고, frontend는 별도 cached + typecheck와 Vite bundle task로 한 번씩 실행한 뒤 gateway migration을 적용합니다. 4. `sammo:gateway-api`, `sammo:gateway-frontend`, `sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다. 5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를 diff --git a/tools/build-scripts/materialize-profile-frontend.mjs b/tools/build-scripts/materialize-profile-frontend.mjs new file mode 100644 index 00000000..0586575e --- /dev/null +++ b/tools/build-scripts/materialize-profile-frontend.mjs @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto'; +import { cp, mkdir, rename, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +const sanitizeArtifactName = (value) => value.replace(/[^0-9A-Za-z._-]+/g, '_'); + +const pathExists = async (target) => { + try { + await stat(target); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +}; + +export const materializeProfileFrontend = async (workspaceRoot, profileName) => { + const normalizedProfileName = profileName.trim(); + if (!normalizedProfileName) throw new Error('profileName is required.'); + + const source = path.join(workspaceRoot, 'app', 'game-frontend', '.release-build'); + const target = path.join( + workspaceRoot, + '.release-dist', + sanitizeArtifactName(normalizedProfileName), + 'game-frontend' + ); + await stat(path.join(source, 'index.html')); + + const parent = path.dirname(target); + const nonce = `${process.pid}-${randomUUID()}`; + const staging = path.join(parent, `.game-frontend-staging-${nonce}`); + const previous = path.join(parent, `.game-frontend-previous-${nonce}`); + await mkdir(parent, { recursive: true }); + await cp(source, staging, { recursive: true, errorOnExist: true, force: false }); + + let movedPrevious = false; + try { + if (await pathExists(target)) { + await rename(target, previous); + movedPrevious = true; + } + await rename(staging, target); + if (movedPrevious) await rm(previous, { recursive: true, force: true }); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + if (movedPrevious && !(await pathExists(target))) await rename(previous, target); + throw error; + } + + return target; +}; + +const isDirectExecution = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (isDirectExecution) { + const profileName = process.argv[2]; + if (!profileName) throw new Error('Usage: materialize-profile-frontend.mjs '); + const target = await materializeProfileFrontend(process.cwd(), profileName); + process.stdout.write(`Profile frontend materialized at ${target}\n`); +} diff --git a/turbo.json b/turbo.json index f83d3229..b99ccdb5 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,28 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, + "build:release": { + "dependsOn": ["typecheck:release"], + "env": ["NODE_ENV", "VITE_*"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**", ".release-build/**"] + }, + "typecheck:release": { + "dependsOn": ["@sammo-ts/infra#prisma:generate"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/tsconfig*.json", + "$TURBO_ROOT$/resources/navigation.json", + "$TURBO_ROOT$/packages/common/src/**", + "$TURBO_ROOT$/packages/infra/src/**", + "$TURBO_ROOT$/packages/infra/prisma/*.prisma", + "$TURBO_ROOT$/packages/logic/src/**", + "$TURBO_ROOT$/app/game-engine/src/**", + "$TURBO_ROOT$/app/game-api/src/**", + "$TURBO_ROOT$/app/gateway-api/src/**" + ], + "cache": true + }, "typecheck": { "dependsOn": ["^build", "prisma:generate"], "cache": true