@gaonjs/cli 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -155,8 +155,14 @@ export async function runTestCommand(args = [], opts = {}) {
155
155
  cmd = vitestBin;
156
156
  spawnArgs = ['run', ...passthrough];
157
157
  }
158
+ // 결정 130: 테스트 프로세스에 스트림 네임스페이스 접두를 주입해 잡·이벤트
159
+ // NATS 스트림을 개발 스택과 격리한다(규칙 10 · DB `<db>_test` 와 대칭). 접두가
160
+ // 붙으면 테스트가 쓰는 스트림/subject 가 `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로
161
+ // 갈려, 개발용 `gaon work` 가 떠 있어도 서로 잡을 훔치지 않는다. 사용자가 이미
162
+ // 값을 세팅했으면 존중한다(고급 · 다중 테스트 컨텍스트 분리).
163
+ const testEnv = { ...process.env, GAON_STREAM_PREFIX: process.env.GAON_STREAM_PREFIX ?? 'test' };
158
164
  if (json) {
159
- process.stdout.write(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope }) + '\n');
165
+ process.stdout.write(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope, streamPrefix: testEnv.GAON_STREAM_PREFIX }) + '\n');
160
166
  }
161
167
  else {
162
168
  process.stdout.write(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
@@ -164,7 +170,7 @@ export async function runTestCommand(args = [], opts = {}) {
164
170
  const exitCode = await new Promise((resolvePromise) => {
165
171
  const child = spawn(cmd, spawnArgs, {
166
172
  cwd,
167
- env: process.env,
173
+ env: testEnv,
168
174
  // vitest 컬러 출력·리포터를 그대로 보여주기 위해 stdio 를 상속한다.
169
175
  stdio: 'inherit',
170
176
  });
@@ -86,6 +86,10 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
86
86
  `curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
87
87
  - **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
88
88
  `gaon jobs retry <id>` 로 조회·재적재한다.
89
+ - **실행 컨텍스트(결정 129)** — `gaon work` 는 `gaon serve` 와 **같은 도메인
90
+ 배선**(DB 커넥션 · 메일)을 태운다. 그래서 잡 핸들러에서 모델 조회(`User.find`),
91
+ `Mail.deliver`, `broadcast`, 다른 잡 `.later()` 를 웹 컨트롤러에서와 똑같이 쓴다
92
+ — 워커라서 못 쓰는 배터리는 없다.
89
93
  - 기본 백오프 곡선은 `[1s, 5s, 30s, 5m, 1h]` + 지터 0.2, 재시도 3
90
94
  (총 4시도) — §7 · 벤치마크로 확정된 값.
91
95
 
@@ -361,8 +361,11 @@ async function runSearch(q: string) {
361
361
  **method-override** 가 잡는다(결정 89).
362
362
  - **채널 구독은 `useChannel()`** — 실시간 클라이언트는 `gaonjs/vue` 의
363
363
  `useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
364
- URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
365
- 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
364
+ URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클·**자동 재연결**을
365
+ 재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
366
+ useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
367
+ 켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은
368
+ 재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것.
366
369
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
367
370
  중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
368
371
  - **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
@@ -144,6 +144,7 @@ export function useRoom(roomId: number) {
144
144
  params: { room: roomId },
145
145
  onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
146
146
  onPresence: (delta) => { /* 접속자 join/leave */ },
147
+ onReconnect: () => { /* 재연결됨 — 놓친 데이터를 Inertia partial reload 로 따라잡기 */ },
147
148
  })
148
149
  return { messages, status, send }
149
150
  }
@@ -153,8 +154,18 @@ export function useRoom(roomId: number) {
153
154
  정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
154
155
  - **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출, `presence` 는
155
156
  `onPresence`. 그 외는 `onFrame`.
157
+ - **자동 재연결(결정 128 · 기본 켬)** — 소켓이 끊기면(서버 재시작·네트워크 blip)
158
+ useChannel 이 **지수 백오프**(1s·2s·5s·10s · 이후 10s 반복 · 지터)로 자동 재접속한다.
159
+ 새 소켓은 서버가 다시 인가하고 프레즌스 스냅샷을 다시 밀어주므로 접속자 목록이
160
+ 재동기된다(`messages` 는 유지 · 놓친 이벤트 리플레이는 범위 밖). `status` 는
161
+ `'connecting' | 'open' | 'reconnecting' | 'closed'` 로, 드롭 후 `'reconnecting'`,
162
+ 성공하면 `'open'`. **재연결 성공 시 `onReconnect` 콜백**으로 놓친 데이터를 따라잡는다
163
+ (Inertia partial reload). **미인가(4401 close · authorize 거부·세션 만료)면 재연결하지
164
+ 않고 `'closed'`** — 서버 다운(재시도)과 인가 거부(포기)를 close code 로 가른다. 끄려면
165
+ `reconnect: false`, 튜닝은 `reconnect: { curveMs, maxDelayMs, maxAttempts }`. 언마운트·
166
+ 수동 `close()` = 의도적 종료라 재연결하지 않는다.
156
167
  - **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
157
- 을 직접 쓸 수 있다(봉투·라이프사이클을 스스로 책임진다). 기본 경로는 `useChannel`.
168
+ 을 직접 쓸 수 있다(봉투·라이프사이클·재연결을 스스로 책임진다). 기본 경로는 `useChannel`.
158
169
 
159
170
  ### 5. 허브 프로세스 (`gaon hub`)
160
171
 
@@ -15,13 +15,19 @@
15
15
  테스트만 다른 DB 로 돌리는 것은 자기모순이며, 방언 차이 버그가
16
16
  테스트를 통과해 운영에서 터지는 구멍이었다.
17
17
 
18
- - 테스트 전에 compose 로 DB · NATS 를 띄우고, **테스트 전용
19
- 데이터베이스**(truncate 격리 · 결정 111) + **테스트 전용 스트림
20
- 프리픽스**를 쓴다.
18
+ - 테스트 전에 compose 로 DB · NATS 를 띄운다. `gaon test` 가 **테스트 전용
19
+ 데이터베이스**(`<db>_test` · truncate 격리 · 결정 111) **테스트 전용 NATS
20
+ 스트림 네임스페이스**(`GAON_STREAM_PREFIX` · 결정 130) 를 **자동으로** 격리한다.
21
21
  - `gaon test` 가 테스트 전용 데이터베이스(`<db>_test`)를 **자동 준비**한다
22
22
  — 없으면 만들고(CREATE DATABASE) 마이그레이션까지 적용한 뒤 vitest 를
23
23
  돌린다. 스캐폴드 `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`)
24
24
  매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
25
+ - `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
26
+ 테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
27
+ `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 그래서 개발용 `gaon work` 가
28
+ 떠 있어도 테스트 잡을 훔치지 않고, 테스트가 남긴 잡을 개발 워커가 처리하지도
29
+ 않는다 — **테스트 전에 워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이
30
+ 격리가 없어 개발 스택과 섞이니 `gaon test` 를 쓴다.)
25
31
  - SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
26
32
  아니다.
27
33
 
@@ -170,8 +176,10 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
170
176
  - **`expect(true).toBe(true)` 류 무의미 단언 금지** — 실 인프라에
171
177
  접속하지 않는 통합 테스트는 §9 위반으로 판정된다.
172
178
  - **NATS·DB 목업 금지** — `vi.mock` 으로 전송을 막으면 검증이 무효.
173
- - **테스트 격리** 공유 이름을 쓰면 병렬 테스트가 서로의 잡을
174
- 소비한다. 큐·스트림 프리픽스를 테스트 전용으로.
179
+ - **테스트 격리는 `gaon test` 준다(결정 130)** 잡·이벤트 스트림을
180
+ 테스트 네임스페이스(`GAON_STREAM_PREFIX`)로 자동 격리하므로, 개발용 `gaon work`
181
+ 와 나란히 돌려도 서로의 잡을 소비하지 않는다. 직접 `vitest` 로 돌리면 이 격리가
182
+ 없으니(개발 스택 공유) `gaon test` 를 쓴다.
175
183
  - **워커 정리** — 직접 `runWorker` 를 쓰면 테스트 종료 전 `stop()` 을
176
184
  보장하라 (`expectJobProcessed` 는 자동 정리).
177
185
  - **직렬화 테스트가 직접 행만 봄** — hidden 검증을 `Model.create()` 결과
@@ -185,5 +193,6 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
185
193
  | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
186
194
  | 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
187
195
  | 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
196
+ | 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
188
197
  | §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
189
198
  | 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
package/dist/work.js CHANGED
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { hostname } from 'node:os';
13
13
  import { connectNats, runWork } from '@gaonjs/async';
14
- import { createDb } from '@gaonjs/data';
14
+ import { createDb, registerConnection, hasConnection, getConnection } from '@gaonjs/data';
15
+ import { loadGaonConfig, wireDomain } from '@gaonjs/config';
15
16
  import { loadDomain } from './domain.js';
16
17
  /** DB URL 에서 어댑터를 추정한다(postgres·mysql). */
17
18
  function dbConfigFromUrl(url) {
@@ -69,12 +70,23 @@ export async function runWorkCommand(opts = {}) {
69
70
  }
70
71
  };
71
72
  const nats = await connectNats({ servers: opts.natsUrl, name: `work@${id}` });
72
- let db;
73
+ // 도메인 배터리 배선(결정 129) — 잡 핸들러가 모델을 읽고(registerConnection)
74
+ // 메일을 보내려면(configureMailer) work 도 serve 와 같은 도메인 배선을 태워야
75
+ // 한다. serve 는 wireGaon → wireDomain, work 은 여기서 직접 wireDomain 을
76
+ // 호출한다. 이 배선이 없으면 잡에서 모델 조회·메일 발송이 운영 워커에서만
77
+ // 조용히 깨진다(gaon dev 는 serve 프로세스가 대신 배선해 가려짐).
78
+ const config = await loadGaonConfig(root);
79
+ const domainWiring = await wireDomain(config, root);
80
+ // 아웃박스 릴레이·모델 읽기용 main 커넥션. gaon.config 에 db.main 이 있으면
81
+ // wireDomain 이 이미 등록했다. config 없이 env(DATABASE_URL)만 있는 프로젝트는
82
+ // 여기서 관례로 보강 등록해 잡의 모델 읽기와 릴레이가 함께 동작하게 한다.
73
83
  // 스캐폴드 .env 관례(DATABASE_URL)를 정본으로 · GAON_DATABASE_URL 은 하위호환.
74
- // serve(config 경유 DATABASE_URL)와 work 가 같은 env 를 보게 한다.
75
84
  const dbUrl = opts.databaseUrl ?? process.env.DATABASE_URL ?? process.env.GAON_DATABASE_URL;
76
- if (dbUrl)
77
- db = createDb(dbConfigFromUrl(dbUrl));
85
+ if (!hasConnection('main') && dbUrl) {
86
+ const cfg = dbConfigFromUrl(dbUrl);
87
+ registerConnection('main', createDb(cfg), cfg.adapter);
88
+ }
89
+ const db = hasConnection('main') ? getConnection('main') : undefined;
78
90
  const domain = await loadDomain(root);
79
91
  // 로드된 도메인 자산 요약(파일=등록 관측용). 잡·리스너·메일 수를 노출한다.
80
92
  if (json) {
@@ -99,8 +111,7 @@ export async function runWorkCommand(opts = {}) {
99
111
  });
100
112
  }
101
113
  catch (err) {
102
- if (db)
103
- await db.destroy().catch(() => { });
114
+ await domainWiring.closeDomain().catch(() => { });
104
115
  await nats.close();
105
116
  throw err;
106
117
  }
@@ -110,8 +121,7 @@ export async function runWorkCommand(opts = {}) {
110
121
  signals.off('SIGTERM', stop);
111
122
  void (async () => {
112
123
  await work.stop();
113
- if (db)
114
- await db.destroy().catch(() => { });
124
+ await domainWiring.closeDomain().catch(() => { });
115
125
  await nats.close();
116
126
  if (json)
117
127
  process.stdout.write(JSON.stringify({ kind: 'stopped' }) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/config": "0.9.2",
31
- "@gaonjs/core": "0.2.1",
32
- "@gaonjs/web": "0.13.0",
30
+ "@gaonjs/async": "0.8.0",
31
+ "@gaonjs/config": "0.10.0",
33
32
  "@gaonjs/data": "0.13.1",
33
+ "@gaonjs/core": "0.2.1",
34
34
  "@gaonjs/mail": "0.1.3",
35
- "@gaonjs/async": "0.7.0"
35
+ "@gaonjs/web": "0.13.1"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""