@gaonjs/cli 0.62.1 → 0.63.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.
- package/dist/doctor/channel-collision.js +51 -5
- package/dist/doctor/channel-instance-authorize.js +3 -1
- package/dist/doctor/locale-parity.js +72 -9
- package/dist/doctor/shared-purity.js +35 -3
- package/dist/templates/project/AGENTS.md.tpl +3 -3
- package/dist/templates/project/agents/async.md.tpl +32 -0
- package/dist/templates/project/agents/data.md.tpl +54 -4
- package/dist/templates/project/agents/i18n.md.tpl +6 -0
- package/dist/templates/project/agents/realtime.md.tpl +71 -4
- package/package.json +13 -13
|
@@ -11,11 +11,23 @@
|
|
|
11
11
|
// 잡·리스너의 동명 등록은 런타임에 throw 로 막지만(결정 271), 채널은 앱별 맵에
|
|
12
12
|
// 따로 담겨 런타임 충돌 신호가 없다 — 그래서 정적 검사로 잡는다.
|
|
13
13
|
//
|
|
14
|
-
// 의도적 공유의 탈출구: 정의를 `
|
|
15
|
-
// 파일이 **재수출**하면 통과한다(정의 하나 = 훅·인가 규칙 하나 ·
|
|
16
|
-
//
|
|
14
|
+
// 의도적 공유의 탈출구: 정의를 `domain/channels/<이름>.ts` 하나에 두고 각 앱 채널
|
|
15
|
+
// 파일이 **재수출**하면 통과한다(정의 하나 = 훅·인가 규칙 하나 · 앱→domain 은
|
|
16
|
+
// 허용된 방향이라 의존 방향 4규칙에도 맞다).
|
|
17
|
+
//
|
|
18
|
+
// 결정 456: 이 자리는 원래 `shared/channels/` 를 안내했으나 **성립 불가**였다 —
|
|
19
|
+
// authorize 가 DB 를 보면(정본 예시들이 그렇다) domain 을 **값으로** import 해야
|
|
20
|
+
// 하는데 그건 `shared-purity` 위반이다(shared 는 props 로만 받는 순수 UI 영역 ·
|
|
21
|
+
// AGENTS §1-3). 두 규칙이 동시에 성립하지 않아 실사용(auction 샘플)이 막혔다.
|
|
22
|
+
// shared-purity 를 뚫는 대신(순수성 게이트의 경로 예외는 썩는다) 채널 authorize 를
|
|
23
|
+
// **도메인 규칙**으로 보고 domain/channels/ 를 정본 위치로 삼는다. `domain/channels/`
|
|
24
|
+
// 는 도메인 로더(cli/domain.ts)가 스캔하지 않는 **정의 전용** 디렉터리이고, 등록은
|
|
25
|
+
// 여전히 `apps/<앱>/channels/` 재수출 파일이 담당한다.
|
|
17
26
|
//
|
|
18
27
|
// 판정은 소스 텍스트 기반(가벼운 정적 검사) — 채널 모듈을 실행하지 않는다.
|
|
28
|
+
// 재수출 대상의 **디렉터리는 따지지 않는다**(정의가 하나인지만 본다). 다만 옛
|
|
29
|
+
// 안내를 따라 `shared/channels/` 에 정의를 둔 프로젝트에는 이전 안내를 경고로
|
|
30
|
+
// 회수한다(아래 checkSharedChannelsDir).
|
|
19
31
|
import { readdir, readFile } from 'node:fs/promises';
|
|
20
32
|
import { join, relative, resolve, dirname } from 'node:path';
|
|
21
33
|
import { stripComments } from './source-scan.js';
|
|
@@ -92,13 +104,47 @@ export async function checkChannelCollision(cwd) {
|
|
|
92
104
|
`한쪽의 공개 규칙으로 다른 앱 메시지가 샙니다.\n` +
|
|
93
105
|
`→ 앱마다 이름을 분리하세요: apps/${target.app}/channels/${renamed}.ts ` +
|
|
94
106
|
`(클라이언트 useChannel('${renamed}') 도 함께 바꿉니다).\n` +
|
|
95
|
-
`→
|
|
96
|
-
|
|
107
|
+
`→ 여러 앱이 **한 방을 공유**하는 것이 의도면 정의를 domain/channels/${name}.ts 하나에 두고 ` +
|
|
108
|
+
`각 앱 채널 파일에서 재수출하세요(정의 하나 = 인가 규칙 하나 · 등록 파일은 apps/ 에 그대로 둡니다):\n` +
|
|
109
|
+
` export { default } from '../../../domain/channels/${name}.js'\n` +
|
|
110
|
+
` (authorize 가 DB 를 보면 domain 값 import 가 필요해 shared/ 에는 둘 수 없습니다 — ` +
|
|
111
|
+
`shared 는 props 로만 받는 순수 UI 영역입니다 · 결정 456)`,
|
|
97
112
|
detail: { channel: name, apps: files.map((f) => f.app), files: files.map((f) => f.rel) },
|
|
98
113
|
});
|
|
99
114
|
}
|
|
115
|
+
issues.push(...(await checkSharedChannelsDir(cwd)));
|
|
100
116
|
return { rule: 'channel-collision', issues };
|
|
101
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* 결정 456: 옛 안내(`shared/channels/`)를 따라 둔 공유 채널 정의를 회수한다.
|
|
120
|
+
*
|
|
121
|
+
* 경고인 이유: authorize 가 DB 를 안 보는 채널이면 그 배치도 **동작은 한다**
|
|
122
|
+
* (DB 를 보는 순간 shared-purity 가 error 로 잡는다). 동작하는 배치를 error 로
|
|
123
|
+
* 세워 빌드를 깨는 대신, 정본 위치로 옮기라고 안내만 한다.
|
|
124
|
+
*/
|
|
125
|
+
async function checkSharedChannelsDir(cwd) {
|
|
126
|
+
const dir = join(cwd, 'shared', 'channels');
|
|
127
|
+
const issues = [];
|
|
128
|
+
for (const file of (await safeListFiles(dir)).sort()) {
|
|
129
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
|
|
130
|
+
continue;
|
|
131
|
+
const name = file.slice(0, -3);
|
|
132
|
+
const rel = relative(cwd, join(dir, file));
|
|
133
|
+
issues.push({
|
|
134
|
+
rule: 'channel-collision',
|
|
135
|
+
level: 'warning',
|
|
136
|
+
file: rel,
|
|
137
|
+
message: `공유 채널 정의가 shared/ 에 있습니다: ${rel}\n` +
|
|
138
|
+
` 채널 authorize 는 도메인 규칙이라 shared 가 아니라 domain 이 정본 위치입니다 — shared 는 ` +
|
|
139
|
+
`props 로만 받는 순수 UI 영역이고(AGENTS §1-3), authorize 가 DB 를 보는 순간 domain 값 import 가 ` +
|
|
140
|
+
`필요해 shared-purity 에러가 납니다(결정 456).\n` +
|
|
141
|
+
`→ domain/channels/${name}.ts 로 옮기고 각 앱 채널 파일의 재수출 경로를 바꾸세요:\n` +
|
|
142
|
+
` export { default } from '../../../domain/channels/${name}.js'`,
|
|
143
|
+
detail: { kind: 'shared-channel-definition', channel: name, file: rel },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return issues;
|
|
147
|
+
}
|
|
102
148
|
async function safeListDirs(dir) {
|
|
103
149
|
try {
|
|
104
150
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// 아니면 authorize(ctx) 에서 ctx.instance 로 입장을 판정하라.
|
|
9
9
|
//
|
|
10
10
|
// 판정은 소스 텍스트 기반(가벼운 정적 검사 · channel-collision 과 동일 접근).
|
|
11
|
-
// 재수출 파일(
|
|
11
|
+
// 재수출 파일(멀티앱 공유 채널 = `domain/channels/` 정의 · 결정 456)은 대상 모듈을
|
|
12
|
+
// 따라가 정의 소스를 검사한다 — 대상 디렉터리는 따지지 않으므로 옛 shared/ 배치도
|
|
13
|
+
// 그대로 판정된다.
|
|
12
14
|
import { readdir, readFile } from 'node:fs/promises';
|
|
13
15
|
import { join, relative, resolve, dirname } from 'node:path';
|
|
14
16
|
import { stripComments } from './source-scan.js';
|
|
@@ -8,9 +8,18 @@
|
|
|
8
8
|
//
|
|
9
9
|
// 경고(error 아님): 부분 누락은 fallback 으로 화면이 깨지진 않는 소프트 결함이라
|
|
10
10
|
// 빌드를 세우지 않는다. --json 은 detail.locale·detail.missing 으로 구조화한다.
|
|
11
|
-
|
|
11
|
+
//
|
|
12
|
+
// 결정 456: **앱 스코프 카탈로그(`apps/<앱>/locales/<로케일>.json`)까지 훑는다.**
|
|
13
|
+
// 결정 454 가 앱 전용 화면 문구의 정본 배치로 이 경로를 지정했고 `agents/i18n.md`
|
|
14
|
+
// 정본 예시가 "같은 키를 나란히(locale-parity)" 라고 **약속**하는데, 검사는 루트
|
|
15
|
+
// `locales/` 만 보고 있었다(auction 샘플 실측 — 앱 카탈로그에서 키를 지워도 무소음).
|
|
16
|
+
// `i18n-app-scope` 도 대신 못 잡는다: 그 검사의 키 집합은 **전 로케일 합집합**이라
|
|
17
|
+
// en 에만 있는 키도 "있는 키" 로 세기 때문이다(축이 다르다 — 앱 경계 침범 검출).
|
|
18
|
+
// 비교는 **앱 내부에서만** 한다 — 앱 카탈로그와 루트를 교차 비교하면 앱 전용 키가
|
|
19
|
+
// 전부 "루트에 없음" 으로 뜨는 오탐이 된다(앱 카탈로그는 루트의 초과분이 정상).
|
|
20
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
12
21
|
import { join, relative } from 'node:path';
|
|
13
|
-
import { loadScopedLocales, flattenKeys } from '@gaonjs/i18n';
|
|
22
|
+
import { loadAppLocales, loadScopedLocales, flattenKeys } from '@gaonjs/i18n';
|
|
14
23
|
import { analyzeProjectI18n, resolveLocalesDir } from '../i18n-config.js';
|
|
15
24
|
// 경고 한 줄이 폭주하지 않게 나열 상한 — 넘으면 "…외 N개" 로 접는다(detail 에는 전량).
|
|
16
25
|
const MAX_KEYS_SHOWN = 20;
|
|
@@ -41,15 +50,62 @@ export async function checkLocaleParity(cwd) {
|
|
|
41
50
|
const resources = scoped[scope];
|
|
42
51
|
issues.push(...parityIssues(cwd, localesDir, resources, scoped.layout, scope));
|
|
43
52
|
}
|
|
53
|
+
issues.push(...appParityIssues(cwd));
|
|
44
54
|
return { rule: 'locale-parity', issues };
|
|
45
55
|
}
|
|
46
|
-
/**
|
|
47
|
-
function
|
|
56
|
+
/** `apps/` 아래 앱 폴더 이름들(없으면 빈 배열). */
|
|
57
|
+
function appNames(cwd) {
|
|
58
|
+
try {
|
|
59
|
+
return readdirSync(join(cwd, 'apps'), { withFileTypes: true })
|
|
60
|
+
.filter((e) => e.isDirectory())
|
|
61
|
+
.map((e) => e.name)
|
|
62
|
+
.sort();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 결정 456: 앱 스코프 카탈로그의 로케일 간 키 diff. 앱 **내부에서만** 비교한다
|
|
70
|
+
* (앱 카탈로그는 루트 frontend 의 초과분이 정상이라 교차 비교는 전량 오탐).
|
|
71
|
+
* 앱 카탈로그는 frontend 전용이므로(결정 454 R7) 스코프 분리는 없다.
|
|
72
|
+
*/
|
|
73
|
+
function appParityIssues(cwd) {
|
|
74
|
+
const issues = [];
|
|
75
|
+
for (const app of appNames(cwd)) {
|
|
76
|
+
const appLocalesDir = join(cwd, 'apps', app, 'locales');
|
|
77
|
+
if (!existsSync(appLocalesDir))
|
|
78
|
+
continue;
|
|
79
|
+
const resources = loadAppLocales(appLocalesDir);
|
|
80
|
+
for (const [lng, missing] of missingByLang(resources)) {
|
|
81
|
+
const rel = relative(cwd, join(appLocalesDir, `${lng}.json`));
|
|
82
|
+
const shown = missing.slice(0, MAX_KEYS_SHOWN);
|
|
83
|
+
const more = missing.length - shown.length;
|
|
84
|
+
const list = shown.map((k) => `'${k}'`).join(', ') + (more > 0 ? ` …외 ${more}개` : '');
|
|
85
|
+
issues.push({
|
|
86
|
+
rule: 'locale-parity',
|
|
87
|
+
level: 'warning',
|
|
88
|
+
file: rel,
|
|
89
|
+
message: `로케일 '${lng}' 에 같은 앱의 다른 로케일엔 있는 키 ${missing.length}개가 빠졌습니다: ${rel}\n` +
|
|
90
|
+
` 누락 키: ${list}\n` +
|
|
91
|
+
`→ ${rel} 에 이 키들을 채우고 gaon gen 을 실행하세요 — 없으면 '${lng}' 사용자 화면에\n` +
|
|
92
|
+
` 키 문자열이 그대로 노출됩니다(결정 455 런타임 degrade). 앱 공용 문구면 대신\n` +
|
|
93
|
+
` locales/${lng}/frontend.json 으로 올리세요.`,
|
|
94
|
+
detail: { locale: lng, scope: `app:${app}`, app, missing },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return issues;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 로케일 간 키 diff — 로케일별 "다른 로케일엔 있는데 나에겐 없는 키" 를 낸다.
|
|
102
|
+
* 복수형 접미사는 base 로 정규화한다(로케일별 정당한 복수형 차이는 누락이 아니다).
|
|
103
|
+
* 로케일이 0·1개면 비교 대상이 없어 빈 결과.
|
|
104
|
+
*/
|
|
105
|
+
function missingByLang(resources) {
|
|
48
106
|
const langs = Object.keys(resources).sort();
|
|
49
107
|
if (langs.length < 2)
|
|
50
108
|
return [];
|
|
51
|
-
// 로케일별 키 집합과 전체 union(어느 로케일에든 등장한 키의 합집합)을 만든다.
|
|
52
|
-
// 복수형 접미사는 base 로 정규화해 로케일별 정당한 복수형 차이를 오탐으로 잡지 않는다.
|
|
53
109
|
const keysByLang = new Map();
|
|
54
110
|
const union = new Set();
|
|
55
111
|
for (const lng of langs) {
|
|
@@ -58,12 +114,19 @@ function parityIssues(cwd, localesDir, resources, layout, scope) {
|
|
|
58
114
|
for (const k of keys)
|
|
59
115
|
union.add(k);
|
|
60
116
|
}
|
|
61
|
-
const
|
|
117
|
+
const out = [];
|
|
62
118
|
for (const lng of langs) {
|
|
63
119
|
const have = keysByLang.get(lng);
|
|
64
120
|
const missing = [...union].filter((k) => !have.has(k)).sort();
|
|
65
|
-
if (missing.length
|
|
66
|
-
|
|
121
|
+
if (missing.length > 0)
|
|
122
|
+
out.push([lng, missing]);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/** 한 스코프 안에서 로케일 간 키 diff 를 계산한다(레거시 단일 파일은 backend 스코프에 담긴다). */
|
|
127
|
+
function parityIssues(cwd, localesDir, resources, layout, scope) {
|
|
128
|
+
const issues = [];
|
|
129
|
+
for (const [lng, missing] of missingByLang(resources)) {
|
|
67
130
|
// 파일 경로는 레이아웃에 맞춘다 — 폴더 분리면 <lng>/<scope>.json, 레거시면 <lng>.json.
|
|
68
131
|
const rel = layout[lng] === 'legacy'
|
|
69
132
|
? relative(cwd, join(localesDir, `${lng}.json`))
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
// 파싱해 import 선언만 훑는다. 상대 import 는 실 파일까지 해석하지 않고 경로
|
|
24
24
|
// 접두사(domain/) 로 판정 — 이 검사는 순수성 게이트라 정확도보다 재현성이 우선
|
|
25
25
|
// (false positive 는 오히려 안전).
|
|
26
|
+
//
|
|
27
|
+
// 결정 456: **재수출(`export { X } from '../../domain/...'`)도 값 통로**라 같이 잡는다.
|
|
28
|
+
// 종전엔 `ts.isImportDeclaration` 만 봐서 재수출로 domain 값을 shared 밖으로 흘리는
|
|
29
|
+
// 경로가 조용히 통과했다(순수성 게이트의 구멍). `export * from` 도 같다.
|
|
30
|
+
// 동적 `import()` 는 여전히 대상 밖이다 — 표현식이라 정적 판정이 불안정하고(변수·
|
|
31
|
+
// 템플릿 리터럴), shared 에서 domain 을 동적으로 부르는 것은 관례상 나타나지 않는다.
|
|
32
|
+
// 필요해지면 그때 닫는다(지금 넣으면 오탐 비용이 이득보다 크다).
|
|
26
33
|
import { readdir, readFile } from 'node:fs/promises';
|
|
27
34
|
import { join, relative, resolve, dirname } from 'node:path';
|
|
28
35
|
import ts from 'typescript';
|
|
@@ -61,6 +68,31 @@ function collectImportedNames(node) {
|
|
|
61
68
|
}
|
|
62
69
|
return out;
|
|
63
70
|
}
|
|
71
|
+
/** 재수출 선언에서 이름과 type-only 플래그를 추출한다(`export * from` 은 이름 미상 = 값 통로). */
|
|
72
|
+
function collectExportedNames(node) {
|
|
73
|
+
const clauseTypeOnly = node.isTypeOnly;
|
|
74
|
+
const bindings = node.exportClause;
|
|
75
|
+
if (!bindings) {
|
|
76
|
+
// `export * from '...'` — 무엇이 나가는지 알 수 없다. type-only 표기가 없으면 값 통로로 본다.
|
|
77
|
+
return [{ name: '*', typeOnly: clauseTypeOnly }];
|
|
78
|
+
}
|
|
79
|
+
if (ts.isNamespaceExport(bindings))
|
|
80
|
+
return [{ name: bindings.name.text, typeOnly: clauseTypeOnly }];
|
|
81
|
+
return bindings.elements.map((el) => ({ name: el.name.text, typeOnly: clauseTypeOnly || el.isTypeOnly }));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* import 선언과 **from 절이 있는 재수출 선언**을 같은 모양으로 낸다(결정 456).
|
|
85
|
+
* 둘 다 "다른 모듈의 심볼을 이 파일 경계로 들이는" 값 통로라 순수성 판정이 같다.
|
|
86
|
+
*/
|
|
87
|
+
function asModuleBinding(node) {
|
|
88
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
89
|
+
return { spec: node.moduleSpecifier.text, names: collectImportedNames(node) };
|
|
90
|
+
}
|
|
91
|
+
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
92
|
+
return { spec: node.moduleSpecifier.text, names: collectExportedNames(node) };
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
64
96
|
function isRelativeSpecifier(s) {
|
|
65
97
|
return s.startsWith('./') || s.startsWith('../');
|
|
66
98
|
}
|
|
@@ -92,10 +124,10 @@ export function inspectSharedSource(file, source, cwd) {
|
|
|
92
124
|
const sf = ts.createSourceFile(file, code, ts.ScriptTarget.ES2022, true);
|
|
93
125
|
const baseDir = dirname(file);
|
|
94
126
|
const visit = (node) => {
|
|
95
|
-
|
|
96
|
-
|
|
127
|
+
const decl = asModuleBinding(node);
|
|
128
|
+
if (decl) {
|
|
129
|
+
const { spec, names } = decl;
|
|
97
130
|
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
98
|
-
const names = collectImportedNames(node);
|
|
99
131
|
// 1) 프레임웍의 api/pageProps 를 value import → error
|
|
100
132
|
if (matchesFrameworkModule(spec)) {
|
|
101
133
|
for (const n of names) {
|
|
@@ -141,9 +141,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
141
141
|
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
142
142
|
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). **파티션 키 컬럼이 실제 컬럼인지도 검사**(결정 277 · `checkPartitions` — 오타·유령 컬럼). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`·`checkPartitions`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134·277 · `agents/data.md`)
|
|
143
143
|
26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
|
|
144
|
-
27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(
|
|
144
|
+
27. `locale-parity` — 루트 `locales/` **와 앱 스코프 `apps/<앱>/locales/`** 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(루트) 또는 키 문자열(앱 · 결정 455)을 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing`·`detail.scope` 로 구조화 · 스코프는 `backend`·`frontend`·`app:<앱>`). **앱 카탈로그는 앱 내부에서만 비교**한다 — 앱 전용 키는 루트에 없는 것이 정상이라 교차 비교는 전량 오탐이다. 로케일이 0·1개면 무소음 (결정 216·456 · `agents/i18n.md` §9)
|
|
145
145
|
28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
|
|
146
|
-
29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께),
|
|
146
|
+
29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), **여러 앱이 한 방을 공유**하는 것이 의도면 정의를 `domain/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나 · **등록 파일은 `apps/<앱>/channels/` 에 그대로**). `shared/channels/` 는 정본이 아니다 — authorize 가 DB 를 보면 domain 값 import 가 필요해 `shared-purity`(#6)와 성립 불가이고, `shared/` 는 props 로만 받는 순수 UI 영역이다(§1-3). 옛 배치가 남아 있으면 같은 규칙이 **경고**로 회수한다 — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (결정 456 · `agents/realtime.md` §2.10)
|
|
147
147
|
30. `channel-instance-authorize` — `instance: true` 채널(파라미터화 채널 · 결정 440)에 `authorize` 가 없음 = **경고**. 인스턴스 채널은 임의 문자열 키로 무한 실행 인스턴스(`/gaon/ws/<이름>/<인스턴스>`)가 열리므로, authorize 가 없으면 누구나 아무 인스턴스에나 입장한다. 매치·스레드처럼 참가자가 정해진 채널이면 `authorize(ctx)` 에서 `ctx.instance` 로 입장을 판정하라 — 공개 관전형(누구나 입장)이 의도면 무시해도 된다(재수출 정의는 대상 모듈을 따라가 판정 · `agents/realtime.md` §2.7)
|
|
148
148
|
31. `agents-docs-stale` — 이 문서(`AGENTS.md`)·`agents/*.md` 사본이 **설치된 gaonjs 템플릿(정본)과 다름** = **경고**(byte 비교). gaonjs 업그레이드 후 관례 문서가 옛 채로 남으면 AI 가 낡은 관례·없는 표면으로 코드를 짠다 — `gaon g agents-docs` 로 재동기하라(미리보기 `--check` · 멱등 · 사본에 직접 적은 내용은 프로젝트 소유 문서(CLAUDE.md)로 옮긴 뒤 — 이 문서들은 프레임웍 정본 사본이다) (결정 449)
|
|
149
149
|
32. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)에 `NODE_ENV` 가 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 이 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 가 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 그 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`) — 모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
|
|
@@ -226,7 +226,7 @@ gaon doctor # 정적 검사 35종 (§2.2)
|
|
|
226
226
|
| `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
|
|
227
227
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
228
228
|
| `gaon check` / `test` / `doctor` | 검증 루프 |
|
|
229
|
-
| `gaon console` | 프로젝트 컨텍스트 REPL |
|
|
229
|
+
| `gaon console` | 프로젝트 컨텍스트 REPL — 컨텍스트는 `gaon`·`config`·`apps`·`domain` 넷. **`domain` 은 로드 요약**(`{jobs, listeners, mails}` = **개수**)이지 잡 지도가 아니다 · 모델·잡 핸들은 `await import('./domain/…')` 로 가져온다(top-level await 허용 · 결정 251·456 · `agents/async.md` §6.1) |
|
|
230
230
|
| `gaon jobs` | DLQ 조회·재적재 |
|
|
231
231
|
| `gaon mcp` | 내장 MCP 서버 — 도구 7종: `list_routes`·`get_schema`·`run_migration`·`run_tests`·`read_agent_doc`·`run_check`·`run_doctor` |
|
|
232
232
|
|
|
@@ -411,6 +411,37 @@ drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤
|
|
|
411
411
|
> "스케줄이 안 도는 흔한 원인은 `gaon work` 를 안 띄운 것" 은 이제 **운영에만**
|
|
412
412
|
> 해당하고, 개발에선 `gaon dev` 가 알아서 띄운다.
|
|
413
413
|
|
|
414
|
+
### 6.1 `gaon console` 에서 잡 손으로 발행하기 (결정 456)
|
|
415
|
+
|
|
416
|
+
`gaon console` 은 프로젝트 컨텍스트 REPL 이다(§4.1). 부팅하면서 domain 을 로드하므로
|
|
417
|
+
**잡 이름이 이미 심겨 있고**(파일=등록), `.later()` 를 그 자리에서 부를 수 있다.
|
|
418
|
+
|
|
419
|
+
REPL 컨텍스트에 실제로 들어 있는 키는 넷이다:
|
|
420
|
+
|
|
421
|
+
| 키 | 값 |
|
|
422
|
+
|---|---|
|
|
423
|
+
| `gaon` | 배선된 런타임(`WiredGaon` · DB 커넥션·NATS 등) |
|
|
424
|
+
| `config` | 로드된 `gaon.config.ts` |
|
|
425
|
+
| `apps` | 앱 목록(`{ name, ... }[]`) |
|
|
426
|
+
| `domain` | **로드 요약** — `{ jobs: number, listeners: number, mails: number, schedule? }` |
|
|
427
|
+
|
|
428
|
+
**`domain.jobs` 는 잡 지도가 아니라 개수(number)다.** 부팅 배너의
|
|
429
|
+
`domain(jobs=4 · …)` 와 같은 값이다 — `Object.keys(domain.jobs)` 를 부르면 숫자에
|
|
430
|
+
대고 부르는 것이라 **빈 배열**이 나온다(잡이 없어서가 아니다). 모델·서비스·잡을
|
|
431
|
+
컨텍스트에 자동 노출하지는 않는다(결정 251 — 빈 스텁을 심어 배너로 약속하면 오도라
|
|
432
|
+
아예 심지 않는다). 핸들이 필요하면 **직접 import** 한다(REPL 은 top-level await 허용):
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
// gaon> — 잡 하나를 큐에 넣는다
|
|
436
|
+
const { closeDueAuctions } = await import('./domain/jobs/closeDueAuctions.ts')
|
|
437
|
+
await closeDueAuctions.later({ auctionId: 42n })
|
|
438
|
+
|
|
439
|
+
// 즉시 실행이 아니라 큐 발행이다 — 처리는 `gaon work`(또는 `gaon dev`)가 한다.
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
이름은 console 부팅 때 로더가 이미 심었으므로(파일명 = 잡 이름) 어느 프로세스에서
|
|
443
|
+
꺼내 쓰든 `.later()` 가 같은 큐로 간다. 모델·서비스도 같은 방식으로 가져온다.
|
|
444
|
+
|
|
414
445
|
### 7. 분산 락 (`lock()`) (결정 147)
|
|
415
446
|
|
|
416
447
|
**동시 실행을 막아야 하면 `lock(key, fn)`** — 같은 `key` 에 대해 전
|
|
@@ -535,4 +566,5 @@ async create() {
|
|
|
535
566
|
| 결정 397 | graceful drain 상한 유계화(§6) — 포화 큐의 긴 잡이 소비 루프 종료를 붙들어 `drainTimeoutMs` 가 무효이던 우회 봉합(closers 도 같은 deadline 공유 · 워커·리스너 공통) |
|
|
536
567
|
| 결정 398 | 리스너 크래시 루프 소진 신호(§3) — EVENTS 스트림 MAX_DELIVERIES advisory 백스톱으로 `dropped` 신호(잡 결정 347 동형 · 공유 스트림이라 메시지 삭제는 안 함) |
|
|
537
568
|
| 결정 400 | P3 청소 묶음 — 허브 KV 복원 오염 키 방어(try/continue) · 라인 디코더 완결 초과 라인도 onOverflow(무신호 명령 소실 금지) · MySQL timestamp 모드 함정 문서화 · listDlq 롤링 버퍼(O(limit) 메모리) · advisory dead 는 삭제 성공 워커만 신호 + 삭제 실패 잔류 관측 |
|
|
569
|
+
| **결정 456** | **`gaon console` 컨텍스트 모양 문서화**(§6.1) — 키 4종과 `domain` 이 **로드 요약(개수)** 임을 명기 + `await import()` 로 잡 핸들을 얻어 `.later()` 하는 정본 레시피. 배경: 컨텍스트 모양이 어느 문서에도 없어 `Object.keys(domain.jobs)`(숫자에 대고 부름 → `[]`)를 "잡 맵이 비었다" 로 오독한 실사용(auction). 결정 251(자동 노출 안 함 · 빈 스텁 금지)은 그대로이고, 근거가 코드 주석에만 있던 것을 사용자 문서로 올린다 |
|
|
538
570
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|
|
@@ -131,11 +131,13 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
131
131
|
|
|
132
132
|
### 3. 컬럼 수식어
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
수식어는 빌더마다 **있는 것만** 붙는다 — 아래 §3.1 매트릭스가 정본이다.
|
|
135
|
+
표에 없는 조합은 **컴파일 에러**이니 추측하지 말 것(`gaon check` 가 잡는다 ·
|
|
136
|
+
`gaon db migrate` 를 먼저 돌리면 런타임 TypeError 로 만난다).
|
|
135
137
|
|
|
136
138
|
- `.nullable()` — NULL 허용. Row 타입이 `T | null` 이 되며 insert 에서
|
|
137
139
|
선택적.
|
|
138
|
-
- `.default(v)` — DDL 기본값 (
|
|
140
|
+
- `.default(v)` — DDL 기본값 (E-4 §4.1 · `belongsTo`·`bytea` 제외 — §3.1). `t.id()` /
|
|
139
141
|
`t.timestamps()` / `.default()` 가 붙은 컬럼은 `create()` 입력에서
|
|
140
142
|
선택적이다 (v0.15 §4.2 line 392–395).
|
|
141
143
|
- `.hidden()` — 직렬화 경계(`Serialized<>` · `SerializedOf<>`)에서
|
|
@@ -151,14 +153,61 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
151
153
|
— 단 레코드를 손으로 재구성하거나 JSON 왕복하면 마커가 사라지니 그럴 땐 hidden
|
|
152
154
|
컬럼을 직접 넣지 않는다. `select()`/`distinct(col)` 로 hidden 컬럼을 **명시 선택**한
|
|
153
155
|
좁은 행에도 마커가 실려 직렬화 경계에서 동일하게 제외된다(결정 283).
|
|
154
|
-
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
156
|
+
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4 · `bytea` 제외 — §3.1). `belongsTo` 에도
|
|
157
|
+
붙는다(결정 456) — **1:1 관계**(한 대상당 한 행)를 이걸로 표현한다.
|
|
155
158
|
- `.index()` — 컬럼 레벨 인덱스 (E-4). 옵션 객체로 method·partial 지정 (결정 273):
|
|
156
159
|
- `.index()` — 기본 인덱스. **jsonb 컬럼은 자동 gin**(유연 검색 `@>`·`?` 용 · The One Way), 그 외는 btree.
|
|
157
160
|
- `.index({ using: 'brin' })` — 시계열 정렬 컬럼(예: `createdAt`)에 관례로 BRIN.
|
|
158
161
|
- `.index({ where: "status = 'active'" })` — partial(부분) 인덱스.
|
|
159
162
|
- method 집합 = `btree | gin | brin | gist`. **jsonb→gin 자동은 컬럼 `.index()` 한 곳뿐** —
|
|
160
163
|
brin/gist 는 명시(과유도가 perpetual-diff 를 늘림). **`t.jsonb()` 도 `.index()` 를 가진다**(결정 273 신설).
|
|
161
|
-
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4
|
|
164
|
+
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4 · `enum`·`uuid`·`belongsTo`·`bytea`
|
|
165
|
+
제외 — §3.1).
|
|
166
|
+
|
|
167
|
+
#### 3.1 수식어 × 빌더 매트릭스 (결정 456)
|
|
168
|
+
|
|
169
|
+
✔ = 있음 · — = **없음(컴파일 에러)**.
|
|
170
|
+
|
|
171
|
+
| 빌더 | `.nullable()` | `.default(v)` | `.hidden()` | `.unique()` | `.index()` | `.check()` | `.max(n)` |
|
|
172
|
+
|---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|
|
173
|
+
| `t.string()` · `t.text()` | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | **✔** |
|
|
174
|
+
| `t.boolean()` · `t.bigint()` · `t.id()` | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | — |
|
|
175
|
+
| `t.int()` · `t.smallint()` · `t.float()` · `t.double()` | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | — |
|
|
176
|
+
| `t.decimal(p, s)` | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | — |
|
|
177
|
+
| `t.datetime()` · `t.timestamps()` | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | — |
|
|
178
|
+
| `t.date()` · `t.time()` | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | — |
|
|
179
|
+
| `t.json<T>()` · `t.jsonb<T>()` | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | — |
|
|
180
|
+
| `t.enum([...] as const)` | ✔ | ✔ | ✔ | ✔ | ✔ | **—** | — |
|
|
181
|
+
| `t.uuid()` · `t.uuidPk()` | ✔ | ✔ | ✔ | ✔ | ✔ | **—** | — |
|
|
182
|
+
| **`t.belongsTo('테이블')`** | ✔ | **—** | ✔ | **✔** | ✔ | **—** | — |
|
|
183
|
+
| `t.bytea()` | ✔ | **—** | ✔ | **—** | **—** | **—** | — |
|
|
184
|
+
|
|
185
|
+
빠져 있는 칸의 이유(전부 의도):
|
|
186
|
+
|
|
187
|
+
- **`enum`·`uuid` 의 `.check()`** — `enum` 은 값 목록 자체가 이미 CHECK 를 낳고
|
|
188
|
+
(`text + CHECK` · E-4 (a)), `uuid` 는 타입이 이미 형식을 강제한다.
|
|
189
|
+
- **`belongsTo` 의 `.default()`·`.check()`** — FK 에 리터럴 기본값이나 자유 CHECK 를
|
|
190
|
+
두는 것은 참조 무결성과 어긋난다. 값 제약이 필요하면 대상 테이블에 건다.
|
|
191
|
+
- **`bytea` 의 `.unique()`·`.index()`·`.default()`** — 인라인 바이너리에 제약·인덱스를
|
|
192
|
+
거는 것은 거의 항상 설계 실수다(해시 컬럼을 따로 두고 거기 건다).
|
|
193
|
+
|
|
194
|
+
**1:1 관계는 `belongsTo(...).unique()`** — 복합(2컬럼 이상) 유일성은 컬럼 수식어로
|
|
195
|
+
표현할 수 없으니 아래 테이블 레벨 `unique` 를 쓴다:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
export const orders = table('orders', {
|
|
199
|
+
id: t.id(),
|
|
200
|
+
auctionId: t.belongsTo('auctions').unique(), // 1:1 — 경매당 주문 하나
|
|
201
|
+
buyerId: t.belongsTo('users'),
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// 복합 유일성(경매 × 입찰자 1행)은 테이블 레벨로
|
|
205
|
+
export const watches = table(
|
|
206
|
+
'watches',
|
|
207
|
+
{ id: t.id(), auctionId: t.belongsTo('auctions'), userId: t.belongsTo('users') },
|
|
208
|
+
{ unique: [['auctionId', 'userId']] },
|
|
209
|
+
)
|
|
210
|
+
```
|
|
162
211
|
|
|
163
212
|
**테이블 레벨 복합 제약** (E-4 §4.2 · 인덱스 객체 확장 결정 273):
|
|
164
213
|
|
|
@@ -1265,3 +1314,4 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
1265
1314
|
| 결정 335 | 문서형 커넥션 url·database 둘 다 없으면 부팅 fail-loud — 드라이버 기본 `test` DB 무신호 접속 봉합(§7.1) |
|
|
1266
1315
|
| 결정 336 | 중첩 hidden dot-path 실지원(290 대체) — 중첩 객체·type:{} 서브도큐먼트·서브도큐먼트 배열 · 직렬화 경계 상속 스레딩 · 지원 밖 위치는 fail-loud(§7.1) |
|
|
1267
1316
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
1317
|
+
| **결정 456** | **수식어 × 빌더 매트릭스(§3.1) + `t.belongsTo(...).unique()` 신설** — "모든 타입에 적용" 이라 적고 조합표가 없어 `t.belongsTo('x').unique()` 가 migrate 를 죽였다(auction 실측). 1:1 FK 는 정당한 도메인 요구라 `unique` 는 **열고**, `default`·`check` 는 참조 무결성과 어긋나 **닫는다**(표에 이유까지 명기 · `type-tests/columnModifiers.test-d.ts` 가 매트릭스를 타입으로 고정) |
|
|
@@ -192,6 +192,11 @@ t(key as never) // 없으면 'posts.abc' 가 화면에
|
|
|
192
192
|
컴파일은 통과하고 일본어 사용자만 조용히 fallback 을 본다. `gaon doctor` 의 `locale-parity`
|
|
193
193
|
가 **스코프별로**(backend↔backend · frontend↔frontend) 키 diff 를 계산해 경고한다.
|
|
194
194
|
|
|
195
|
+
**앱 스코프 카탈로그도 대상이다**(결정 456) — `apps/<앱>/locales/<로케일>.json` 은 그 앱
|
|
196
|
+
안에서만 비교한다(스코프 이름 `app:<앱>`). 앱 전용 키는 루트에 없는 것이 정상이라
|
|
197
|
+
루트와 교차 비교하지 않는다. 앱 카탈로그의 누락은 fallback 이 아니라 **키 문자열이 그대로
|
|
198
|
+
화면에 뜬다**(결정 455) — 그래서 정적으로 먼저 잡는다.
|
|
199
|
+
|
|
195
200
|
## 정본 예시
|
|
196
201
|
|
|
197
202
|
### (a) 새 화면 문구 추가 — 앱 전용
|
|
@@ -295,3 +300,4 @@ gaon gen
|
|
|
295
300
|
| 결정 412 · 414 | i18n config 정적 분석 범위·경고 · 생성 키 이스케이프 · `supportedLngs: []` |
|
|
296
301
|
| **결정 455** | 런타임 없는키 = **키 렌더 + 경고**(서버·클라 동일) · 요청/화면을 죽이지 않는다 · 폴백 해석은 유지 · 정적 키는 컴파일에서 차단(2단) |
|
|
297
302
|
| **결정 454** | **클라 `t()`(`gaonjs/vue`) · backend/frontend 폴더 레이아웃 · 두 갈래 타입 · 앱별 카탈로그 청크(immutable·seal 면제) · 공유 prop `locale`(연성 예약) · doctor `i18n-layout`·`i18n-app-scope`** |
|
|
303
|
+
| **결정 456** | **`locale-parity` 앱 스코프 확장**(§9) — `apps/<앱>/locales/` 를 앱 내부에서 비교(스코프 `app:<앱>`). 결정 454 가 정본 배치로 지정한 경로가 커버리지 밖이라 앱 카탈로그의 로케일 누락이 정적으로 안 잡혔다(auction 실측 · 이 문서 정본 예시는 이미 `locale-parity` 를 약속하고 있었다). 루트 교차 비교는 기각(앱 전용 키가 전량 오탐) · `i18n-app-scope` 재사용도 기각(그 검사의 키 집합은 전 로케일 합집합이라 축이 다름) |
|
|
@@ -221,7 +221,7 @@ export default channel({
|
|
|
221
221
|
return false
|
|
222
222
|
}
|
|
223
223
|
if (String(matchId) !== ctx.instance) return false // 비정규 표기("042"·"0x2a"·공백) 거부
|
|
224
|
-
return await MatchPlayer.where(
|
|
224
|
+
return await MatchPlayer.where('matchId', '=', matchId).where('userId', '=', u.id).exists()
|
|
225
225
|
},
|
|
226
226
|
onMessage(ctx, data) {
|
|
227
227
|
// ctx.broadcast 는 자기 인스턴스(match:<id>)로 자동 스코프 — 다른 매치는 못 받는다
|
|
@@ -485,7 +485,7 @@ async authorize(ctx) {
|
|
|
485
485
|
return false
|
|
486
486
|
}
|
|
487
487
|
if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
|
|
488
|
-
return !(await RoomBan.where(
|
|
488
|
+
return !(await RoomBan.where('roomId', '=', roomId).where('userId', '=', u.id).exists())
|
|
489
489
|
}
|
|
490
490
|
```
|
|
491
491
|
|
|
@@ -765,6 +765,63 @@ export function useRoom(roomId: number) {
|
|
|
765
765
|
종전엔 무한 재접속 루프가 완전 무로그라 허브 프로세스 로그에만 흔적이 남았다.
|
|
766
766
|
건강한 연결이 서면 리셋돼 재발 시 다시 경고한다.
|
|
767
767
|
|
|
768
|
+
### 2.10 여러 앱이 **한 방을 공유**할 때 (결정 456)
|
|
769
|
+
|
|
770
|
+
채널 **이름은 전역**이다(§2 · 위 "알려진 함정"). 그래서 두 앱이 같은 이름의 채널을
|
|
771
|
+
각각 정의하면 subject·프레즌스 로스터가 섞이고 두 `authorize` 가 갈린다 — doctor
|
|
772
|
+
`channel-collision` 이 **에러**로 막는다.
|
|
773
|
+
|
|
774
|
+
그런데 "섞이는 것" 자체가 **요구사항**인 경우가 있다: 스토어프론트(web)의 입찰자와
|
|
775
|
+
운영 콘솔(console)의 직원이 **같은 방·같은 로스터**에 있어야 강퇴·입장순 승계·참여자
|
|
776
|
+
목록이 성립한다. 이때 정본은 **정의 하나 + 앱별 재수출**이고, 정의는 `domain/` 에 둔다:
|
|
777
|
+
|
|
778
|
+
```
|
|
779
|
+
domain/channels/auctionRoom.ts ← 정의 하나 = 인가 규칙 하나 (정의 전용 · 로더가 안 훑는다)
|
|
780
|
+
apps/web/channels/auctionRoom.ts → 재수출 (web 에 WS 경로를 연다)
|
|
781
|
+
apps/console/channels/auctionRoom.ts → 재수출 (console 에 WS 경로를 연다)
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
```ts
|
|
785
|
+
// domain/channels/auctionRoom.ts — 정의는 여기 하나뿐이다
|
|
786
|
+
import { channel } from 'gaonjs/async'
|
|
787
|
+
import { AuctionBan } from '../models/AuctionBan.js'
|
|
788
|
+
|
|
789
|
+
export default channel({
|
|
790
|
+
instance: true,
|
|
791
|
+
async authorize(ctx) {
|
|
792
|
+
const u = ctx.user as { id: bigint } | null
|
|
793
|
+
if (!u) return false
|
|
794
|
+
let auctionId: bigint
|
|
795
|
+
try {
|
|
796
|
+
auctionId = BigInt(ctx.instance)
|
|
797
|
+
} catch {
|
|
798
|
+
return false
|
|
799
|
+
}
|
|
800
|
+
if (String(auctionId) !== ctx.instance) return false // 정규형만(§2.7 · 결정 447)
|
|
801
|
+
return !(await AuctionBan.where('auctionId', '=', auctionId).where('userId', '=', u.id).exists())
|
|
802
|
+
},
|
|
803
|
+
})
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
```ts
|
|
807
|
+
// apps/web/channels/auctionRoom.ts · apps/console/channels/auctionRoom.ts — 둘 다 같은 한 줄
|
|
808
|
+
export { default } from '../../../domain/channels/auctionRoom.js'
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
- **등록 파일은 앱에 그대로 둔다.** 파일이 앱 아래 있어야 그 앱에 WS 경로가 열린다
|
|
812
|
+
(`/gaon/ws/…` · console 은 `/console/gaon/ws/…`). 위 "알려진 함정"의 "채널 파일
|
|
813
|
+
위치는 `apps/<앱>/channels/`" 는 이 **등록 파일**을 말한다 — 공유 **정의 모듈**만
|
|
814
|
+
domain 으로 빠진다.
|
|
815
|
+
- **왜 `shared/` 가 아닌가.** `shared/` 는 props 로만 받는 **순수 UI** 영역이고
|
|
816
|
+
(AGENTS §1-3), doctor `shared-purity` 가 domain **값** import 를 에러로 막는다.
|
|
817
|
+
위 예시처럼 `authorize` 가 DB 를 보면 모델을 값으로 import 해야 하므로 shared 에
|
|
818
|
+
두는 순간 성립하지 않는다. 채널 인가는 도메인 규칙이니 domain 이 제자리다.
|
|
819
|
+
- **`domain/channels/` 는 정의 전용이다.** 도메인 로더(`gaon work`)는 `jobs`·
|
|
820
|
+
`listeners`·`events`·`mails`·`schedule.ts` 만 훑으므로 이중 등록이 없다.
|
|
821
|
+
- **의존 방향에도 맞다** — 앱→domain 은 허용된 방향이다(§1-3 ①).
|
|
822
|
+
- 한 앱만 쓰는 채널은 그냥 `apps/<앱>/channels/<이름>.ts` 에 정의한다. 이 우회는
|
|
823
|
+
**여러 앱이 한 방을 공유할 때만** 쓴다.
|
|
824
|
+
|
|
768
825
|
## 정본 예시
|
|
769
826
|
|
|
770
827
|
서버가 먼저 밀어주는 데이터(알림·채팅·접속자)는 채널/프레즌스가 정답
|
|
@@ -809,8 +866,12 @@ export default channel({
|
|
|
809
866
|
|
|
810
867
|
## 알려진 함정
|
|
811
868
|
|
|
812
|
-
- **채널 파일 위치는 `apps/<앱>/channels/`** —
|
|
813
|
-
라우트처럼 앱 경계 안).
|
|
869
|
+
- **채널 *등록* 파일 위치는 `apps/<앱>/channels/`** — 그 파일이 있어야 그 앱에 WS
|
|
870
|
+
경로가 열린다(라우트처럼 앱 경계 안). 한 앱만 쓰는 채널은 정의도 여기 둔다.
|
|
871
|
+
**예외는 하나** — 여러 앱이 **한 방을 공유**할 때만 공유 *정의 모듈*이
|
|
872
|
+
`domain/channels/<이름>.ts` 로 빠지고 앱 파일은 재수출만 한다(§2.10 · 결정 456).
|
|
873
|
+
`shared/channels/` 는 정본이 아니다(shared 는 순수 UI · authorize 의 domain 값
|
|
874
|
+
import 가 `shared-purity` 에 걸린다).
|
|
814
875
|
- **채널 *이름* 은 전역이다 — 앱 소속이 아니다(§2).** 파일이 앱 아래 있다고 이름까지
|
|
815
876
|
앱별로 갈리는 게 아니다: 발화 subject(`gaon.chan.<이름>`)와 프레즌스 키
|
|
816
877
|
(`presence.<이름>.<멤버>`)는 이름만 쓴다. 두 앱에 같은 이름의 채널 파일을 두면
|
|
@@ -818,6 +879,11 @@ export default channel({
|
|
|
818
879
|
이름을 전역 고유로 짓는다(`adminRoom`·`webRoom`).
|
|
819
880
|
- **`presenceInfo` 에 민감 정보 금지** — 접속자 목록은 채널 전원에게
|
|
820
881
|
공개된다. 공개 메타만.
|
|
882
|
+
- **`MemberLeft` 의 `roster` 원소에는 `info` 가 없다** — `{ id, joinSeq? }` 뿐이다
|
|
883
|
+
(§2.9 ⑤). `presenceList()`/`ctx.presence()` 는 `presenceInfo` 결과(`info`)를 주지만
|
|
884
|
+
이탈 이벤트의 로스터는 승계 판정에 필요한 최소 형태다. 승계 리스너에서 멤버의
|
|
885
|
+
속성(직원인지·등급 등)이 필요하면 `id` 로 **DB 를 조회**한다 — 프레즌스 메타는
|
|
886
|
+
권위가 아니다(정본 = DB · §2.9 ④).
|
|
821
887
|
- **raw data 에코는 반정본** — `onMessage(ctx, data) { ctx.broadcast(data) }` 처럼
|
|
822
888
|
클라 페이로드를 통째로 되쏘면 작성자 위조·XSS 표면이 열린다. 본문만 검증·상한해
|
|
823
889
|
취하고, 작성자 같은 신뢰 필드는 **서버 권위 `ctx.user`** 로 붙인 봉투만 broadcast
|
|
@@ -910,6 +976,7 @@ export default channel({
|
|
|
910
976
|
| 결정 446 | `instancesOf(name, { counts: true })`(§2.8) — 인스턴스별 **멤버 수** 동봉(프레즌스 키 단일 스캔 · presenceStats 동형) · `{ meta: true, counts: true }` 조합 지원 · 로비 인원 수 per-인스턴스 `presenceList` N+1 제거 · 기존 시그니처 불변 |
|
|
911
977
|
| 결정 447 | 인스턴스 키 정규형 비교 정본화(§2.7·§2.9 ban) — 숫자 키를 `BigInt()`/`Number()` 로 파싱해 검증하는 authorize 는 `String(id) !== ctx.instance → false` 정규형 가드까지(비정규 표기 "042"·"0x2a" 가 로스터에 안 보이는 유령 인스턴스로 같은 DB row 에 기록을 남기는 우회 차단) · 프레임웍 자동 정규화 기각(키는 앱 정의 불투명 문자열 — uuid·slug 는 파싱 무관) |
|
|
912
978
|
| 결정 448 | CLI 명령 공통 `--help`(`packages/cli`) — `gaon serve --help`·`gaon hub --help` 가 부팅을 시도하던 것을 디스패치 전 공통 처리로 봉합 · `gaon test -- <인자>` 의 `--` 뒤는 패스스루 보존 |
|
|
979
|
+
| **결정 456** | **멀티앱 공유 채널 정본 위치 = `domain/channels/`**(§2.10) — 옛 `shared/channels/` 안내는 `shared-purity`(domain 값 import 금지)와 성립 불가였다(DB 를 보는 authorize = 정본 예시들의 형태). shared-purity 예외 기각(순수성 게이트의 경로 예외는 썩는다) · 등록 파일은 `apps/<앱>/channels/` 재수출 유지 · `domain/channels/` 는 로더 미스캔 정의 전용 · doctor 는 재수출 대상 디렉터리를 안 따지므로 코드 변경 없이 통과하고, 옛 배치는 `channel-collision` 경고로 회수 |
|
|
913
980
|
|
|
914
981
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
915
982
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,19 +28,19 @@
|
|
|
28
28
|
"dist",
|
|
29
29
|
"README.md"
|
|
30
30
|
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""
|
|
33
|
+
},
|
|
31
34
|
"dependencies": {
|
|
35
|
+
"@gaonjs/async": "workspace:*",
|
|
36
|
+
"@gaonjs/config": "workspace:*",
|
|
37
|
+
"@gaonjs/core": "workspace:*",
|
|
38
|
+
"@gaonjs/data": "workspace:*",
|
|
39
|
+
"@gaonjs/i18n": "workspace:*",
|
|
40
|
+
"@gaonjs/mail": "workspace:*",
|
|
41
|
+
"@gaonjs/web": "workspace:*",
|
|
32
42
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
43
|
"typescript": "^5.9.0",
|
|
34
|
-
"vite": "^7.0.0"
|
|
35
|
-
"@gaonjs/async": "0.22.0",
|
|
36
|
-
"@gaonjs/config": "0.25.9",
|
|
37
|
-
"@gaonjs/core": "0.3.0",
|
|
38
|
-
"@gaonjs/i18n": "0.4.1",
|
|
39
|
-
"@gaonjs/mail": "0.5.3",
|
|
40
|
-
"@gaonjs/web": "0.32.0",
|
|
41
|
-
"@gaonjs/data": "0.25.4"
|
|
42
|
-
},
|
|
43
|
-
"scripts": {
|
|
44
|
-
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""
|
|
44
|
+
"vite": "^7.0.0"
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
}
|