mega-framework 0.1.13 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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)
@@ -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)
@@ -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,4 @@
1
+ export { enrich } from "./enrich.js";
2
+ export { parallel } from "./parallel.js";
3
+ export { runPool } from "./pool.js";
4
+ export { attachMany, attachOne } from "./attach.js";
@@ -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";