mega-framework 0.1.11 → 0.1.14
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/package.json +1 -1
- package/sample/crud/docs/guide/03-service-model-db.md +62 -6
- package/sample/crud/docs/guide/11-data-assembly.md +174 -0
- package/src/core/index.js +2 -0
- package/src/core/mega-service.js +81 -0
- package/src/core/service-helpers/attach.js +197 -0
- package/src/core/service-helpers/enrich.js +79 -0
- package/src/core/service-helpers/index.js +13 -0
- package/src/core/service-helpers/parallel.js +49 -0
- package/src/core/service-helpers/pool.js +73 -0
- package/src/index.js +2 -0
- package/templates/model/code-mongo.tpl +1 -9
- package/templates/model/code.tpl +1 -10
- package/templates/model/test-mongo.tpl +1 -23
- package/templates/model/test.tpl +0 -17
- package/types/core/index.d.ts +1 -0
- package/types/core/mega-service.d.ts +81 -13
- package/types/core/service-helpers/attach.d.ts +52 -0
- package/types/core/service-helpers/enrich.d.ts +21 -0
- package/types/core/service-helpers/index.d.ts +4 -0
- package/types/core/service-helpers/parallel.d.ts +10 -0
- package/types/core/service-helpers/pool.d.ts +34 -0
- package/types/index.d.ts +1 -1
package/package.json
CHANGED
|
@@ -802,12 +802,68 @@ export class User extends MegaModel {
|
|
|
802
802
|
|
|
803
803
|
- **옵트인**이다 — `static schema` 가 없는 모델(레거시 raw SQL 운용)은 그냥 무시된다.
|
|
804
804
|
- 같은 `static schema` 선언은 **공통 CRUD([§2-1](#2-1-공통-crud-static-schema-opt-in-adr-212))도 함께 켠다**(SQL 어댑터) — "스키마 1선언 = 마이그레이션 + CRUD".
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
805
|
+
|
|
806
|
+
#### 컬럼 타입 — `t.<type>()`
|
|
807
|
+
|
|
808
|
+
각 타입 메서드는 `ColumnBuilder` 를 반환하므로 아래 **수식어를 체인**할 수 있다(`src/core/migration/schema-builder.js`).
|
|
809
|
+
|
|
810
|
+
| 분류 | 메서드 | 비고 |
|
|
811
|
+
| --- | --- | --- |
|
|
812
|
+
| 정수 | `t.serial()` · `t.bigSerial()` | 자동증가(postgres SERIAL / BIGSERIAL) |
|
|
813
|
+
| 정수 | `t.integer()` · `t.bigInteger()` · `t.smallInteger()` | |
|
|
814
|
+
| 실수 | `t.real()` · `t.doublePrecision()` | |
|
|
815
|
+
| 정밀수 | `t.decimal(precision, scale)` | NUMERIC(p,s) |
|
|
816
|
+
| 문자열 | `t.varchar(maxLength)` · `t.char(length)` · `t.text()` | |
|
|
817
|
+
| 논리 | `t.boolean()` | |
|
|
818
|
+
| 시간 | `t.timestamp()` · `t.timestamptz()` · `t.date()` · `t.time()` | |
|
|
819
|
+
| 기타 | `t.uuid()` · `t.json()` · `t.jsonb()` · `t.bytea()` | |
|
|
820
|
+
| 열거 | `t.enum(values, { name? })` | postgres 는 `TEXT + CHECK (col IN (...))` 로 렌더(native enum 미사용 — 값 추가/삭제가 제약 교체로 단순) |
|
|
821
|
+
| **mongo 전용** | `t.objectId()` · `t.object(shape?)` · `t.array(items?, { uniqueItems? })` | SQL dialect 는 렌더 시 거부. `object`/`array` 의 중첩 필드는 타입·길이·enum·notNull(→required)만 — unique/primary/references/check 는 **최상위 전용** |
|
|
822
|
+
|
|
823
|
+
#### 컬럼 수식어 (체인) — `ColumnBuilder`
|
|
824
|
+
|
|
825
|
+
| 메서드 | 뜻 |
|
|
826
|
+
| --- | --- |
|
|
827
|
+
| `.primary()` | 단일 컬럼 PRIMARY KEY. 복합 PK 는 `t.primary(['a','b'])`(아래) |
|
|
828
|
+
| `.notNull()` | NOT NULL |
|
|
829
|
+
| `.nullable()` | 명시 null 허용. SQL 은 기본 nullable 이라 선언적 표시(no-op), mongo 는 `['type','null']` 유니온. `.notNull()` 과 **동시 사용 불가** |
|
|
830
|
+
| `.unique({ name? })` | UNIQUE 제약. 이름 미지정 시 `uniq_<table>_<col>` |
|
|
831
|
+
| `.default(value)` | DEFAULT. literal(string/number/boolean/null) 또는 `{ raw: 'expr' }`(raw SQL 식) |
|
|
832
|
+
| `.defaultNow()` | `DEFAULT CURRENT_TIMESTAMP` 단축 |
|
|
833
|
+
| `.check(expr, { name? })` | CHECK 제약. 이름 미지정 시 `chk_<table>_<col>` |
|
|
834
|
+
| `.references(model, col, { onDelete?, onUpdate?, name? })` | FK. 대상은 **모델 이름**(테이블명 아님 — 스냅샷 시 해석). `onDelete`/`onUpdate` ∈ `cascade · set null · set default · restrict · no action` |
|
|
835
|
+
| `.comment(text)` | `COMMENT ON COLUMN` |
|
|
836
|
+
|
|
837
|
+
- 테이블 레벨: `t.primary(['a','b'])` — **복합 PRIMARY KEY**(반환값 없음, 컬럼 맵엔 안 들어감). 단일 PK 는 컬럼 체인 `.primary()`.
|
|
838
|
+
- FK 대상 모델은 **file-scan 범위 안에 있어야** 한다(없으면 fail-fast). 관계는 FK 까지만 — 조인 헬퍼/lazy load 같은 ORM 기능은 없다(ADR-009).
|
|
839
|
+
|
|
840
|
+
#### 인덱스 — `static indexes = (t) => [ t.index(...) ]`
|
|
841
|
+
|
|
842
|
+
```js
|
|
843
|
+
static indexes = (t) => [
|
|
844
|
+
t.index(['role', 'createdAt']), // 복합. 이름 자동: idx_<table>_<col…> (= idx_users_role_createdAt)
|
|
845
|
+
t.index(['email'], { unique: true }), // unique 인덱스
|
|
846
|
+
t.index({ expression: 'lower(email)' }, { name: 'idx_users_email_lower' }), // 표현식 인덱스
|
|
847
|
+
t.index(['org_id'], { where: 'deleted_at IS NULL' }), // 부분 인덱스(조건부)
|
|
848
|
+
t.index(['payload'], { using: 'gin' }), // 인덱스 방법(postgres USING)
|
|
849
|
+
]
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
| `t.index(컬럼들|식, opts)` 인자 | 뜻 |
|
|
853
|
+
| --- | --- |
|
|
854
|
+
| 1번째: `'col'` / `['a','b']` / `{ expression: 'lower(x)' }` | 단일·복합 컬럼 또는 표현식 인덱스 |
|
|
855
|
+
| `opts.name` | 인덱스 이름 override(기본 dialect 표준명) |
|
|
856
|
+
| `opts.unique` | `true` 면 UNIQUE 인덱스 |
|
|
857
|
+
| `opts.where` | **부분 인덱스** WHERE 조건(예: `'deleted_at IS NULL'`) |
|
|
858
|
+
| `opts.using` | 인덱스 방법(postgres `USING` — 예: `gin`/`gist`/`hash`) |
|
|
859
|
+
|
|
860
|
+
> 컬럼에 직접 `.unique()` 를 거는 것(=UNIQUE **제약**)과 `t.index([...], { unique: true })`(=UNIQUE **인덱스**)는
|
|
861
|
+
> 비슷하나, 후자는 복합·표현식·부분 인덱스를 지원한다. 단일 컬럼 유일성은 `.unique()` 가 간단하다.
|
|
862
|
+
|
|
863
|
+
**자동 이름 규칙**(postgres dialect, `{ name }` 미지정 시): 인덱스 `idx_<table>_<cols>` · UNIQUE 인덱스/제약
|
|
864
|
+
`uniq_<table>_<cols>` · FK `fk_<table>_<col>_<reftable>` · CHECK `chk_<table>_<col>`. 63byte 초과 시 거부되니
|
|
865
|
+
길면 `{ name }` 으로 짧게 지정한다.
|
|
866
|
+
|
|
811
867
|
- 제외 규칙: `_` 로 시작하는 파일/폴더, `*.test.js`, `static skip = true`.
|
|
812
868
|
- 식별자는 63byte(postgres 한도)를 넘으면 거부된다 — 합성 이름(`fk_…`)이 걸리면 `{name}` 으로
|
|
813
869
|
짧은 이름을 명시하면 된다.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# 11. 데이터 조립 — attachMany / attachOne / enrich / parallel
|
|
2
|
+
|
|
3
|
+
여러 테이블·컬렉션을 묶은 "데이터셋"을 만들 때 쓰는 **MegaService 헬퍼 4종**입니다(ADR-230).
|
|
4
|
+
|
|
5
|
+
프레임워크에는 ORM·조인이 **일부러 없습니다**(ADR-009). 쿼리는 여러분이 모델을 import 해 직접 작성하고,
|
|
6
|
+
헬퍼는 **머지·캐시·동시성**의 기계적인 일만 합니다. 그래서:
|
|
7
|
+
|
|
8
|
+
- 쿼리 자유도 100% (원하는 `findMany`/`query` 를 그대로 씀)
|
|
9
|
+
- **dialect 무관** — 공통 CRUD `findMany` 가 배열을 SQL `IN` · mongo `$in` 양쪽으로 바꿔주므로, 부모는
|
|
10
|
+
Postgres·자식은 MongoDB 처럼 **서로 다른 저장소를 섞어도** 동작합니다(`$lookup` 으론 불가능한 조합).
|
|
11
|
+
- 라우트/컨트롤러는 모델을 직접 import 할 수 없습니다(ADR-022). 이 조립은 **Service 안에서** 합니다.
|
|
12
|
+
|
|
13
|
+
> 헬퍼는 `this.attachMany(...)` 처럼 **서비스 인스턴스 메서드**입니다. 서비스 밖에서 순수 함수가 필요하면
|
|
14
|
+
> `import { attachMany } from 'mega-framework'` 로도 쓸 수 있지만(캐시는 직접 어댑터 주입), 보통은
|
|
15
|
+
> 서비스 메서드를 씁니다.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 어떤 걸 언제 쓰나
|
|
20
|
+
|
|
21
|
+
| 상황 | 메서드 | 쿼리 수 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| 부모마다 자식 **여러 개** 붙이기 (글쓴이 → 글들) | `attachMany` (1:N) | **1 + 1**(배치) |
|
|
24
|
+
| 부모마다 부모(역참조) **하나** 붙이기 (글 → 글쓴이) | `attachOne` (N:1) | **1 + 1**(배치) |
|
|
25
|
+
| 부모마다 **조건이 달라** 배치로 못 묶음 | `enrich` (per-row) | 1 + N ⚠ |
|
|
26
|
+
| 여러 독립 작업을 **동시에** 실행 | `parallel` | 동시 |
|
|
27
|
+
|
|
28
|
+
큰 배열에는 **`attachMany`/`attachOne` 를 우선** 쓰세요. `enrich` 는 편하지만 본질적으로 **N+1** 입니다
|
|
29
|
+
(아래 경고 참고).
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1) attachMany — 1:N 배치 머지
|
|
34
|
+
|
|
35
|
+
`parent[parentKey='id']` ↔ `child[fk]`. 부모마다 매칭되는 자식 **배열**을 `as` 속성으로 붙입니다.
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// apps/main/services/users-service.js
|
|
39
|
+
import { MegaService } from 'mega-framework'
|
|
40
|
+
import { User } from '../models/user-model.js'
|
|
41
|
+
import { Post } from '../models/post-model.js'
|
|
42
|
+
|
|
43
|
+
export class UsersService extends MegaService {
|
|
44
|
+
async activeWithPosts() {
|
|
45
|
+
const users = await User.findMany({ active: true }) // 부모 1쿼리
|
|
46
|
+
await this.attachMany(users, Post, 'userId', 'posts') // 자식 1쿼리(배치 IN)
|
|
47
|
+
return users // [{ id, name, posts: [ {..}, {..} ] }, ...] 매칭 없으면 posts: []
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
옵션 `{ where, select, orderBy, limit, parentKey, cache, fetch }`:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
await this.attachMany(users, Post, 'userId', 'posts', {
|
|
56
|
+
where: { published: true }, // 자식 추가 조건
|
|
57
|
+
select: ['id', 'title'], // 조인 키(userId)는 자동 포함됨
|
|
58
|
+
orderBy: { createdAt: 'desc' },
|
|
59
|
+
limit: 100, // ⚠ 자식 "전체" 상한 (부모당 아님 — 아래 한계)
|
|
60
|
+
parentKey: 'id', // 부모 매칭 컬럼(기본 id)
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 2) attachOne — N:1 배치 머지
|
|
67
|
+
|
|
68
|
+
`parent[fk]` ↔ `child[childKey='id']`. 부모마다 매칭 자식 **하나**(없으면 `null`)를 붙입니다.
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
const posts = await Post.findMany({ published: true })
|
|
72
|
+
await this.attachOne(posts, User, 'userId', 'author') // post.userId ↔ user.id
|
|
73
|
+
// posts: [{ id, title, userId, author: { id, name } | null }, ...]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
부모 쪽 키는 위치 인자 `fk` 로 고정됩니다. 자식 쪽 컬럼이 `id` 가 아니면 `opts.childKey` 로 바꿉니다:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
await this.attachOne(rows, Org, 'orgCode', 'org', { childKey: 'code' }) // row.orgCode ↔ org.code
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 3) 깊은 조인 — attach 를 이어 붙이기
|
|
85
|
+
|
|
86
|
+
`attachMany`/`attachOne` 는 부모 배열을 제자리에서 채우고 그대로 돌려주므로, **여러 단계로 이어** 쓰면 됩니다.
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
const users = await User.findMany({ active: true })
|
|
90
|
+
await this.attachMany(users, Post, 'userId', 'posts') // user.posts[]
|
|
91
|
+
|
|
92
|
+
// posts 들을 평탄화해서 다음 단계의 부모로
|
|
93
|
+
const allPosts = users.flatMap((u) => u.posts)
|
|
94
|
+
await this.attachMany(allPosts, Comment, 'postId', 'comments') // post.comments[]
|
|
95
|
+
// → user → posts → comments (총 3쿼리, 행 수와 무관)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 4) 캐시 — hit 시 쿼리 0
|
|
101
|
+
|
|
102
|
+
`opts.cache = { key, ttl?, bucket }` 를 주면 자식 결과를 한 키로 캐시합니다. **키는 직접 지정**하세요
|
|
103
|
+
(자동 해시는 stale·충돌 위험이라 채택하지 않았습니다).
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
await this.attachMany(users, Post, 'userId', 'posts', {
|
|
107
|
+
cache: { bucket: 'demo', key: 'posts:active', ttl: 30 }, // hit 시 자식 쿼리 skip
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- 무효화는 직접: `await this.ctx.cache('demo').del('posts:active')`
|
|
112
|
+
- ⚠ redis 캐시는 JSON 직렬화라 `ObjectId`/`Date` 가 **문자열**이 됩니다. HTTP 응답엔 무방하지만, **캐시된
|
|
113
|
+
값을 다시 쿼리 입력으로 쓰지 마세요**.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 5) cross-dialect — 저장소를 섞기
|
|
118
|
+
|
|
119
|
+
헬퍼는 plain object 만 다루므로 부모·자식이 다른 DB 여도 됩니다.
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
const users = await User.findMany({ active: true }) // Postgres
|
|
123
|
+
await this.attachMany(users, MongoActivity, 'userId', 'activity') // MongoDB, 같은 코드
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
> mongo `_id` 처럼 `ObjectId` 키도 안전합니다 — 헬퍼가 비교 시 `String()` 으로 정규화합니다(인스턴스가
|
|
127
|
+
> 달라도 같은 값이면 매칭).
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 6) enrich — per-row 보강 (⚠ N+1)
|
|
132
|
+
|
|
133
|
+
부모마다 **조건이 달라** 배치로 못 묶을 때만 씁니다. 각 행에 콜백을 적용합니다.
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
await this.enrich(users, async (u) => {
|
|
137
|
+
u.unread = await Notification.count({ userId: u.id, read: false }) // 행마다 1쿼리
|
|
138
|
+
}, 5) // 동시 5개까지
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- 두 번째 인자가 숫자면 **concurrency**. 객체면 `{ concurrency: 10, settled, cache }`.
|
|
142
|
+
- 콜백이 값을 반환하면 그 값으로 행을 교체하고, `undefined` 면 mutate 된 원본을 유지합니다.
|
|
143
|
+
- **`concurrency` 는 동시성만 줄입니다 — 쿼리 수(N+1)는 그대로**. 큰 배열엔 `attachMany` 를 먼저 검토하세요.
|
|
144
|
+
- `settled: true` 면 일부 행이 실패해도 진행하고(해당 행 원본 유지) 실패는 `log.warn` 으로 남깁니다.
|
|
145
|
+
- 캐시: `{ cache: { key: (row) => `user:${row.id}`, ttl: 60, bucket: 'demo' } }` — 행별 키, hit 시 콜백 skip.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## 7) parallel — 독립 작업 동시 실행
|
|
150
|
+
|
|
151
|
+
`Promise.all`/`allSettled` + 동시성 제한. thunk(`() => Promise`)의 **객체/배열**을 받습니다.
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
const { user, orders, stats } = await this.parallel({
|
|
155
|
+
user: () => User.findById(id),
|
|
156
|
+
orders: () => Order.findMany({ userId: id }),
|
|
157
|
+
stats: () => this.services.metrics.summary(id),
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- 배열도 됩니다: `const [a, b] = await this.parallel([() => fa(), () => fb()])`
|
|
162
|
+
- `this.parallel(specs, 4)` — 동시 4개로 제한(기본 무제한).
|
|
163
|
+
- `{ settled: true }` — 결과가 `{ status, value | reason }` 로 감싸져 모두 완주합니다.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 한계 (꼭 알아두기)
|
|
168
|
+
|
|
169
|
+
- **부모당 N개(per-parent limit)는 불가**: `attachMany` 의 `limit` 은 자식 **전체** 상한이지 "부모마다 최신
|
|
170
|
+
3개"가 아닙니다. 부모별 top-N 은 window 함수가 필요해 배치 1쿼리로는 cross-dialect 가 안 됩니다. 정말
|
|
171
|
+
필요하면 `enrich` 로 부모별 쿼리(N+1)를 받아들이세요.
|
|
172
|
+
- **트랜잭션**: 헬퍼는 트랜잭션을 열지 않습니다. 부모·자식 쿼리 사이의 동시 쓰기로 불일치가 생길 수 있고,
|
|
173
|
+
일관성이 필요하면 **단일 어댑터**일 때 `Model.withTransaction` 으로 감싸세요(cross-store 는 불가, ADR-010).
|
|
174
|
+
- **캐시 직렬화**: 위 4) 참고 — 캐시된 값은 응답용이지 재쿼리 입력용이 아닙니다.
|
package/src/core/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export { MegaWsPresence } from './ws-presence.js'
|
|
|
5
5
|
export { MegaServer } from './mega-server.js'
|
|
6
6
|
export { Router, MegaRouteError } from './router.js'
|
|
7
7
|
export { MegaService } from './mega-service.js'
|
|
8
|
+
// 데이터 조립 순수 함수 (ADR-230) — 보통은 MegaService 메서드를 쓰지만, 서비스 밖에서도 직접 호출 가능.
|
|
9
|
+
export { attachMany, attachOne, enrich, parallel } from './service-helpers/index.js'
|
|
8
10
|
export { MegaCluster } from './mega-cluster.js'
|
|
9
11
|
export { loadRoutes } from './routes-loader.js'
|
|
10
12
|
// 중앙 부팅 orchestrator (ADR-123)
|
package/src/core/mega-service.js
CHANGED
|
@@ -12,7 +12,19 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 자동 DI(ADR-148): `apps/<app>/services/<name>-service.js` 를 부팅 시 로드해 두면 핸들러·다른 서비스가
|
|
14
14
|
* `ctx.services.<name>` / `this.services.<name>` 로 요청별 인스턴스를 받는다(첫 접근 시 lazy 생성·요청 내 캐시).
|
|
15
|
+
*
|
|
16
|
+
* 데이터 조립(ADR-230): `attachMany`/`attachOne`(배치 머지) · `enrich`(per-row) · `parallel`(동시 실행)
|
|
17
|
+
* 4종 헬퍼를 제공한다. 쿼리는 사용자가 모델을 import 해 직접 작성하고(ADR-009·022), 헬퍼는 머지·캐시·
|
|
18
|
+
* 동시성의 기계적 작업만 맡는다(ORM·관계 declaration 아님). 본문은 `service-helpers/` 의 순수 함수에
|
|
19
|
+
* 위임하며 여기서는 `ctx.cache`/`log` 만 주입한다.
|
|
15
20
|
*/
|
|
21
|
+
import {
|
|
22
|
+
attachMany as _attachMany,
|
|
23
|
+
attachOne as _attachOne,
|
|
24
|
+
enrich as _enrich,
|
|
25
|
+
parallel as _parallel,
|
|
26
|
+
} from './service-helpers/index.js'
|
|
27
|
+
|
|
16
28
|
export class MegaService {
|
|
17
29
|
/**
|
|
18
30
|
* @param {Record<string, any>} ctx - 요청 컨텍스트 (req·log·services·db·cache·bus 등)
|
|
@@ -38,4 +50,73 @@ export class MegaService {
|
|
|
38
50
|
get services() {
|
|
39
51
|
return this.ctx?.services ?? {}
|
|
40
52
|
}
|
|
53
|
+
|
|
54
|
+
// ── 데이터 조립 헬퍼 (ADR-230) ────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 버킷→캐시 어댑터 resolver(헬퍼 cache 옵션 주입용). `ctx.cache(bucket)` 가 없으면 throw.
|
|
58
|
+
* @protected
|
|
59
|
+
* @param {string} bucket - `services.caches` 별명.
|
|
60
|
+
* @returns {any} MegaCacheAdapter.
|
|
61
|
+
*/
|
|
62
|
+
_cacheFor(bucket) {
|
|
63
|
+
if (typeof this.ctx?.cache !== 'function') {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'MegaService: ctx.cache(bucket) 접근자가 없습니다 — cache 옵션은 caches 가 설정된 요청 ctx 가 필요합니다.',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
return this.ctx.cache(bucket)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 1:N 배치 머지 — `parent[parentKey='id']` ↔ `child[fk]`. 부모마다 매칭 자식 **배열**을 `as` 로 붙인다.
|
|
73
|
+
* 단일 배치 쿼리(IN/$in)라 dialect·저장소 무관(ADR-230). per-parent limit 은 불가(전체 상한만).
|
|
74
|
+
*
|
|
75
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
76
|
+
* @param {any} Model - 자식 모델(공통 CRUD `findMany`, 또는 `opts.fetch`).
|
|
77
|
+
* @param {string} fk - 자식의 외래키 컬럼.
|
|
78
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
79
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, limit?: number, parentKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
80
|
+
* @returns {Promise<any[]>} parents.
|
|
81
|
+
*/
|
|
82
|
+
attachMany(parents, Model, fk, as, opts = {}) {
|
|
83
|
+
return _attachMany(parents, Model, fk, as, opts, (b) => this._cacheFor(b), this.log)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* N:1 배치 머지 — `parent[fk]` ↔ `child[childKey='id']`. 부모마다 매칭 자식 **하나**(없으면 null)를 붙인다.
|
|
88
|
+
*
|
|
89
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
90
|
+
* @param {any} Model - 자식 모델.
|
|
91
|
+
* @param {string} fk - 부모가 들고 있는 외래키 컬럼.
|
|
92
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
93
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, childKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
94
|
+
* @returns {Promise<any[]>} parents.
|
|
95
|
+
*/
|
|
96
|
+
attachOne(parents, Model, fk, as, opts = {}) {
|
|
97
|
+
return _attachOne(parents, Model, fk, as, opts, (b) => this._cacheFor(b), this.log)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* per-row 보강 — 각 행에 `fn` 적용(N+1 본질, concurrency 는 동시성만). cache 로 행별 결과 재사용.
|
|
102
|
+
*
|
|
103
|
+
* @param {any[]} rows - 대상 배열(제자리 mutate 후 반환).
|
|
104
|
+
* @param {(row: any) => Promise<any>} fn - 행별 콜백.
|
|
105
|
+
* @param {number | { concurrency?: number, settled?: boolean, cache?: { key: (row: any) => string, ttl?: number, bucket: string } }} [opts]
|
|
106
|
+
* @returns {Promise<any[]>} rows.
|
|
107
|
+
*/
|
|
108
|
+
enrich(rows, fn, opts = {}) {
|
|
109
|
+
return _enrich(rows, fn, opts, (b) => this._cacheFor(b), this.log)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 동시 실행 — thunk(`() => Promise`)의 객체/배열을 받아 같은 형태로 결과 반환(동시성 제한 가능).
|
|
114
|
+
*
|
|
115
|
+
* @param {Record<string, () => Promise<any>> | Array<() => Promise<any>>} specs
|
|
116
|
+
* @param {number | { concurrency?: number, settled?: boolean }} [opts]
|
|
117
|
+
* @returns {Promise<any>}
|
|
118
|
+
*/
|
|
119
|
+
parallel(specs, opts = {}) {
|
|
120
|
+
return _parallel(specs, opts)
|
|
121
|
+
}
|
|
41
122
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 배치 머지 헬퍼 — `attachMany`(1:N) / `attachOne`(N:1) (ADR-230).
|
|
4
|
+
*
|
|
5
|
+
* 사용자가 부모 레코드를 이미 손에 들고 있을 때, 자식 모델을 **단일 배치 쿼리**(IN/$in)로 끌어와
|
|
6
|
+
* 메모리에서 머지한다. per-row 루프(N+1)를 1+1 로 압축하는 dataloader 패턴이며, 어댑터의 `findMany`
|
|
7
|
+
* 가 배열 필터를 SQL `IN` · mongo `$in` 양쪽으로 처리하므로 **dialect 무관**하다. 부모·자식이 서로
|
|
8
|
+
* 다른 저장소여도(예: pg 부모 + mongo 자식) plain object 만 다루므로 그대로 동작한다.
|
|
9
|
+
*
|
|
10
|
+
* ORM·관계 declaration 이 아니다(ADR-009) — 조인 키를 매 호출 명시하고, 결과는 plain object 이며,
|
|
11
|
+
* 인스턴스(ActiveRecord)를 만들지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* # 한계
|
|
14
|
+
* - **per-parent limit 불가**: `opts.limit` 은 자식 **전체** 상한이지 "부모당 N개"가 아니다(부모별
|
|
15
|
+
* top-N 은 window 함수가 필요해 배치 1쿼리로 cross-dialect 불가). 부모별이 필요하면 `enrich` 로.
|
|
16
|
+
* - **캐시 직렬화**: redis 캐시는 JSON 직렬화라 ObjectId/Date 가 문자열이 된다. 응답용은 무방하나
|
|
17
|
+
* 캐시된 값을 다시 쿼리 입력으로 쓰면 안 된다.
|
|
18
|
+
*
|
|
19
|
+
* @module core/service-helpers/attach
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 비교용 키 정규화 — mongo ObjectId 등 비-원시 키는 인스턴스끼리 `===` 가 성립하지 않으므로
|
|
24
|
+
* `String()` 로 안정화한다. null/undefined 는 그대로 보존(매칭 안 됨).
|
|
25
|
+
* @param {any} v
|
|
26
|
+
* @returns {any}
|
|
27
|
+
*/
|
|
28
|
+
function normKey(v) {
|
|
29
|
+
return v === null || v === undefined ? v : String(v)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 정규화 기준 중복 제거(원본 값 보존 — IN 쿼리엔 실제 ObjectId/값이 들어가야 한다).
|
|
34
|
+
* @param {any[]} arr
|
|
35
|
+
* @returns {any[]}
|
|
36
|
+
*/
|
|
37
|
+
function uniq(arr) {
|
|
38
|
+
const seen = new Set()
|
|
39
|
+
/** @type {any[]} */
|
|
40
|
+
const out = []
|
|
41
|
+
for (const v of arr) {
|
|
42
|
+
if (v === null || v === undefined) continue
|
|
43
|
+
const k = normKey(v)
|
|
44
|
+
if (!seen.has(k)) {
|
|
45
|
+
seen.add(k)
|
|
46
|
+
out.push(v)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 머지 키가 select 에서 빠지면 머지가 깨진다(특히 mongo `_id` 는 기본 숨김). 자동 포함한다.
|
|
54
|
+
* @param {string[] | undefined} select
|
|
55
|
+
* @param {string} col
|
|
56
|
+
* @returns {string[] | undefined}
|
|
57
|
+
*/
|
|
58
|
+
function ensureKeyInSelect(select, col) {
|
|
59
|
+
if (!Array.isArray(select)) return select
|
|
60
|
+
return select.includes(col) ? select : [...select, col]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 공통 인자 검증.
|
|
65
|
+
* @param {string} name @param {any[]} parents @param {string} fk @param {string} as
|
|
66
|
+
*/
|
|
67
|
+
function validateArgs(name, parents, fk, as) {
|
|
68
|
+
if (!Array.isArray(parents)) throw new TypeError(`${name}: parents must be an array`)
|
|
69
|
+
if (!fk || typeof fk !== 'string') throw new TypeError(`${name}: fk(key column) must be a non-empty string`)
|
|
70
|
+
if (!as || typeof as !== 'string') throw new TypeError(`${name}: as(target property) must be a non-empty string`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 자식 레코드 fetch — cache hit 면 쿼리 skip. `opts.fetch`(사용자 함수) 우선, 없으면 `Model.findMany`.
|
|
75
|
+
*
|
|
76
|
+
* @param {any} Model - 자식 모델(공통 CRUD `findMany` 보유. `opts.fetch` 사용 시 미사용).
|
|
77
|
+
* @param {string} matchCol - 자식에서 IN 으로 조회할 컬럼.
|
|
78
|
+
* @param {any[]} ids - 중복 제거된 부모 키(정규화 전 원본).
|
|
79
|
+
* @param {any} opts - { where, select, orderBy, limit, fetch, cache }.
|
|
80
|
+
* @param {(bucket: string) => any} [cacheFor] - 버킷→캐시 어댑터 resolver.
|
|
81
|
+
* @param {any} [logger]
|
|
82
|
+
* @returns {Promise<any[]>}
|
|
83
|
+
*/
|
|
84
|
+
async function fetchChildren(Model, matchCol, ids, opts, cacheFor, logger) {
|
|
85
|
+
const log = logger ?? console
|
|
86
|
+
const cacheOpt = opts.cache
|
|
87
|
+
let cache = null
|
|
88
|
+
if (cacheOpt) {
|
|
89
|
+
if (!cacheOpt.key || typeof cacheOpt.key !== 'string') {
|
|
90
|
+
throw new TypeError('attach: cache.key must be a non-empty string (auto-hash 비채택 — stale/충돌 방지)')
|
|
91
|
+
}
|
|
92
|
+
if (!cacheOpt.bucket) throw new TypeError('attach: cache.bucket is required (ctx.cache(bucket))')
|
|
93
|
+
if (typeof cacheFor !== 'function') {
|
|
94
|
+
throw new Error('attach: cache requested but no cache resolver — MegaService 를 통해 호출하세요.')
|
|
95
|
+
}
|
|
96
|
+
cache = cacheFor(cacheOpt.bucket)
|
|
97
|
+
const hit = await cache.get(cacheOpt.key)
|
|
98
|
+
if (hit !== null && hit !== undefined) return hit
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @type {any[]} */
|
|
102
|
+
let children
|
|
103
|
+
if (typeof opts.fetch === 'function') {
|
|
104
|
+
if (opts.where || opts.select || opts.orderBy || opts.limit !== undefined) {
|
|
105
|
+
log.warn?.('attach: opts.fetch 가 지정되어 where/select/orderBy/limit 는 무시됩니다(fetch 우선).')
|
|
106
|
+
}
|
|
107
|
+
children = await opts.fetch(ids)
|
|
108
|
+
} else {
|
|
109
|
+
if (!Model || typeof Model.findMany !== 'function') {
|
|
110
|
+
throw new TypeError('attach: Model.findMany 가 없습니다 — schema 선언 모델이거나 opts.fetch 를 쓰세요.')
|
|
111
|
+
}
|
|
112
|
+
const filter = { [matchCol]: ids, ...(opts.where ?? {}) }
|
|
113
|
+
/** @type {Record<string, any>} */
|
|
114
|
+
const findOpts = {}
|
|
115
|
+
if (opts.select) findOpts.select = ensureKeyInSelect(opts.select, matchCol)
|
|
116
|
+
if (opts.orderBy) findOpts.orderBy = opts.orderBy
|
|
117
|
+
if (opts.limit !== undefined) findOpts.limit = opts.limit
|
|
118
|
+
children = await Model.findMany(filter, findOpts)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (cache) {
|
|
122
|
+
await cache.set(cacheOpt.key, children, cacheOpt.ttl !== undefined ? { ttl: cacheOpt.ttl } : {})
|
|
123
|
+
}
|
|
124
|
+
return children
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 1:N 배치 머지 — `parent[parentKey]`(기본 `id`) ↔ `child[fk]`. 부모마다 매칭 자식 **배열**을 붙인다.
|
|
129
|
+
*
|
|
130
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
131
|
+
* @param {any} Model - 자식 모델.
|
|
132
|
+
* @param {string} fk - 자식의 외래키 컬럼.
|
|
133
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
134
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, limit?: number, parentKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
135
|
+
* @param {(bucket: string) => any} [cacheFor]
|
|
136
|
+
* @param {any} [logger]
|
|
137
|
+
* @returns {Promise<any[]>} parents(동일 참조).
|
|
138
|
+
*/
|
|
139
|
+
export async function attachMany(parents, Model, fk, as, opts = {}, cacheFor, logger) {
|
|
140
|
+
validateArgs('attachMany', parents, fk, as)
|
|
141
|
+
if (parents.length === 0) return parents
|
|
142
|
+
const parentKey = opts.parentKey ?? 'id'
|
|
143
|
+
const ids = uniq(parents.map((p) => p[parentKey]))
|
|
144
|
+
if (ids.length === 0) {
|
|
145
|
+
for (const p of parents) p[as] = []
|
|
146
|
+
return parents
|
|
147
|
+
}
|
|
148
|
+
const children = await fetchChildren(Model, fk, ids, opts, cacheFor, logger)
|
|
149
|
+
/** @type {Map<any, any[]>} */
|
|
150
|
+
const groups = new Map()
|
|
151
|
+
for (const c of children) {
|
|
152
|
+
const k = normKey(c[fk])
|
|
153
|
+
let arr = groups.get(k)
|
|
154
|
+
if (!arr) {
|
|
155
|
+
arr = []
|
|
156
|
+
groups.set(k, arr)
|
|
157
|
+
}
|
|
158
|
+
arr.push(c)
|
|
159
|
+
}
|
|
160
|
+
for (const p of parents) p[as] = groups.get(normKey(p[parentKey])) ?? []
|
|
161
|
+
return parents
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* N:1 배치 머지 — `parent[fk]` ↔ `child[childKey]`(기본 `id`). 부모마다 매칭 자식 **하나**(없으면 null).
|
|
166
|
+
*
|
|
167
|
+
* 주의: 부모 쪽 매칭 컬럼은 위치 인자 `fk` 로 고정된다. 튜닝 가능한 건 자식 쪽 컬럼이라
|
|
168
|
+
* `opts.childKey`(기본 `id`)로 노출한다.
|
|
169
|
+
*
|
|
170
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
171
|
+
* @param {any} Model - 자식 모델.
|
|
172
|
+
* @param {string} fk - 부모가 들고 있는 외래키 컬럼(자식 식별자 값).
|
|
173
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
174
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, childKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
175
|
+
* @param {(bucket: string) => any} [cacheFor]
|
|
176
|
+
* @param {any} [logger]
|
|
177
|
+
* @returns {Promise<any[]>} parents(동일 참조).
|
|
178
|
+
*/
|
|
179
|
+
export async function attachOne(parents, Model, fk, as, opts = {}, cacheFor, logger) {
|
|
180
|
+
validateArgs('attachOne', parents, fk, as)
|
|
181
|
+
if (parents.length === 0) return parents
|
|
182
|
+
const childKey = opts.childKey ?? 'id'
|
|
183
|
+
const ids = uniq(parents.map((p) => p[fk]))
|
|
184
|
+
if (ids.length === 0) {
|
|
185
|
+
for (const p of parents) p[as] = null
|
|
186
|
+
return parents
|
|
187
|
+
}
|
|
188
|
+
const children = await fetchChildren(Model, childKey, ids, opts, cacheFor, logger)
|
|
189
|
+
/** @type {Map<any, any>} */
|
|
190
|
+
const byKey = new Map()
|
|
191
|
+
for (const c of children) {
|
|
192
|
+
const k = normKey(c[childKey])
|
|
193
|
+
if (!byKey.has(k)) byKey.set(k, c) // 1개 가정 — 첫 매치 우선.
|
|
194
|
+
}
|
|
195
|
+
for (const p of parents) p[as] = byKey.get(normKey(p[fk])) ?? null
|
|
196
|
+
return parents
|
|
197
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* per-row 보강 헬퍼 — `enrich` (ADR-230).
|
|
4
|
+
*
|
|
5
|
+
* 각 행마다 비동기 콜백을 적용한다(부모마다 조건이 달라 배치로 묶기 어려운 경우). 본질적으로 **N+1**
|
|
6
|
+
* 이며 `concurrency` 는 쿼리 수를 줄이지 않고 동시성만 제한한다 — 큰 배열엔 `attachMany` 를 우선 검토.
|
|
7
|
+
*
|
|
8
|
+
* 캐시(`opts.cache`)를 주면 행별 키로 결과를 재사용한다 — hit 시 콜백을 건너뛴다. redis 캐시는 JSON
|
|
9
|
+
* 직렬화라 ObjectId/Date 가 문자열이 된다(응답용 OK, 재쿼리 입력 금지).
|
|
10
|
+
*
|
|
11
|
+
* @module core/service-helpers/enrich
|
|
12
|
+
*/
|
|
13
|
+
import { runPool } from './pool.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `rows` 각 항목에 `fn` 을 적용한다. `fn` 반환값이 `undefined` 가 아니면 그 값으로 교체하고,
|
|
17
|
+
* `undefined` 면 (제자리 mutate 된) 원본 행을 유지한다.
|
|
18
|
+
*
|
|
19
|
+
* @param {any[]} rows - 대상 배열(제자리 mutate 후 반환).
|
|
20
|
+
* @param {(row: any) => Promise<any>} fn - 행별 콜백.
|
|
21
|
+
* @param {number | { concurrency?: number, settled?: boolean, cache?: { key: (row: any) => string, ttl?: number, bucket: string } }} [opts]
|
|
22
|
+
* number 면 `concurrency`. settled:true 면 일부 실패해도 진행(실패 행은 그대로 두고 경고 로그).
|
|
23
|
+
* @param {(bucket: string) => any} [cacheFor] - 버킷→캐시 어댑터 resolver(cache 옵션 쓸 때만 필요).
|
|
24
|
+
* @param {any} [logger] - settled 실패 경고용(P4/P5 — silent skip 금지).
|
|
25
|
+
* @returns {Promise<any[]>} rows(동일 참조).
|
|
26
|
+
*/
|
|
27
|
+
export async function enrich(rows, fn, opts = {}, cacheFor, logger) {
|
|
28
|
+
if (!Array.isArray(rows)) throw new TypeError('enrich: rows must be an array')
|
|
29
|
+
if (typeof fn !== 'function') throw new TypeError('enrich: fn must be a function (row) => Promise')
|
|
30
|
+
const o = typeof opts === 'number' ? { concurrency: opts } : (opts ?? {})
|
|
31
|
+
const concurrency = o.concurrency ?? 10
|
|
32
|
+
const settled = o.settled === true
|
|
33
|
+
const cacheOpt = o.cache
|
|
34
|
+
const log = logger ?? console
|
|
35
|
+
|
|
36
|
+
let cache = null
|
|
37
|
+
if (cacheOpt) {
|
|
38
|
+
if (typeof cacheOpt.key !== 'function') {
|
|
39
|
+
throw new TypeError('enrich: cache.key must be a function (row) => string')
|
|
40
|
+
}
|
|
41
|
+
if (!cacheOpt.bucket) throw new TypeError('enrich: cache.bucket is required (ctx.cache(bucket))')
|
|
42
|
+
if (typeof cacheFor !== 'function') {
|
|
43
|
+
throw new Error('enrich: cache requested but no cache resolver — MegaService 를 통해 호출하세요.')
|
|
44
|
+
}
|
|
45
|
+
cache = cacheFor(cacheOpt.bucket)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 행 i 처리 — 캐시 hit/miss 분기 후 rows[i] 갱신. @param {any} row @param {number} i */
|
|
49
|
+
const worker = async (row, i) => {
|
|
50
|
+
if (cache) {
|
|
51
|
+
const k = cacheOpt.key(row)
|
|
52
|
+
const hit = await cache.get(k)
|
|
53
|
+
if (hit !== null && hit !== undefined) {
|
|
54
|
+
rows[i] = hit
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const r = await fn(row)
|
|
58
|
+
const finalRow = r === undefined ? row : r
|
|
59
|
+
rows[i] = finalRow
|
|
60
|
+
await cache.set(k, finalRow, cacheOpt.ttl !== undefined ? { ttl: cacheOpt.ttl } : {})
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const r = await fn(row)
|
|
64
|
+
if (r !== undefined) rows[i] = r
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const out = await runPool(rows, worker, { concurrency, settled })
|
|
68
|
+
|
|
69
|
+
// settled 모드의 개별 실패는 runPool 이 삼키므로 여기서 명시 경고(P4 — silent skip 금지).
|
|
70
|
+
if (settled) {
|
|
71
|
+
for (let i = 0; i < out.length; i++) {
|
|
72
|
+
const d = /** @type {any} */ (out[i])
|
|
73
|
+
if (d && d.status === 'rejected') {
|
|
74
|
+
log.warn?.({ err: d.reason, index: i }, 'enrich: row 콜백 실패(settled) — 해당 행은 원본 유지')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return rows
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaService 데이터 조립 헬퍼 배럴 (ADR-230).
|
|
4
|
+
*
|
|
5
|
+
* 순수 함수들 — `MegaService` 의 동명 메서드가 `ctx.cache`/`log` 를 주입해 위임한다. 서비스 인스턴스
|
|
6
|
+
* 없이 단위 테스트 가능하도록 직접 export 한다.
|
|
7
|
+
*
|
|
8
|
+
* @module core/service-helpers
|
|
9
|
+
*/
|
|
10
|
+
export { attachMany, attachOne } from './attach.js'
|
|
11
|
+
export { enrich } from './enrich.js'
|
|
12
|
+
export { parallel } from './parallel.js'
|
|
13
|
+
export { runPool } from './pool.js'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 동시 실행 헬퍼 — `parallel` (ADR-230).
|
|
4
|
+
*
|
|
5
|
+
* `Promise.all` / `Promise.allSettled` 를 동시성 제한과 함께 추상화한다. 입력은 **thunk**(`() => Promise`)
|
|
6
|
+
* 의 객체 또는 배열이다 — 이미 시작된 promise 가 아니라 thunk 여야 `concurrency` 가 의미를 갖는다.
|
|
7
|
+
* 출력은 입력과 같은 형태(객체→객체, 배열→배열)다.
|
|
8
|
+
*
|
|
9
|
+
* @module core/service-helpers/parallel
|
|
10
|
+
*/
|
|
11
|
+
import { runPool } from './pool.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Record<string, () => Promise<any>> | Array<() => Promise<any>>} specs - thunk 의 객체/배열.
|
|
15
|
+
* @param {number | { concurrency?: number, settled?: boolean }} [opts]
|
|
16
|
+
* number 면 `concurrency`(기본 무제한). settled:true 면 `{ status, value | reason }` 로 감싸 모두 완주.
|
|
17
|
+
* @returns {Promise<any>} specs 와 동일 형태의 결과.
|
|
18
|
+
*/
|
|
19
|
+
export async function parallel(specs, opts = {}) {
|
|
20
|
+
const o = typeof opts === 'number' ? { concurrency: opts } : (opts ?? {})
|
|
21
|
+
const isArray = Array.isArray(specs)
|
|
22
|
+
if (!isArray && (specs === null || typeof specs !== 'object')) {
|
|
23
|
+
throw new TypeError('parallel: specs must be an object or array of thunks (() => Promise)')
|
|
24
|
+
}
|
|
25
|
+
/** @type {string[]} */
|
|
26
|
+
const keys = isArray ? [] : Object.keys(specs)
|
|
27
|
+
/** @type {Array<() => Promise<any>>} */
|
|
28
|
+
const thunks = isArray ? /** @type {any} */ (specs) : keys.map((k) => /** @type {any} */ (specs)[k])
|
|
29
|
+
|
|
30
|
+
thunks.forEach((fn, i) => {
|
|
31
|
+
if (typeof fn !== 'function') {
|
|
32
|
+
const label = isArray ? `[${i}]` : `.${keys[i]}`
|
|
33
|
+
throw new TypeError(`parallel: spec${label} must be a function (() => Promise), got ${typeof fn}`)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const arr = await runPool(thunks, (fn) => fn(), {
|
|
38
|
+
concurrency: o.concurrency,
|
|
39
|
+
settled: o.settled,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (isArray) return arr
|
|
43
|
+
/** @type {Record<string, any>} */
|
|
44
|
+
const out = {}
|
|
45
|
+
keys.forEach((k, i) => {
|
|
46
|
+
out[k] = arr[i]
|
|
47
|
+
})
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 바운디드 동시성 실행기 — service-helpers(enrich/parallel)가 공유한다(ADR-230).
|
|
4
|
+
*
|
|
5
|
+
* 신규 dep 없이(`p-limit` 미도입) 입력 순서를 보존하며 동시 실행 수만 제한한다. 두 모드:
|
|
6
|
+
* - `settled:false`(기본) — 첫 worker rejection 에서 전체를 reject 한다(Promise.all 의미). 이미
|
|
7
|
+
* 시작된 worker 는 자연 종료되지만 결과는 버려진다.
|
|
8
|
+
* - `settled:true` — 각 항목을 `{ status, value | reason }` 로 감싸 **모두 완주**한다(allSettled 의미).
|
|
9
|
+
*
|
|
10
|
+
* @module core/service-helpers/pool
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @template T, R
|
|
15
|
+
* @typedef {{ status: 'fulfilled', value: R } | { status: 'rejected', reason: any }} Settled
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `items` 를 `worker` 로 매핑하되 동시 실행 수를 `concurrency` 로 제한한다. 결과 배열은 입력 순서 보존.
|
|
20
|
+
*
|
|
21
|
+
* @template T, R
|
|
22
|
+
* @param {T[]} items - 매핑 대상.
|
|
23
|
+
* @param {(item: T, index: number) => Promise<R>} worker - 각 항목 처리(반환값이 결과).
|
|
24
|
+
* @param {{ concurrency?: number, settled?: boolean }} [opts] - `concurrency` 미지정/비양수면 무제한(=항목 수).
|
|
25
|
+
* @returns {Promise<Array<R | Settled<T, R>>>} settled 면 descriptor 배열, 아니면 결과 배열.
|
|
26
|
+
*/
|
|
27
|
+
export async function runPool(items, worker, opts = {}) {
|
|
28
|
+
const n = items.length
|
|
29
|
+
/** @type {any[]} */
|
|
30
|
+
const results = new Array(n)
|
|
31
|
+
if (n === 0) return results
|
|
32
|
+
|
|
33
|
+
const settled = opts.settled === true
|
|
34
|
+
let limit = opts.concurrency
|
|
35
|
+
// 무제한·잘못된 값 → 항목 수(전부 동시). 그 외엔 [1, n] 으로 클램프.
|
|
36
|
+
if (limit === undefined || limit === null || !Number.isFinite(limit) || limit <= 0) {
|
|
37
|
+
limit = n
|
|
38
|
+
}
|
|
39
|
+
limit = Math.max(1, Math.min(Math.floor(limit), n))
|
|
40
|
+
|
|
41
|
+
let next = 0
|
|
42
|
+
/** @type {any} settled:false 에서 첫 에러 보관 — 완주 후 throw. */
|
|
43
|
+
let failure = null
|
|
44
|
+
let hasFailure = false
|
|
45
|
+
|
|
46
|
+
// 워커 루프 — 공유 커서(next)에서 항목을 하나씩 집어 처리. catch 내부 처리라 절대 reject 안 함.
|
|
47
|
+
async function spawn() {
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (hasFailure && !settled) return // 이미 실패 — 새 작업 시작 중단(진행 중인 건 종료).
|
|
50
|
+
const i = next++
|
|
51
|
+
if (i >= n) return
|
|
52
|
+
try {
|
|
53
|
+
const r = await worker(items[i], i)
|
|
54
|
+
results[i] = settled ? { status: 'fulfilled', value: r } : r
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (settled) {
|
|
57
|
+
results[i] = { status: 'rejected', reason: e }
|
|
58
|
+
} else if (!hasFailure) {
|
|
59
|
+
hasFailure = true
|
|
60
|
+
failure = e
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const workers = []
|
|
68
|
+
for (let k = 0; k < limit; k++) workers.push(spawn())
|
|
69
|
+
await Promise.all(workers)
|
|
70
|
+
|
|
71
|
+
if (hasFailure && !settled) throw failure
|
|
72
|
+
return results
|
|
73
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,8 @@ export { bootApp, buildBootContext } from './core/index.js'
|
|
|
6
6
|
export { getApp, hasApp } from './core/index.js'
|
|
7
7
|
export { runCli, parseArgs, runWorkerHost, runSchedulerHost, dispatchPluginCommand, USAGE } from './cli/index.js'
|
|
8
8
|
export { MegaService } from './core/index.js'
|
|
9
|
+
// 데이터 조립 헬퍼 순수 함수 (ADR-230) — 보통은 MegaService 메서드 사용.
|
|
10
|
+
export { attachMany, attachOne, enrich, parallel } from './core/index.js'
|
|
9
11
|
export { MegaCluster } from './core/index.js'
|
|
10
12
|
export { wrapEnvelope, errorEnvelope, buildErrorHandler, ajvErrorToValidationError } from './core/index.js'
|
|
11
13
|
// WS envelope + 컨트롤러 베이스 (ADR-015 / ADR-074)
|
|
@@ -17,19 +17,11 @@ export class {{Name}} extends MegaModel {
|
|
|
17
17
|
static table = '{{table}}'
|
|
18
18
|
|
|
19
19
|
// 자동 마이그레이션 스키마(ADR-209) — 빌더 API 는 docs/guide/03-service-model-db.md §5 참조.
|
|
20
|
-
static schema = (t) => ({
|
|
20
|
+
static schema = /** @param {any} t */ (t) => ({
|
|
21
21
|
name: t.varchar(100).notNull(),
|
|
22
22
|
createdAt: t.timestamptz().notNull(), // 생성 시 앱에서 new Date() 로 채운다(mongo 는 default 미지원)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
// 인덱스(선택): static indexes = (t) => [t.index(['name'], { unique: true })]
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* _id 로 1건 조회 — `this.db` 는 native mongodb `Db`(ADR-009), 도큐먼트 API 직접 사용.
|
|
29
|
-
* @param {import('mongodb').ObjectId} id
|
|
30
|
-
* @returns {Promise<object|null>}
|
|
31
|
-
*/
|
|
32
|
-
static async findById(id) {
|
|
33
|
-
return this.db.collection(this.table).findOne({ _id: id })
|
|
34
|
-
}
|
|
35
27
|
}
|
package/templates/model/code.tpl
CHANGED
|
@@ -15,7 +15,7 @@ export class {{Name}} extends MegaModel {
|
|
|
15
15
|
static table = '{{table}}'
|
|
16
16
|
|
|
17
17
|
// 자동 마이그레이션 스키마(ADR-204) — 빌더 API 는 docs/guide/03-service-model-db.md §5 참조.
|
|
18
|
-
static schema = (t) => ({
|
|
18
|
+
static schema = /** @param {any} t */ (t) => ({
|
|
19
19
|
id: t.serial().primary(),
|
|
20
20
|
name: t.varchar(100).notNull(),
|
|
21
21
|
createdAt: t.timestamptz().defaultNow(),
|
|
@@ -23,13 +23,4 @@ export class {{Name}} extends MegaModel {
|
|
|
23
23
|
|
|
24
24
|
// 인덱스(선택): static indexes = (t) => [t.index(['name'], { unique: true })]
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* id 로 1건 조회. `this.query` 는 계측된 어댑터 query 위임(ADR-138).
|
|
28
|
-
* @param {string|number} id
|
|
29
|
-
* @returns {Promise<object|null>}
|
|
30
|
-
*/
|
|
31
|
-
static async findById(id) {
|
|
32
|
-
const { rows } = await this.query('select * from {{table}} where id = $1', [id])
|
|
33
|
-
return rows[0] ?? null
|
|
34
|
-
}
|
|
35
26
|
}
|
|
@@ -12,27 +12,5 @@ describe('{{Name}} model (mongodb)', () => {
|
|
|
12
12
|
|
|
13
13
|
test('schema 빌더 선언 — 자동 마이그레이션 트랙 옵트인(ADR-209)', () => {
|
|
14
14
|
expect(typeof {{Name}}.schema).toBe('function')
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
test('findById — native Db 의 도큐먼트 API 에 위임', async () => {
|
|
18
|
-
/** @type {any[]} */
|
|
19
|
-
const calls = []
|
|
20
|
-
const fakeDb = {
|
|
21
|
-
collection: (/** @type {string} */ name) => ({
|
|
22
|
-
findOne: async (/** @type {any} */ filter) => {
|
|
23
|
-
calls.push({ name, filter })
|
|
24
|
-
return { _id: filter._id }
|
|
25
|
-
},
|
|
26
|
-
}),
|
|
27
|
-
}
|
|
28
|
-
// MegaModel.db 는 getter 라 서브클래스 own property 로 가린다(테스트 한정).
|
|
29
|
-
Object.defineProperty({{Name}}, 'db', { value: fakeDb, configurable: true })
|
|
30
|
-
try {
|
|
31
|
-
const doc = await {{Name}}.findById(/** @type {any} */ ('id-1'))
|
|
32
|
-
expect(doc).toEqual({ _id: 'id-1' })
|
|
33
|
-
expect(calls[0].name).toBe('{{table}}')
|
|
34
|
-
} finally {
|
|
35
|
-
delete /** @type {any} */ ({{Name}}).db
|
|
36
|
-
}
|
|
37
|
-
})
|
|
15
|
+
})
|
|
38
16
|
})
|
package/templates/model/test.tpl
CHANGED
|
@@ -13,21 +13,4 @@ describe('{{Name}} model', () => {
|
|
|
13
13
|
test('schema 빌더 선언 — 자동 마이그레이션 트랙 옵트인(ADR-204)', () => {
|
|
14
14
|
expect(typeof {{Name}}.schema).toBe('function')
|
|
15
15
|
})
|
|
16
|
-
|
|
17
|
-
test('findById — 어댑터 query 에 위임', async () => {
|
|
18
|
-
const orig = {{Name}}.query
|
|
19
|
-
/** @type {any[]} */
|
|
20
|
-
const calls = []
|
|
21
|
-
{{Name}}.query = async (/** @type {string} */ sql, /** @type {any[]} */ params) => {
|
|
22
|
-
calls.push({ sql, params })
|
|
23
|
-
return { rows: [{ id: params[0] }] }
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
const row = await {{Name}}.findById(7)
|
|
27
|
-
expect(row).toEqual({ id: 7 })
|
|
28
|
-
expect(calls[0].params).toEqual([7])
|
|
29
|
-
} finally {
|
|
30
|
-
{{Name}}.query = orig
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
16
|
})
|
package/types/core/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { MegaWebSocketController } from "./ws-controller.js";
|
|
|
10
10
|
export { MegaHubLink } from "./hub-link.js";
|
|
11
11
|
export { createSessionCleanupSchedule } from "./session-cleanup-schedule.js";
|
|
12
12
|
export { Router, MegaRouteError } from "./router.js";
|
|
13
|
+
export { attachMany, attachOne, enrich, parallel } from "./service-helpers/index.js";
|
|
13
14
|
export { bootApp, buildBootContext } from "./boot.js";
|
|
14
15
|
export { getApp, hasApp } from "./app-registry.js";
|
|
15
16
|
export { wrapEnvelope, errorEnvelope, synthesizeEnvelopeResponseSchema, ENVELOPE_MARK } from "./envelope.js";
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MegaService — 도메인 비즈니스 로직 베이스.
|
|
3
|
-
*
|
|
4
|
-
* 라우트 핸들러(인라인 함수 또는 정적 메서드 ref)는 직접 모델을 호출하지 않고 항상 서비스를 거쳐야
|
|
5
|
-
* 한다(ADR-022). ESLint custom rule `mega/no-direct-model-import`(`mega-framework/eslint-plugin`,
|
|
6
|
-
* eslint.config.js 에 배선)가 lint 시점에 routes/controllers → models 직접 import 를 차단한다.
|
|
7
|
-
*
|
|
8
|
-
* 서비스는 요청 컨텍스트 ctx 와 함께 인스턴스화되며, this.ctx / this.app / this.log /
|
|
9
|
-
* this.services 로 접근. 다른 서비스 호출은 this.services.<name> 으로 합성.
|
|
10
|
-
*
|
|
11
|
-
* 자동 DI(ADR-148): `apps/<app>/services/<name>-service.js` 를 부팅 시 로드해 두면 핸들러·다른 서비스가
|
|
12
|
-
* `ctx.services.<name>` / `this.services.<name>` 로 요청별 인스턴스를 받는다(첫 접근 시 lazy 생성·요청 내 캐시).
|
|
13
|
-
*/
|
|
14
1
|
export class MegaService {
|
|
15
2
|
/**
|
|
16
3
|
* @param {Record<string, any>} ctx - 요청 컨텍스트 (req·log·services·db·cache·bus 등)
|
|
@@ -28,4 +15,85 @@ export class MegaService {
|
|
|
28
15
|
get log(): any;
|
|
29
16
|
/** 다른 서비스 호출용. ctx.services 가 없으면 빈 객체. */
|
|
30
17
|
get services(): any;
|
|
18
|
+
/**
|
|
19
|
+
* 버킷→캐시 어댑터 resolver(헬퍼 cache 옵션 주입용). `ctx.cache(bucket)` 가 없으면 throw.
|
|
20
|
+
* @protected
|
|
21
|
+
* @param {string} bucket - `services.caches` 별명.
|
|
22
|
+
* @returns {any} MegaCacheAdapter.
|
|
23
|
+
*/
|
|
24
|
+
protected _cacheFor(bucket: string): any;
|
|
25
|
+
/**
|
|
26
|
+
* 1:N 배치 머지 — `parent[parentKey='id']` ↔ `child[fk]`. 부모마다 매칭 자식 **배열**을 `as` 로 붙인다.
|
|
27
|
+
* 단일 배치 쿼리(IN/$in)라 dialect·저장소 무관(ADR-230). per-parent limit 은 불가(전체 상한만).
|
|
28
|
+
*
|
|
29
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
30
|
+
* @param {any} Model - 자식 모델(공통 CRUD `findMany`, 또는 `opts.fetch`).
|
|
31
|
+
* @param {string} fk - 자식의 외래키 컬럼.
|
|
32
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
33
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, limit?: number, parentKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
34
|
+
* @returns {Promise<any[]>} parents.
|
|
35
|
+
*/
|
|
36
|
+
attachMany(parents: any[], Model: any, fk: string, as: string, opts?: {
|
|
37
|
+
where?: Record<string, any>;
|
|
38
|
+
select?: string[];
|
|
39
|
+
orderBy?: any;
|
|
40
|
+
limit?: number;
|
|
41
|
+
parentKey?: string;
|
|
42
|
+
cache?: {
|
|
43
|
+
key: string;
|
|
44
|
+
ttl?: number;
|
|
45
|
+
bucket: string;
|
|
46
|
+
};
|
|
47
|
+
fetch?: (ids: any[]) => Promise<any[]>;
|
|
48
|
+
}): Promise<any[]>;
|
|
49
|
+
/**
|
|
50
|
+
* N:1 배치 머지 — `parent[fk]` ↔ `child[childKey='id']`. 부모마다 매칭 자식 **하나**(없으면 null)를 붙인다.
|
|
51
|
+
*
|
|
52
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
53
|
+
* @param {any} Model - 자식 모델.
|
|
54
|
+
* @param {string} fk - 부모가 들고 있는 외래키 컬럼.
|
|
55
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
56
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, childKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
57
|
+
* @returns {Promise<any[]>} parents.
|
|
58
|
+
*/
|
|
59
|
+
attachOne(parents: any[], Model: any, fk: string, as: string, opts?: {
|
|
60
|
+
where?: Record<string, any>;
|
|
61
|
+
select?: string[];
|
|
62
|
+
orderBy?: any;
|
|
63
|
+
childKey?: string;
|
|
64
|
+
cache?: {
|
|
65
|
+
key: string;
|
|
66
|
+
ttl?: number;
|
|
67
|
+
bucket: string;
|
|
68
|
+
};
|
|
69
|
+
fetch?: (ids: any[]) => Promise<any[]>;
|
|
70
|
+
}): Promise<any[]>;
|
|
71
|
+
/**
|
|
72
|
+
* per-row 보강 — 각 행에 `fn` 적용(N+1 본질, concurrency 는 동시성만). cache 로 행별 결과 재사용.
|
|
73
|
+
*
|
|
74
|
+
* @param {any[]} rows - 대상 배열(제자리 mutate 후 반환).
|
|
75
|
+
* @param {(row: any) => Promise<any>} fn - 행별 콜백.
|
|
76
|
+
* @param {number | { concurrency?: number, settled?: boolean, cache?: { key: (row: any) => string, ttl?: number, bucket: string } }} [opts]
|
|
77
|
+
* @returns {Promise<any[]>} rows.
|
|
78
|
+
*/
|
|
79
|
+
enrich(rows: any[], fn: (row: any) => Promise<any>, opts?: number | {
|
|
80
|
+
concurrency?: number;
|
|
81
|
+
settled?: boolean;
|
|
82
|
+
cache?: {
|
|
83
|
+
key: (row: any) => string;
|
|
84
|
+
ttl?: number;
|
|
85
|
+
bucket: string;
|
|
86
|
+
};
|
|
87
|
+
}): Promise<any[]>;
|
|
88
|
+
/**
|
|
89
|
+
* 동시 실행 — thunk(`() => Promise`)의 객체/배열을 받아 같은 형태로 결과 반환(동시성 제한 가능).
|
|
90
|
+
*
|
|
91
|
+
* @param {Record<string, () => Promise<any>> | Array<() => Promise<any>>} specs
|
|
92
|
+
* @param {number | { concurrency?: number, settled?: boolean }} [opts]
|
|
93
|
+
* @returns {Promise<any>}
|
|
94
|
+
*/
|
|
95
|
+
parallel(specs: Record<string, () => Promise<any>> | Array<() => Promise<any>>, opts?: number | {
|
|
96
|
+
concurrency?: number;
|
|
97
|
+
settled?: boolean;
|
|
98
|
+
}): Promise<any>;
|
|
31
99
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1:N 배치 머지 — `parent[parentKey]`(기본 `id`) ↔ `child[fk]`. 부모마다 매칭 자식 **배열**을 붙인다.
|
|
3
|
+
*
|
|
4
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
5
|
+
* @param {any} Model - 자식 모델.
|
|
6
|
+
* @param {string} fk - 자식의 외래키 컬럼.
|
|
7
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
8
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, limit?: number, parentKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
9
|
+
* @param {(bucket: string) => any} [cacheFor]
|
|
10
|
+
* @param {any} [logger]
|
|
11
|
+
* @returns {Promise<any[]>} parents(동일 참조).
|
|
12
|
+
*/
|
|
13
|
+
export function attachMany(parents: any[], Model: any, fk: string, as: string, opts?: {
|
|
14
|
+
where?: Record<string, any>;
|
|
15
|
+
select?: string[];
|
|
16
|
+
orderBy?: any;
|
|
17
|
+
limit?: number;
|
|
18
|
+
parentKey?: string;
|
|
19
|
+
cache?: {
|
|
20
|
+
key: string;
|
|
21
|
+
ttl?: number;
|
|
22
|
+
bucket: string;
|
|
23
|
+
};
|
|
24
|
+
fetch?: (ids: any[]) => Promise<any[]>;
|
|
25
|
+
}, cacheFor?: (bucket: string) => any, logger?: any): Promise<any[]>;
|
|
26
|
+
/**
|
|
27
|
+
* N:1 배치 머지 — `parent[fk]` ↔ `child[childKey]`(기본 `id`). 부모마다 매칭 자식 **하나**(없으면 null).
|
|
28
|
+
*
|
|
29
|
+
* 주의: 부모 쪽 매칭 컬럼은 위치 인자 `fk` 로 고정된다. 튜닝 가능한 건 자식 쪽 컬럼이라
|
|
30
|
+
* `opts.childKey`(기본 `id`)로 노출한다.
|
|
31
|
+
*
|
|
32
|
+
* @param {any[]} parents - 부모 레코드(제자리 mutate 후 반환).
|
|
33
|
+
* @param {any} Model - 자식 모델.
|
|
34
|
+
* @param {string} fk - 부모가 들고 있는 외래키 컬럼(자식 식별자 값).
|
|
35
|
+
* @param {string} as - 부모에 붙일 속성명.
|
|
36
|
+
* @param {{ where?: Record<string, any>, select?: string[], orderBy?: any, childKey?: string, cache?: { key: string, ttl?: number, bucket: string }, fetch?: (ids: any[]) => Promise<any[]> }} [opts]
|
|
37
|
+
* @param {(bucket: string) => any} [cacheFor]
|
|
38
|
+
* @param {any} [logger]
|
|
39
|
+
* @returns {Promise<any[]>} parents(동일 참조).
|
|
40
|
+
*/
|
|
41
|
+
export function attachOne(parents: any[], Model: any, fk: string, as: string, opts?: {
|
|
42
|
+
where?: Record<string, any>;
|
|
43
|
+
select?: string[];
|
|
44
|
+
orderBy?: any;
|
|
45
|
+
childKey?: string;
|
|
46
|
+
cache?: {
|
|
47
|
+
key: string;
|
|
48
|
+
ttl?: number;
|
|
49
|
+
bucket: string;
|
|
50
|
+
};
|
|
51
|
+
fetch?: (ids: any[]) => Promise<any[]>;
|
|
52
|
+
}, cacheFor?: (bucket: string) => any, logger?: any): Promise<any[]>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rows` 각 항목에 `fn` 을 적용한다. `fn` 반환값이 `undefined` 가 아니면 그 값으로 교체하고,
|
|
3
|
+
* `undefined` 면 (제자리 mutate 된) 원본 행을 유지한다.
|
|
4
|
+
*
|
|
5
|
+
* @param {any[]} rows - 대상 배열(제자리 mutate 후 반환).
|
|
6
|
+
* @param {(row: any) => Promise<any>} fn - 행별 콜백.
|
|
7
|
+
* @param {number | { concurrency?: number, settled?: boolean, cache?: { key: (row: any) => string, ttl?: number, bucket: string } }} [opts]
|
|
8
|
+
* number 면 `concurrency`. settled:true 면 일부 실패해도 진행(실패 행은 그대로 두고 경고 로그).
|
|
9
|
+
* @param {(bucket: string) => any} [cacheFor] - 버킷→캐시 어댑터 resolver(cache 옵션 쓸 때만 필요).
|
|
10
|
+
* @param {any} [logger] - settled 실패 경고용(P4/P5 — silent skip 금지).
|
|
11
|
+
* @returns {Promise<any[]>} rows(동일 참조).
|
|
12
|
+
*/
|
|
13
|
+
export function enrich(rows: any[], fn: (row: any) => Promise<any>, opts?: number | {
|
|
14
|
+
concurrency?: number;
|
|
15
|
+
settled?: boolean;
|
|
16
|
+
cache?: {
|
|
17
|
+
key: (row: any) => string;
|
|
18
|
+
ttl?: number;
|
|
19
|
+
bucket: string;
|
|
20
|
+
};
|
|
21
|
+
}, cacheFor?: (bucket: string) => any, logger?: any): Promise<any[]>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {Record<string, () => Promise<any>> | Array<() => Promise<any>>} specs - thunk 의 객체/배열.
|
|
3
|
+
* @param {number | { concurrency?: number, settled?: boolean }} [opts]
|
|
4
|
+
* number 면 `concurrency`(기본 무제한). settled:true 면 `{ status, value | reason }` 로 감싸 모두 완주.
|
|
5
|
+
* @returns {Promise<any>} specs 와 동일 형태의 결과.
|
|
6
|
+
*/
|
|
7
|
+
export function parallel(specs: Record<string, () => Promise<any>> | Array<() => Promise<any>>, opts?: number | {
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
settled?: boolean;
|
|
10
|
+
}): Promise<any>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 바운디드 동시성 실행기 — service-helpers(enrich/parallel)가 공유한다(ADR-230).
|
|
3
|
+
*
|
|
4
|
+
* 신규 dep 없이(`p-limit` 미도입) 입력 순서를 보존하며 동시 실행 수만 제한한다. 두 모드:
|
|
5
|
+
* - `settled:false`(기본) — 첫 worker rejection 에서 전체를 reject 한다(Promise.all 의미). 이미
|
|
6
|
+
* 시작된 worker 는 자연 종료되지만 결과는 버려진다.
|
|
7
|
+
* - `settled:true` — 각 항목을 `{ status, value | reason }` 로 감싸 **모두 완주**한다(allSettled 의미).
|
|
8
|
+
*
|
|
9
|
+
* @module core/service-helpers/pool
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* @template T, R
|
|
13
|
+
* @typedef {{ status: 'fulfilled', value: R } | { status: 'rejected', reason: any }} Settled
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* `items` 를 `worker` 로 매핑하되 동시 실행 수를 `concurrency` 로 제한한다. 결과 배열은 입력 순서 보존.
|
|
17
|
+
*
|
|
18
|
+
* @template T, R
|
|
19
|
+
* @param {T[]} items - 매핑 대상.
|
|
20
|
+
* @param {(item: T, index: number) => Promise<R>} worker - 각 항목 처리(반환값이 결과).
|
|
21
|
+
* @param {{ concurrency?: number, settled?: boolean }} [opts] - `concurrency` 미지정/비양수면 무제한(=항목 수).
|
|
22
|
+
* @returns {Promise<Array<R | Settled<T, R>>>} settled 면 descriptor 배열, 아니면 결과 배열.
|
|
23
|
+
*/
|
|
24
|
+
export function runPool<T, R>(items: T[], worker: (item: T, index: number) => Promise<R>, opts?: {
|
|
25
|
+
concurrency?: number;
|
|
26
|
+
settled?: boolean;
|
|
27
|
+
}): Promise<Array<R | Settled<T, R>>>;
|
|
28
|
+
export type Settled<T, R> = {
|
|
29
|
+
status: "fulfilled";
|
|
30
|
+
value: R;
|
|
31
|
+
} | {
|
|
32
|
+
status: "rejected";
|
|
33
|
+
reason: any;
|
|
34
|
+
};
|
package/types/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export { registerAspPlugin } from "./lib/asp/plugin.js";
|
|
|
18
18
|
export { MegaAspTerminator } from "./lib/asp/ws-terminator.js";
|
|
19
19
|
export { normalizeAspConfig } from "./lib/asp/config.js";
|
|
20
20
|
export { MegaHubLink } from "./core/hub-link.js";
|
|
21
|
-
export { MegaApp, MegaServer, Router, loadAndValidateConfig, loadRoutes, bootApp, buildBootContext, getApp, hasApp, MegaService, MegaCluster, wrapEnvelope, errorEnvelope, buildErrorHandler, ajvErrorToValidationError, MegaWebSocketController, createWsMessage, validateWsMessage, parseWsMessage, generateMessageId, WS_MESSAGE_SCHEMA, WS_PROTOCOL_VERSION, WS_TYPE_PATTERN, buildHttpCtx, buildAdapterAccessors, registerSession, generateSid, readSession, createSessionStore, SESSION_STORE_DRIVERS, createSessionCleanupSchedule } from "./core/index.js";
|
|
21
|
+
export { MegaApp, MegaServer, Router, loadAndValidateConfig, loadRoutes, bootApp, buildBootContext, getApp, hasApp, MegaService, attachMany, attachOne, enrich, parallel, MegaCluster, wrapEnvelope, errorEnvelope, buildErrorHandler, ajvErrorToValidationError, MegaWebSocketController, createWsMessage, validateWsMessage, parseWsMessage, generateMessageId, WS_MESSAGE_SCHEMA, WS_PROTOCOL_VERSION, WS_TYPE_PATTERN, buildHttpCtx, buildAdapterAccessors, registerSession, generateSid, readSession, createSessionStore, SESSION_STORE_DRIVERS, createSessionCleanupSchedule } from "./core/index.js";
|
|
22
22
|
export { runCli, parseArgs, runWorkerHost, runSchedulerHost, dispatchPluginCommand, USAGE } from "./cli/index.js";
|
|
23
23
|
export { MegaHttpError, MegaValidationError, MegaAuthError, MegaForbiddenError, MegaNotFoundError, MegaConflictError, MegaInternalError } from "./errors/http-errors.js";
|
|
24
24
|
export { buildLogger, buildLoggerOptions, buildTargets } from "./lib/mega-logger.js";
|