@gaonjs/cli 0.5.0 → 0.10.1
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/commands/check.d.ts +21 -2
- package/dist/commands/check.js +70 -7
- package/dist/commands/db.d.ts +3 -1
- package/dist/commands/db.js +8 -2
- package/dist/commands/g.d.ts +1 -1
- package/dist/commands/g.js +27 -3
- package/dist/commands/mcp.d.ts +15 -0
- package/dist/commands/mcp.js +78 -0
- package/dist/db/diff.js +5 -0
- package/dist/db/journal.d.ts +34 -0
- package/dist/db/journal.js +71 -0
- package/dist/db/migrate.d.ts +6 -1
- package/dist/db/migrate.js +120 -102
- package/dist/db/replay.d.ts +49 -0
- package/dist/db/replay.js +148 -0
- package/dist/db/status.d.ts +12 -0
- package/dist/db/status.js +61 -0
- package/dist/dev/index.d.ts +2 -0
- package/dist/dev/index.js +2 -0
- package/dist/dev/vite.d.ts +67 -0
- package/dist/dev/vite.js +126 -0
- package/dist/dev.d.ts +18 -0
- package/dist/dev.js +15 -0
- package/dist/doctor/agents-doc-index.d.ts +4 -0
- package/dist/doctor/agents-doc-index.js +80 -0
- package/dist/doctor/fixers/dependency-direction.d.ts +9 -0
- package/dist/doctor/fixers/dependency-direction.js +98 -0
- package/dist/doctor/fixers/index.d.ts +15 -0
- package/dist/doctor/fixers/index.js +66 -0
- package/dist/doctor/fixers/schema-filename.d.ts +14 -0
- package/dist/doctor/fixers/schema-filename.js +104 -0
- package/dist/doctor/fixers/types.d.ts +59 -0
- package/dist/doctor/fixers/types.js +15 -0
- package/dist/doctor/schema-filename.d.ts +6 -0
- package/dist/doctor/schema-filename.js +81 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +49 -0
- package/dist/doctor.js +179 -5
- package/dist/generate.js +2 -2
- package/dist/hub.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +50 -10
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.js +7 -0
- package/dist/mcp/server.d.ts +50 -0
- package/dist/mcp/server.js +102 -0
- package/dist/mcp/tools.d.ts +109 -0
- package/dist/mcp/tools.js +485 -0
- package/dist/scaffold/app.d.ts +5 -0
- package/dist/scaffold/app.js +172 -0
- package/dist/scaffold/controller.js +2 -2
- package/dist/scaffold/index.d.ts +2 -1
- package/dist/scaffold/index.js +2 -1
- package/dist/scaffold/job.d.ts +5 -0
- package/dist/scaffold/job.js +35 -0
- package/dist/scaffold/model.js +8 -8
- package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
- package/dist/templates/auth/registration.controller.ts.tpl +1 -1
- package/dist/templates/auth/session.controller.ts.tpl +1 -1
- package/dist/templates/auth/user.model.ts.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +214 -0
- package/dist/templates/project/agents/async.md.tpl +218 -0
- package/dist/templates/project/agents/data.md.tpl +556 -0
- package/dist/templates/project/agents/frontend.md.tpl +201 -0
- package/dist/templates/project/agents/realtime.md.tpl +157 -0
- package/dist/templates/project/agents/security.md.tpl +92 -0
- package/dist/templates/project/agents/testing.md.tpl +101 -0
- package/dist/templates/project/agents/web.md.tpl +177 -0
- package/dist/templates/project/apps/web/index.html.tpl +18 -0
- package/dist/templates/project/apps/web/main.ts.tpl +24 -0
- package/dist/templates/project/package.json.tpl +5 -2
- package/dist/templates/project/vite.config.ts.tpl +23 -0
- package/dist/tsResolve.js +1 -1
- package/dist/work.d.ts +2 -2
- package/dist/work.js +3 -1
- package/package.json +13 -11
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.d.ts +0 -12
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +0 -7
- package/dist/__fixtures__/db-minimal/gaon.config.d.ts +0 -2
- package/dist/__fixtures__/db-minimal/gaon.config.js +0 -11
- package/dist/check.d.ts +0 -29
- package/dist/check.js +0 -92
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# agents/data.md — 데이터 레이어 (스키마 · 모델 · 관계 · 쿼리 · 서비스 · 마이그레이션)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
|
|
5
|
+
> 대상 패키지: `@gaonjs/data` (파사드 import 는 `gaonjs/data` · `gaonjs/service`).
|
|
6
|
+
|
|
7
|
+
## 정본 규칙
|
|
8
|
+
|
|
9
|
+
### 1. 스키마 정의 (v0.15 §4.2)
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// domain/schema/posts.ts — 스키마 파일명 = 테이블명 (루트 §네이밍)
|
|
13
|
+
import { table, t } from 'gaonjs/data'
|
|
14
|
+
|
|
15
|
+
export const posts = table('posts', {
|
|
16
|
+
id: t.id(), // bigint PK, 자동
|
|
17
|
+
title: t.string().max(200),
|
|
18
|
+
body: t.text(),
|
|
19
|
+
published: t.boolean().default(false),
|
|
20
|
+
authorId: t.belongsTo('users'), // FK + 관계를 한 줄로
|
|
21
|
+
...t.timestamps(), // createdAt, updatedAt
|
|
22
|
+
})
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**컬럼 정의가 곧 DB 타입 + TS 타입 + 검증 규칙의 단일 원천**이다
|
|
26
|
+
(v0.15 §4.2 line 388 원문). 세 곳에 따로 쓰지 않는다.
|
|
27
|
+
|
|
28
|
+
### 1.1 관계 선언 (M2D · 결정 33)
|
|
29
|
+
|
|
30
|
+
관계는 **두 자리**에 나뉘어 산다 — FK 컬럼을 **가진 쪽**은 컬럼으로,
|
|
31
|
+
**갖지 않는 쪽**(역방향)은 `table()` 3번째 인자의 `relations` 로 쓴다.
|
|
32
|
+
역방향은 자기 테이블에 컬럼을 만들지 않으므로 컬럼 자리에 둘 수 없다.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// domain/schema/posts.ts
|
|
36
|
+
import { table, t, hasMany, hasOne, belongsToMany } from 'gaonjs/data'
|
|
37
|
+
|
|
38
|
+
export const posts = table('posts', {
|
|
39
|
+
id: t.id(),
|
|
40
|
+
title: t.string().max(200),
|
|
41
|
+
authorId: t.belongsTo('users'), // 정방향(FK 보유) = 컬럼
|
|
42
|
+
...t.timestamps(),
|
|
43
|
+
}, {
|
|
44
|
+
relations: {
|
|
45
|
+
comments: hasMany('comments'), // 1:N → comments.postId
|
|
46
|
+
cover: hasOne('covers'), // 1:1 → covers.postId
|
|
47
|
+
tags: belongsToMany('tags'), // N:M → posts_tags(postId, tagId)
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**대상은 언제나 문자열 테이블명**이다 — 모델을 넘기지 않는다. 모델끼리
|
|
53
|
+
import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레임웍이 한다
|
|
54
|
+
(정본 §4.4 "관계는 지연 정의").
|
|
55
|
+
|
|
56
|
+
| 선언 | 시그니처 | 레코드 접근 | 반환 |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `t.belongsTo(table)` | `(table)` — **컬럼** | `await post.author()` | `Row` (단건) |
|
|
59
|
+
| `hasMany(target, opts?)` | `(target, { foreignKey? })` | `await post.comments()` | `Row[]` |
|
|
60
|
+
| `hasOne(target, opts?)` | `(target, { foreignKey? })` | `await post.cover()` | `Row \| undefined` |
|
|
61
|
+
| `belongsToMany(target, opts?)` | `(target, { through?, foreignKey?, otherKey? })` | `await post.tags()` | `Row[]` |
|
|
62
|
+
|
|
63
|
+
**기본값 관례** (생략 시 · 명시 지정이 항상 이긴다):
|
|
64
|
+
|
|
65
|
+
| 옵션 | 기본값 | 예 |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `foreignKey` | 단수형(**자기** 테이블) + `Id` | `posts` → `postId` |
|
|
68
|
+
| `otherKey` | 단수형(**대상** 테이블) + `Id` | `tags` → `tagId` |
|
|
69
|
+
| `through` | 두 테이블명을 **사전순**으로 결합 | `posts`+`tags` → `posts_tags` |
|
|
70
|
+
|
|
71
|
+
- 관례에 맞지 않으면 **명시한다** — 예: `posts.authorId` 를 가리키는
|
|
72
|
+
`users` 쪽 선언은 `hasMany('posts', { foreignKey: 'authorId' })`.
|
|
73
|
+
- 조인 테이블은 **평범한 테이블**로 따로 정의한다
|
|
74
|
+
(`table('posts_tags', { id: t.id(), postId: t.belongsTo('posts'), tagId: t.belongsTo('tags') })`).
|
|
75
|
+
`belongsToMany` 가 테이블을 만들어 주지는 않는다.
|
|
76
|
+
- doctor 가 **대상·조인 테이블의 존재**와 **커넥션 경계**(§7)를
|
|
77
|
+
검사한다 (`unknown-relation-target` · `cross-connection-relation`).
|
|
78
|
+
|
|
79
|
+
### 1.2 DB 네이밍 (결정 43)
|
|
80
|
+
|
|
81
|
+
테이블명·컬럼명·파일명 사이의 표기와 변환은 아래가 전부다 — 추측하지 않는다.
|
|
82
|
+
|
|
83
|
+
| 대상 | 표기 | 예시 |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| 테이블명 (`table('...')` 문자열) | 소문자 · **복수** · 다단어 `snake_case` | `posts` · `posts_tags` |
|
|
86
|
+
| 컬럼명 (스키마 키 · Row 타입) | **camelCase** | `title` · `authorId` · `createdAt` |
|
|
87
|
+
| FK 컬럼 | 단수 테이블 + `Id` | `posts` → `postId` · `tags` → `tagId` |
|
|
88
|
+
| 타임스탬프 | `createdAt` · `updatedAt` (`...t.timestamps()`) | — |
|
|
89
|
+
| 스키마 파일명 | 테이블명을 **camelCase** 로 | `posts.ts` · `posts_tags` → `postsTags.ts` |
|
|
90
|
+
| export 심볼 | 파일명과 같은 camelCase | `export const postsTags = table('posts_tags', …)` |
|
|
91
|
+
| 모델 파일·심볼 | **PascalCase 단수** | `domain/models/Post.ts` · `export const Post` |
|
|
92
|
+
| `tables.d.ts` 키 · DB 실 테이블 | **테이블명 그대로**(snake_case) | `posts_tags: RowOf<typeof postsTags>` |
|
|
93
|
+
|
|
94
|
+
**변환 규칙은 하나** — 다단어 테이블 `posts_tags` 는:
|
|
95
|
+
|
|
96
|
+
- 스키마 **파일명**·**export 심볼** = camelCase `postsTags`,
|
|
97
|
+
- `table()` 문자열 · `tables.d.ts` 키 · DB 테이블 = snake_case `posts_tags`.
|
|
98
|
+
|
|
99
|
+
컬럼은 어느 경우에도 camelCase 다(스네이크 컬럼 리터럴 금지 · `createdAt`
|
|
100
|
+
이지 `created_at` 아님). 파일명 casing 은 doctor `schema-filename` 이
|
|
101
|
+
강제한다(단수/복수는 대상 밖 · 결정 38).
|
|
102
|
+
|
|
103
|
+
### 2. 컬럼 타입 전체 (실 구현 · `packages/data/src/schema.ts:394-445`)
|
|
104
|
+
|
|
105
|
+
| 빌더 | SQL 타입 | TS 타입 | 비고 |
|
|
106
|
+
|---|---|---|---|
|
|
107
|
+
| `t.id()` | `bigserial` | `bigint` | PK · insert 선택적 |
|
|
108
|
+
| `t.string()` | `varchar` | `string` | `.max(n)` 로 길이 |
|
|
109
|
+
| `t.text()` | `text` | `string` | 긴 문자열 |
|
|
110
|
+
| `t.boolean()` | `boolean` | `boolean` | |
|
|
111
|
+
| `t.bigint()` | `bigint` | `bigint` | |
|
|
112
|
+
| `t.datetime()` | `timestamptz` | `Date` | 타임존 포함 |
|
|
113
|
+
| `t.belongsTo('users')` | `bigint` | `bigint` | FK + 관계 자동 파생 |
|
|
114
|
+
| `t.timestamps()` | `timestamptz × 2` | — | createdAt·updatedAt |
|
|
115
|
+
| `t.decimal(p, s)` | `numeric(p,s)` | `string` | 정확성 우선 (E-4 (b)) |
|
|
116
|
+
| `t.enum([...] as const)` | `text + CHECK` | union type | E-4 (a) |
|
|
117
|
+
| `t.uuid()` | `uuid` | `string` | |
|
|
118
|
+
| `t.uuidPk()` | `uuid` | `string` | PK · `gen_random_uuid()` (E-4 (d)) |
|
|
119
|
+
| `t.json<T>()` / `t.jsonb<T>()` | `jsonb` | `T` (제네릭) | Postgres 권장 = jsonb |
|
|
120
|
+
| `t.int()` | `integer` | `number` | 4바이트 정수 |
|
|
121
|
+
| `t.smallint()` | `smallint` | `number` | 2바이트 정수 |
|
|
122
|
+
| `t.float()` | `real` | `number` | 4바이트 부동소수 |
|
|
123
|
+
| `t.double()` | `double precision` | `number` | 8바이트 부동소수 |
|
|
124
|
+
| `t.date()` | `date` | `string` (ISO 8601) | E-4 (c) |
|
|
125
|
+
| `t.time()` | `time` | `string` (`HH:MM:SS`) | E-4 (c) |
|
|
126
|
+
| `t.bytea()` | `bytea` | `Uint8Array` | 인라인 바이너리 |
|
|
127
|
+
|
|
128
|
+
### 3. 컬럼 수식어
|
|
129
|
+
|
|
130
|
+
모든 타입에 적용되는 수식어(빌더가 지원하는 조합만):
|
|
131
|
+
|
|
132
|
+
- `.nullable()` — NULL 허용. Row 타입이 `T | null` 이 되며 insert 에서
|
|
133
|
+
선택적.
|
|
134
|
+
- `.default(v)` — DDL 기본값 (모든 타입에 확장 · E-4 §4.1). `t.id()` /
|
|
135
|
+
`t.timestamps()` / `.default()` 가 붙은 컬럼은 `create()` 입력에서
|
|
136
|
+
선택적이다 (v0.15 §4.2 line 392–395).
|
|
137
|
+
- `.hidden()` — 직렬화 경계(`Serialized<>` · `SerializedOf<>`)에서
|
|
138
|
+
**타입 레벨로** 제외 (모든 타입에 확장). 페이지 props 에서 접근하면
|
|
139
|
+
컴파일 에러 — 유출이 타입 시스템에서 막힌다 (v0.15 §4.2 line
|
|
140
|
+
396–399 원문). 비밀번호 다이제스트가 대표: `passwordDigest: t.string().hidden()`.
|
|
141
|
+
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
142
|
+
- `.index()` — 컬럼 레벨 인덱스 (E-4).
|
|
143
|
+
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4).
|
|
144
|
+
|
|
145
|
+
**테이블 레벨 복합 제약** (E-4 §4.2):
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
export const posts = table('posts', {
|
|
149
|
+
slug: t.string().max(100),
|
|
150
|
+
ownerId: t.belongsTo('users'),
|
|
151
|
+
...t.timestamps(),
|
|
152
|
+
}, {
|
|
153
|
+
db: 'main', // 커넥션 키 (§7)
|
|
154
|
+
unique: [['ownerId', 'slug']], // 복합 unique
|
|
155
|
+
index: [['ownerId', 'createdAt']], // 복합 index
|
|
156
|
+
check: [['positive_age', 'age > 0']], // [name, expr]
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 4. 체이닝 전체 (`packages/data/src/model.ts:136-338`)
|
|
161
|
+
|
|
162
|
+
체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
|
|
163
|
+
(`destroy`·`findBy`·`paginate`·`order`·해시 인자
|
|
164
|
+
`where({...})` 같은 다른 ORM 관습)를 추측해서 쓰지 말 것 — 표
|
|
165
|
+
바깥의 쿼리는 §5 `Post.query()` 탈출구로 내려간다.
|
|
166
|
+
|
|
167
|
+
**where op 12종** (`model.ts:61-71`):
|
|
168
|
+
|
|
169
|
+
| 분류 | op | val 인자 |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| 비교 (`WhereOp`) | `=` `!=` `>` `<` `>=` `<=` `like` `ilike` | `Row[col]` 1개 |
|
|
172
|
+
| 컬렉션 (`WhereOpIn`) | `in` `not in` | `ReadonlyArray<Row[col]>` |
|
|
173
|
+
| Null (`WhereOpNull`) | `is null` `is not null` | **없음** — 2-인자 호출 `where('deletedAt', 'is null')` |
|
|
174
|
+
|
|
175
|
+
**체이닝 메서드 전체** (`Chain` · `model.ts:79-109`):
|
|
176
|
+
|
|
177
|
+
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
178
|
+
|---|---|---|---|
|
|
179
|
+
| `where` | `(col, op, val?)` | `Chain` | op 에 따라 val 형태 강제 (위 표) |
|
|
180
|
+
| `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약 |
|
|
181
|
+
| `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습 · `model.ts:245-262`) |
|
|
182
|
+
| `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) |
|
|
183
|
+
| `reorder` | `(col, dir?)` | `Chain` | 기존 정렬 전부 버리고 재지정 |
|
|
184
|
+
| `latest` | `()` | `Chain` | `orderBy('createdAt', 'desc')` 고정 축약 |
|
|
185
|
+
| `limit` | `(n)` | `Chain` | |
|
|
186
|
+
| `offset` | `(n)` | `Chain` | 페이지네이션 = `orderBy·offset·limit` 조합 |
|
|
187
|
+
| `first` | `()` | `Promise<Rec \| undefined>` | 자동 `limit 1` |
|
|
188
|
+
| `all` | `()` | `Promise<Rec[]>` | |
|
|
189
|
+
| `count` | `()` | `Promise<bigint>` | driver 별 반환을 **bigint 로 통일** (E-4 (g) · `model.ts:390`) |
|
|
190
|
+
| `exists` | `()` | `Promise<boolean>` | |
|
|
191
|
+
| `sum` · `avg` | `(col)` | `Promise<string \| null>` | numeric 정확성 · 대상 행 없으면 null |
|
|
192
|
+
| `min` · `max` | `(col)` | `Promise<Row[col] \| null>` | 컬럼 타입 그대로 |
|
|
193
|
+
| `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
|
|
194
|
+
| `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
|
|
195
|
+
| `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
|
|
196
|
+
| `updateAll` | `(patch)` | `Promise<bigint>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수 반환. limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
|
|
197
|
+
| `deleteAll` | `()` | `Promise<bigint>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
|
|
198
|
+
|
|
199
|
+
**집계·조인 그룹** (`Chain` · M2E · 결정 34):
|
|
200
|
+
|
|
201
|
+
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
202
|
+
|---|---|---|---|
|
|
203
|
+
| `groupBy` | `(col \| col[])` | `GroupChain` | 그룹 집계로 **분기** — 종단은 집계 함수 하나(`count`/`sum`/`avg`/`min`/`max`)이고 결과는 `Rec[]` 이 아니라 **`그룹 키 + 집계값` 행 배열**(`GroupRow[]`). `Post.groupBy('authorId').count()` → `{ authorId, count: bigint }[]` |
|
|
204
|
+
| `having` | `('count', op, val)` · `('sum'\|'avg'\|'min'\|'max', col, op, val)` | `GroupChain` | groupBy 뒤 **집계값** 필터 (그룹 키 필터는 `where`). `.having('count', '>', 2)` · `.having('sum', 'price', '>=', 1000)` |
|
|
205
|
+
| `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(집계·벌크 쓰기 이어짐), 컬럼을 주면 그 컬럼만 뽑는 `SelectChain`. `distinct().count()` 는 `count(distinct id)` |
|
|
206
|
+
| `withCount` | `(...rels)` | `IncludedChain` | 관계별 개수를 **상관 서브쿼리**로 얹는다 — `withCount('comments')` → 각 Rec 에 `commentsCount: bigint`. 조인이 아니라 행이 안 늘어 `limit` 과 함께 써도 개수가 정확. **hasMany·hasOne·belongsToMany 만**(belongsTo 는 항상 0/1 이라 throw). `include` 와 같은 체인에 실린다(`include('author').withCount('comments')`) |
|
|
207
|
+
| `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`). 조인 테이블 조건은 한정 이름(`where('users.name', '=', ...)`), 1:N 부풀림은 `distinct()` 로 접는다. `join`/`leftJoin`·`where`·`orderBy`·`distinct`·`select`·`pluck`·`count`·`exists`·`first`·`all` 이어짐 |
|
|
208
|
+
| `leftJoin` | `(table, 'table.col', 'self.col')` | `JoinChain` | LEFT OUTER JOIN — 짝 없는 자기 행도 남는다. "짝 없는 것만" = `.where('posts.id', 'is null')` |
|
|
209
|
+
|
|
210
|
+
> 조인 노출은 정본 결정 28 게이트 (f)(원안 = 기각·`Post.query()` 로만)를
|
|
211
|
+
> **재결정**한 결과다 (결정 34 · 2026-07-24 PM 승인). `withCount` 가 관계명
|
|
212
|
+
> 기반이라 조인 없이 조인 파워를 주고, `join`/`leftJoin` 은 반환을 자기
|
|
213
|
+
> 테이블 Rec 으로 좁혀 "정답이 하나" 원칙을 지킨다 — 조인 테이블 컬럼까지
|
|
214
|
+
> 필요한 SELECT 는 여전히 `Post.query()` 탈출구다.
|
|
215
|
+
|
|
216
|
+
**루트 전용** (`ModelApi` · `model.ts:148-171`) — 체인 중간에서는 못 쓴다:
|
|
217
|
+
|
|
218
|
+
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
219
|
+
|---|---|---|---|
|
|
220
|
+
| `create` | `(data)` | `Promise<Rec>` | `t.id()`·`t.timestamps()`·`.default()` 컬럼은 입력에서 선택적 (`InsertOf`) |
|
|
221
|
+
| `batchInsert` | `(rows)` | `Promise<Rec[]>` | **벌크 삽입** (M2F · 결정 35) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · 넣은 순서대로 Rec 반환. 빈 배열은 DB 무접촉 `[]`. beforeCreate 훅은 각 row 에 적용 |
|
|
222
|
+
| `insertOrIgnore` | `(row \| rows)` | `Promise<Rec[]>` | **멱등 삽입** (M2F) — `ON CONFLICT DO NOTHING` · UNIQUE/PK 충돌 행은 건너뛰고 **실제로 삽입된 Rec 만** 반환. 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
|
|
223
|
+
| `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<Rec[]>` | **있으면 갱신 없으면 삽입** (M2F) — `ON CONFLICT DO UPDATE`. `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 `excluded` 로 덮음 · 갱신 대상 없으면 DO NOTHING |
|
|
224
|
+
| `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
|
|
225
|
+
| `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
|
|
226
|
+
|
|
227
|
+
> 벌크 삽입 3종(`batchInsert`·`insertOrIgnore`·`upsert`)은 **RETURNING 을
|
|
228
|
+
> 지원하는 커넥션(Postgres)** 에서만 된다 — 삽입/무시/갱신된 행 집합을
|
|
229
|
+
> 정확히 복원할 수 있는 방언이 RETURNING 뿐이라, 비 RETURNING 커넥션에서는
|
|
230
|
+
> 어림하지 않고 수리 안내와 함께 **throw** 한다(결정 35 · §7.5.3). 삽입 계열은
|
|
231
|
+
> `create` 처럼 루트 전용 — `where` 필터와 무관하므로 체인 종단이 아니다.
|
|
232
|
+
|
|
233
|
+
**레코드(`Rec`) 내장** (`model.ts:47-52`):
|
|
234
|
+
|
|
235
|
+
- `rec.update(patch)` — `Partial<Row>` 부분 갱신, 갱신된 Rec 반환.
|
|
236
|
+
- `rec.delete()` — id 기준 단건 삭제, `Promise<void>` (M2C). 벌크는
|
|
237
|
+
체인 `deleteAll()`. `destroy()` 아님 — TS 생태계 관례(Prisma
|
|
238
|
+
`delete`·Kysely `deleteFrom`) 정합 (결정 31).
|
|
239
|
+
- 관계 메서드 — 선언(§1.1)에서 자동 파생, lazy 1회 조회:
|
|
240
|
+
`authorId: t.belongsTo('users')` → `await post.author()` /
|
|
241
|
+
`comments: hasMany('comments')` → `await post.comments()`.
|
|
242
|
+
**`include()` 로 로드하면 같은 이름이 값이 된다** — `post.comments`
|
|
243
|
+
(배열). 함수가 아니라 값이라 페이지 props 로 그대로 직렬화된다
|
|
244
|
+
(§6 `Serialized<T>` 는 함수 값을 떨군다).
|
|
245
|
+
- 사용자 메서드 (§8 `methods`) — `this` = Rec 으로 바인딩.
|
|
246
|
+
|
|
247
|
+
**체인 상태 전이 주의** (`model.ts:112-146`):
|
|
248
|
+
|
|
249
|
+
- `select()`·`include()` 이후에도 빌더 메서드(`where`·`orWhere`·
|
|
250
|
+
`whereIn`·`orderBy`·`reorder`·`latest`·`limit`·`offset`)는 전부
|
|
251
|
+
이어진다 (M2C 패리티) — `Post.include('author').latest().all()` 도 됨.
|
|
252
|
+
- 단 **스칼라 집계(count 등)·pluck·벌크 쓰기(updateAll/deleteAll)·
|
|
253
|
+
스코프·`groupBy`·`join`/`leftJoin` 은 `Chain` 전용** — select·include
|
|
254
|
+
**앞**에서 끝낸다. `withCount` 는 예외로 `include` 와 같은 자리에 실린다.
|
|
255
|
+
- `groupBy` 이후는 `GroupChain` — 결과가 그룹 행이라 `first`/`all` 대신
|
|
256
|
+
집계 함수가 종단이고, 레코드가 아니라 `include`·`select` 도 없다.
|
|
257
|
+
- `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라
|
|
258
|
+
`include`·집계 그룹은 없고 스칼라 집계·`select`(자기 컬럼)·`pluck` 만.
|
|
259
|
+
- `select()` 이후엔 `include` 도 없다 (부분 행에 관계를 붙이지 않는다).
|
|
260
|
+
- 스코프(§8)는 `Chain` 의 어느 지점에서든 재호출 가능
|
|
261
|
+
(`Post.where(...).published()` 도 됨).
|
|
262
|
+
|
|
263
|
+
### 5. `Post.query()` = Kysely 원본 (탈출구)
|
|
264
|
+
|
|
265
|
+
**정본 §4.4 line 448–449 원문:**
|
|
266
|
+
> "…내부 구현은 Kysely 위에 얹으므로, 복잡한 쿼리는 언제든
|
|
267
|
+
> `Post.query()`로 내려가 순수 쿼리 빌더를 쓸 수 있다(탈출구)."
|
|
268
|
+
|
|
269
|
+
실 구현 (E-4 (h) 정정 · `model.ts:165, 590-593`):
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// 조인·집계·CTE 등 복잡 쿼리
|
|
273
|
+
const rows = await Post.query()
|
|
274
|
+
.innerJoin('users', 'users.id', 'posts.authorId')
|
|
275
|
+
.select(['posts.title', 'users.email'])
|
|
276
|
+
.execute()
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
반환 타입 = Kysely `SelectQueryBuilder<any, any, {}>` (원본). Chain
|
|
280
|
+
좁게 유지 + 조인 등 복잡 쿼리는 여기서 처리 (E-4 (f) 결정).
|
|
281
|
+
|
|
282
|
+
### 6. `Serialized<T>` (vue) vs `SerializedOf<Defs>` (data) — **별개 타입**
|
|
283
|
+
|
|
284
|
+
두 타입은 이름이 비슷하지만 **별개**다 (E-4 §7).
|
|
285
|
+
|
|
286
|
+
- `packages/vue/src/serialize.ts:28` — **`Serialized<T>`**: 임의 값의
|
|
287
|
+
JSON-safe 매핑 (Date → string, bigint → string, Hidden 브랜드 제외).
|
|
288
|
+
`api()` 반환 타입 · `pageProps<'ctrl#action'>()` 결과 타입이 이것.
|
|
289
|
+
- `packages/data/src/schema.ts:544` — **`SerializedOf<Defs>`**: 테이블
|
|
290
|
+
스키마 defs 의 SerializedOf — hidden 컬럼 키 제외한 Row.
|
|
291
|
+
|
|
292
|
+
혼동 유발이라 이름 분리를 유지한다 (E-4 (i) 결정).
|
|
293
|
+
|
|
294
|
+
### 7. 멀티 DB 커넥션 (v0.15 §4.5)
|
|
295
|
+
|
|
296
|
+
- **키 생략 = main** — 기본 경로는 단일 DB 프로젝트와 완전히 같다.
|
|
297
|
+
- **커넥션을 가로지르는 `belongsTo` 는 금지** — doctor 의
|
|
298
|
+
**connections** 검사가 잡는다.
|
|
299
|
+
- **`service()` 트랜잭션은 단일 커넥션에서만 원자적** — 다중 커넥션
|
|
300
|
+
접근 시 doctor 경고.
|
|
301
|
+
- 마이그레이션은 커넥션별: `gaon db diff --db legacy`.
|
|
302
|
+
|
|
303
|
+
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts:601-622`)
|
|
304
|
+
|
|
305
|
+
`model()` 은 스키마(§1)를 Kysely 위의 실행 가능한 API 로 감싼다 —
|
|
306
|
+
scope·인스턴스 메서드를 함께 선언하고, 나머지 체이닝(§4)·CRUD 는
|
|
307
|
+
런타임이 자동 생성한다.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
// domain/models/Post.ts
|
|
311
|
+
import { model } from 'gaonjs/data'
|
|
312
|
+
import { posts } from '../schema/posts.js'
|
|
313
|
+
|
|
314
|
+
export const Post = model(posts, {
|
|
315
|
+
scopes: {
|
|
316
|
+
published: (q) => q.where('published', '=', true),
|
|
317
|
+
recent: (q) => q.latest().limit(10),
|
|
318
|
+
// 파라미터 스코프 (M2C) — q 뒤 인자가 호출 시그니처가 된다
|
|
319
|
+
byAuthor: (q, authorId: bigint) => q.where('authorId', '=', authorId),
|
|
320
|
+
},
|
|
321
|
+
methods: {
|
|
322
|
+
// 인스턴스 메서드 — this 는 레코드(Rec) 타입으로 추론된다
|
|
323
|
+
async publish() {
|
|
324
|
+
return await this.update({ published: true })
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
hooks: {
|
|
328
|
+
beforeCreate(data) {
|
|
329
|
+
// insert 직전 데이터 보정 (정규화·파생값 등)
|
|
330
|
+
data.title = data.title?.trim()
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
// 사용 — scope·체이닝·CRUD 가 그대로 이어진다
|
|
338
|
+
const items = await Post.published().latest().limit(20).all()
|
|
339
|
+
const both = await Post.published().recent().all() // 스코프 조합
|
|
340
|
+
const mine = await Post.byAuthor(user.id).count() // 파라미터 스코프
|
|
341
|
+
const post = await Post.create({ title: '제목', body: '...', authorId: user.id })
|
|
342
|
+
await post.publish() // 인스턴스 메서드
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- **두 번째 인자** = `{ scopes?, methods?, hooks? }`. `scopes` 값은
|
|
346
|
+
`(q) => q.where(...)` 형태로 쿼리를 좁히는 함수, `methods` 는 인스턴스
|
|
347
|
+
메서드(`this` 로 레코드 필드·관계에 접근), `hooks` 는 `beforeCreate`
|
|
348
|
+
하나 (`model.ts:610`).
|
|
349
|
+
- **스코프는 반드시 `scopes: {}` 객체 안에** 선언한다 (NAMESPACED) —
|
|
350
|
+
model() 밖 별도 함수·프로퍼티로 흉내내지 않는다.
|
|
351
|
+
- **파라미터 스코프** (M2C · 결정 31) — 첫 인자 `q` 는 고정, 그 뒤
|
|
352
|
+
인자는 자유이며 **타입을 명시**한다 (`(q, authorId: bigint) => ...`).
|
|
353
|
+
q 뒤 인자가 그대로 호출 시그니처가 된다: `Post.byAuthor(1n)`.
|
|
354
|
+
무인자 스코프를 `Post.published(true)` 처럼 인자 호출하면 타입 에러.
|
|
355
|
+
- **`Post.query()`** 로 언제든 Kysely 원본으로 내려갈 수 있다 (§5).
|
|
356
|
+
- **관계는 `model()` 이 아니라 스키마에 선언한다** (§1.1) — `model()`
|
|
357
|
+
두 번째 인자에 `relations` 자리는 없다. 선언한 관계는 scope·method 와
|
|
358
|
+
자유롭게 조합된다: scope 는 관계 로드 **전에** 체인을 좁히고
|
|
359
|
+
(`Post.published().include('comments')`), method 의 `this` 에서는
|
|
360
|
+
관계를 그대로 호출한다.
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
export const Post = model(posts, {
|
|
364
|
+
scopes: {
|
|
365
|
+
// 스코프는 컬럼만 다룬다 — 관계 조건이 필요하면 Post.query() 탈출구(§5)
|
|
366
|
+
published: (q) => q.where('published', '=', true),
|
|
367
|
+
},
|
|
368
|
+
methods: {
|
|
369
|
+
// this = Rec — 스키마에 선언한 관계가 그대로 붙어 있다
|
|
370
|
+
async commentCount() {
|
|
371
|
+
return (await this.comments()).length
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### 9. 서비스 (`service()`) — 트랜잭션 작업 흐름 (정본 §5.3 · `packages/data/src/service.ts`)
|
|
378
|
+
|
|
379
|
+
로직 배치의 One Way 규칙은 루트 `AGENTS.md` 판단표가 정본이다 (정본 §5.3):
|
|
380
|
+
한 모델 안 로직 → 모델 메서드 · 여러 모델/외부 API/트랜잭션 → `domain/services/` ·
|
|
381
|
+
컨트롤러는 HTTP 번역만.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
// domain/services/publishPost.ts — 파일명 camelCase (루트 §네이밍)
|
|
385
|
+
import { service, afterCommit } from 'gaonjs/service'
|
|
386
|
+
import { Post } from '../models/Post.js'
|
|
387
|
+
|
|
388
|
+
export const PublishPost = service(async (postId: bigint) => {
|
|
389
|
+
const post = await Post.find(postId)
|
|
390
|
+
const published = await post.update({ published: true })
|
|
391
|
+
afterCommit(async () => { /* 커밋 성공 뒤에만 — 이벤트·잡 발행 지점 */ })
|
|
392
|
+
return published
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
// 컨트롤러에서:
|
|
396
|
+
// const post = await PublishPost.call(this.params('id'))
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
- **시그니처** — `service(handler, options?)` → `{ call(...args) }`.
|
|
400
|
+
`call()` 의 인자·반환 타입은 핸들러 정의에서 그대로 추론된다
|
|
401
|
+
(제네릭 인자를 직접 붙이지 않는다).
|
|
402
|
+
- **트랜잭션** — 본문 전체가 하나의 DB 트랜잭션. 중간 실패 시 전부
|
|
403
|
+
롤백. 본문 안의 모델 호출은 코드 변경 없이 트랜잭션에 합류한다
|
|
404
|
+
(AsyncLocalStorage 전파). `{ transaction: false }` 로 해제.
|
|
405
|
+
- **커넥션** — `{ db: '키' }` (생략 = main). 트랜잭션은 **단일
|
|
406
|
+
커넥션에서만 원자적** (§7) — 다른 키의 조회는 트랜잭션 밖에서
|
|
407
|
+
돌고, doctor 의 **connections** 검사가 다중 커넥션 접근을 경고한다.
|
|
408
|
+
- **중첩** — 같은 커넥션 키의 서비스가 서비스를 부르면 바깥
|
|
409
|
+
트랜잭션에 합류한다 (중첩 BEGIN 없음 — 전체가 한 단위).
|
|
410
|
+
- **`afterCommit(fn)`** — 커밋 성공 뒤에만 실행 (롤백 시 실행 안 됨).
|
|
411
|
+
잡·이벤트 발행처럼 "DB 확정 후에만 나가야 하는" 부수효과를 여기 둔다.
|
|
412
|
+
service() 본문 밖에서 부르면 에러.
|
|
413
|
+
- **파사드는 `gaonjs/service`** — `@gaonjs/data` (스코프) 로 import
|
|
414
|
+
하지 않는다.
|
|
415
|
+
|
|
416
|
+
### 10. 마이그레이션 — 파일 리플레이 + 스키마 diff (합성형 · 결정 39)
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
gaon db diff # 스키마(domain/schema/*.ts) ↔ 실제 DB 차이 미리보기 (적용 X)
|
|
420
|
+
gaon db migrate # db/migrations/*.ts replay → 스키마 diff 적용 + _gaon_migrations 이력
|
|
421
|
+
gaon db migrate down # 가장 최근 이력 1건 롤백
|
|
422
|
+
gaon db status # 마이그레이션 파일 적용/대기 + 스키마 drift
|
|
423
|
+
gaon db reset --yes # 초기화 (dev · production 거부)
|
|
424
|
+
gaon db seed # domain/seed.ts 실행
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**기본은 스키마 우선이다.** `domain/schema/*.ts` 를 고치고 `gaon db migrate`
|
|
428
|
+
하면 diff 가 차이를 계산해 반영한다. 여기에 **손작성 마이그레이션 파일**이
|
|
429
|
+
1급으로 합쳐진다(결정 39 · 합성형): `migrate` 는 ① `db/migrations/*.ts` 를
|
|
430
|
+
**파일명 순으로 실제 실행**(replay)하고, ② 그 뒤 스키마 diff 로 나머지를
|
|
431
|
+
적용한다.
|
|
432
|
+
|
|
433
|
+
- **손작성 마이그는 `db/migrations/<파일명>.ts`** 에 두고 `up(db)`·`down(db)`
|
|
434
|
+
(Kysely) 를 export 한다. **파일명이 곧 버전 키** — `0001_add_posts.ts`
|
|
435
|
+
시퀀스든 `20260724_add_posts.ts` 타임스탬프든 사전순 정렬이 안정적이면 된다
|
|
436
|
+
(naming = `timestamp_action`). `down()` 은 롤백(`migrate down`)용 —
|
|
437
|
+
없으면 그 파일은 롤백할 수 없다(migrate down 이 §7.5.3 안내로 알림).
|
|
438
|
+
```ts
|
|
439
|
+
import type { Kysely } from 'kysely'
|
|
440
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
441
|
+
await db.schema.alterTable('posts').addColumn('pinned', 'boolean', (c) => c.notNull().defaultTo(false)).execute()
|
|
442
|
+
}
|
|
443
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
444
|
+
await db.schema.alterTable('posts').dropColumn('pinned').execute()
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
- **데이터 이전(백필)·복잡한 수동 DDL** 처럼 스키마 diff 로 표현할 수 없는
|
|
448
|
+
변경은 이 손작성 파일로 둔다 — 이제 migrate 가 **실제로 실행**한다(재현·
|
|
449
|
+
리뷰 가능). 이력(`_gaon_migrations`)에 파일별 한 행이 남아 재실행 시
|
|
450
|
+
건너뛴다(중복 방지).
|
|
451
|
+
- **migrate 는 스키마에 없는 테이블을 자동 DROP 하지 않는다**(replay·외부
|
|
452
|
+
테이블 보호 · 자동 apply 의 데이터 손실 배제). 테이블 제거는 손작성 마이그의
|
|
453
|
+
`down`(또는 명시적 마이그)으로 한다. `gaon db diff` 는 dropTable 을 계속
|
|
454
|
+
미리 보여주고, migrate 는 건너뛴 테이블을 크게 알린다(조용한 무시 방지).
|
|
455
|
+
- 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
|
|
456
|
+
|
|
457
|
+
## 정본 예시
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
// 목록 + 필터 + 정렬 — 대표 패턴
|
|
461
|
+
const posts = await Post
|
|
462
|
+
.where('published', '=', true)
|
|
463
|
+
.where('authorId', 'in', [1n, 2n, 3n])
|
|
464
|
+
.orderBy('createdAt', 'desc')
|
|
465
|
+
.limit(20)
|
|
466
|
+
.all()
|
|
467
|
+
|
|
468
|
+
// 페이지네이션 (2페이지 · 20개씩)
|
|
469
|
+
const page2 = await Post.latest().offset(20).limit(20).all()
|
|
470
|
+
|
|
471
|
+
// 부분 문자열 검색 · OR 조합
|
|
472
|
+
const found = await Post.where('title', 'like', '%gaon%').first()
|
|
473
|
+
const mine = await Post.where('published', '=', true).orWhere('authorId', '=', me.id).all()
|
|
474
|
+
|
|
475
|
+
// 집계
|
|
476
|
+
const total = await Post.where('published', '=', true).count() // bigint
|
|
477
|
+
const hasAny = await Post.where('authorId', '=', me.id).exists() // boolean
|
|
478
|
+
const views = await Post.sum('viewCount') // string | null (숫자 컬럼)
|
|
479
|
+
|
|
480
|
+
// eager load — include 는 체인 마지막에 (N+1 방지) · 관계 4종 모두
|
|
481
|
+
const withAuthor = await Post.published().latest().limit(20).include('author').all()
|
|
482
|
+
withAuthor[0].author // 로드된 행 (include 시 함수가 아니라 값)
|
|
483
|
+
|
|
484
|
+
// 역방향도 같은 자리 — 여러 관계를 한 번에, 관계당 쿼리 1회
|
|
485
|
+
const feed = await Post.published().include('author', 'comments', 'tags').limit(20).all()
|
|
486
|
+
feed[0].comments[0].body // hasMany → 배열
|
|
487
|
+
feed[0].tags.map((t) => t.name) // belongsToMany → 배열 (피벗 조인 1회)
|
|
488
|
+
|
|
489
|
+
// 관계 개수 — withCount 는 조인이 아니라 서브쿼리라 limit 과 함께 정확
|
|
490
|
+
const list = await Post.include('author').withCount('comments').latest().limit(20).all()
|
|
491
|
+
list[0].commentsCount // bigint · list[0].author 와 한 체인에
|
|
492
|
+
|
|
493
|
+
// 그룹 집계 — 결과는 Rec 이 아니라 `그룹 키 + 집계값` 행
|
|
494
|
+
const byAuthor = await Post.groupBy('authorId').orderBy('count', 'desc').count()
|
|
495
|
+
byAuthor[0] // { authorId: bigint, count: bigint }
|
|
496
|
+
const active = await Post.groupBy('authorId').having('count', '>', 5).count()
|
|
497
|
+
|
|
498
|
+
// 조인 — 필터·정렬 수단, 반환은 자기(posts) Rec (users 컬럼은 안 실림)
|
|
499
|
+
const teamPosts = await Post
|
|
500
|
+
.join('users', 'users.id', 'posts.authorId')
|
|
501
|
+
.where('users.name', '=', 'alice')
|
|
502
|
+
.distinct()
|
|
503
|
+
.all()
|
|
504
|
+
// users.email 까지 뽑아야 하면 → §5 Post.query() 탈출구
|
|
505
|
+
|
|
506
|
+
// 지연 호출 — 한 건만 필요할 때 (loop 안에서 쓰면 N+1 · doctor 가 잡는다)
|
|
507
|
+
const post = await Post.find(id)
|
|
508
|
+
const comments = await post.comments() // Row[]
|
|
509
|
+
const cover = await post.cover() // Row | undefined (hasOne)
|
|
510
|
+
|
|
511
|
+
// 부분 컬럼
|
|
512
|
+
const titles = await Post.pluck('title') // string[]
|
|
513
|
+
const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'title'>[]
|
|
514
|
+
|
|
515
|
+
// 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
|
|
516
|
+
const post = await Post.find(id)
|
|
517
|
+
await post.delete()
|
|
518
|
+
const removed = await Post.where('published', '=', false).deleteAll() // bigint
|
|
519
|
+
const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
|
|
520
|
+
|
|
521
|
+
// 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35)
|
|
522
|
+
const seeded = await Post.batchInsert(rows) // 단일 INSERT 문 · Rec[] (순서 보존)
|
|
523
|
+
const fresh = await Post.insertOrIgnore(rows) // 충돌 건너뜀 · 실 삽입만
|
|
524
|
+
await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
|
|
525
|
+
await Post.upsert({ id, title, body }) // onConflict 생략 = 기본 키(id)
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## 알려진 함정
|
|
529
|
+
|
|
530
|
+
- **체이닝 표 밖 메서드 추측 금지** — `destroy`·`findBy`·`paginate`·
|
|
531
|
+
`order`·해시 인자 `where({...})` 는 없다. 단건 삭제는 `rec.delete()`,
|
|
532
|
+
벌크는 `deleteAll()` (결정 31).
|
|
533
|
+
- **`find(id)` 는 없으면 throw** — undefined 를 원하면
|
|
534
|
+
`where('id', '=', id).first()`.
|
|
535
|
+
- **관계 대상은 문자열 테이블명** — 모델 객체를 넘기면 순환 참조.
|
|
536
|
+
- **불규칙 복수**(`people`·`media` 등)는 단수화 관례가 못 잡는다 —
|
|
537
|
+
`foreignKey`/`otherKey` 를 명시한다.
|
|
538
|
+
- **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
|
|
539
|
+
행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
|
|
540
|
+
- **loop 안 관계 lazy 호출 = N+1** — doctor **n-plus-one** 검사가 잡는다.
|
|
541
|
+
목록은 `include()` 로.
|
|
542
|
+
- **스키마 파일명은 camelCase** — 테이블 `posts_tags` → 파일 `postsTags.ts`
|
|
543
|
+
(파일 안 `table('posts_tags', …)` 문자열은 스네이크 그대로).
|
|
544
|
+
- **잡·이벤트 발행을 트랜잭션과 정합시키려면** `afterCommit()`(service 안)
|
|
545
|
+
또는 아웃박스(`agents/async.md`) — 커밋 전 발행은 롤백 시 유령 부수효과.
|
|
546
|
+
|
|
547
|
+
## 관련 결정 번호
|
|
548
|
+
|
|
549
|
+
| 결정 | 내용 |
|
|
550
|
+
|---|---|
|
|
551
|
+
| 결정 31 | `rec.delete()` 명명(destroy 아님) · 파라미터 스코프 (M2C) |
|
|
552
|
+
| 결정 33 | 관계 선언 위치(컬럼 + relations) · 문자열 테이블명 (M2D) |
|
|
553
|
+
| 결정 34 | 집계·조인 그룹(groupBy·having·distinct·withCount·join) (M2E) |
|
|
554
|
+
| 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) |
|
|
555
|
+
| 결정 39 | 마이그레이션 합성형(파일 replay → 스키마 diff · no-auto-drop) |
|
|
556
|
+
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|