mega-framework 0.1.6 → 0.1.7

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.
Files changed (233) hide show
  1. package/bin/mega-ws-hub.js +2 -2
  2. package/package.json +32 -8
  3. package/sample/crud/.env +1 -1
  4. package/sample/crud/.env.example +1 -1
  5. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  6. package/sample/crud/.mega/journal/snapshot.json +261 -0
  7. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  8. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  9. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  10. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  11. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  12. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  13. package/sample/crud/apps/main/models/note-model.js +79 -0
  14. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  15. package/sample/crud/apps/main/models/user-model.js +146 -0
  16. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  17. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  18. package/sample/crud/apps/main/routes/users.js +55 -10
  19. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  20. package/sample/crud/apps/main/services/auth-service.js +39 -24
  21. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  22. package/sample/crud/apps/main/services/note-service.js +6 -6
  23. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  24. package/sample/crud/apps/main/services/user-service.js +62 -21
  25. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  26. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  27. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  28. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  29. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  30. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  31. package/sample/crud/mega.config.js +3 -2
  32. package/sample/crud/package.json +1 -1
  33. package/sample/crud/scripts/start-ws-hub.sh +2 -2
  34. package/sample/simple/package.json +2 -2
  35. package/src/adapters/adapter-manager.js +2 -1
  36. package/src/adapters/adapter-options.js +30 -0
  37. package/src/adapters/maria-adapter.js +26 -3
  38. package/src/adapters/mega-db-adapter.js +7 -1
  39. package/src/adapters/mongo-adapter.js +19 -1
  40. package/src/adapters/postgres-adapter.js +25 -2
  41. package/src/adapters/sqlite-adapter.js +20 -1
  42. package/src/cli/commands/new.js +13 -3
  43. package/src/cli/commands/scaffold.js +137 -33
  44. package/src/cli/generators/index.js +82 -2
  45. package/src/cli/index.js +353 -100
  46. package/src/core/ajv-mapper.js +27 -2
  47. package/src/core/boot.js +464 -245
  48. package/src/core/cluster-metrics.js +13 -4
  49. package/src/core/ctx-builder.js +6 -2
  50. package/src/core/envelope.js +112 -12
  51. package/src/core/hub-link.js +65 -4
  52. package/src/core/i18n.js +11 -1
  53. package/src/core/index.js +6 -2
  54. package/src/core/mega-app.js +201 -463
  55. package/src/core/mega-cluster.js +4 -1
  56. package/src/core/mega-server.js +40 -9
  57. package/src/core/migration/dialect-registry.js +107 -0
  58. package/src/core/migration/dialects/README.md +62 -0
  59. package/src/core/migration/dialects/maria.js +496 -0
  60. package/src/core/migration/dialects/mongo.js +824 -0
  61. package/src/core/migration/dialects/postgres.js +563 -0
  62. package/src/core/migration/dialects/sqlite.js +476 -0
  63. package/src/core/migration/differ.js +456 -0
  64. package/src/core/migration/generate.js +508 -0
  65. package/src/core/migration/journal.js +167 -0
  66. package/src/core/migration/model-scan.js +84 -0
  67. package/src/core/migration/mongo-migration-db.js +97 -0
  68. package/src/core/migration/schema-builder.js +400 -0
  69. package/src/core/migration/schema-validator.js +315 -0
  70. package/src/core/migration-lock.js +205 -0
  71. package/src/core/migration-runner.js +166 -38
  72. package/src/core/multipart.js +28 -5
  73. package/src/core/pipeline.js +129 -0
  74. package/src/core/router.js +70 -65
  75. package/src/core/security.js +67 -9
  76. package/src/core/workers-manager.js +12 -1
  77. package/src/core/ws-cluster.js +10 -3
  78. package/src/core/ws-message.js +48 -4
  79. package/src/core/ws-presence.js +624 -0
  80. package/src/core/ws-roster.js +4 -1
  81. package/src/core/ws-upgrade.js +118 -12
  82. package/src/index.js +1 -1
  83. package/src/lib/hub-protocol.js +29 -0
  84. package/src/lib/mega-health.js +25 -4
  85. package/src/lib/mega-job-queue.js +98 -21
  86. package/src/lib/mega-job.js +29 -0
  87. package/src/lib/mega-metrics.js +3 -12
  88. package/src/lib/mega-plugin.js +34 -3
  89. package/src/lib/mega-schedule.js +40 -22
  90. package/src/lib/mega-shutdown.js +114 -39
  91. package/src/lib/mega-tracing.js +66 -19
  92. package/src/lib/mega-worker.js +5 -1
  93. package/src/lib/otel-resource.js +36 -0
  94. package/src/{cli → lib}/ws-hub.js +51 -8
  95. package/src/models/crud-sql-builder.js +133 -0
  96. package/src/models/mega-model.js +82 -2
  97. package/src/models/model-crud.js +483 -0
  98. package/src/models/mongo-crud.js +285 -0
  99. package/templates/model/code-mongo.tpl +35 -0
  100. package/templates/model/code.tpl +15 -1
  101. package/templates/model/test-mongo.tpl +38 -0
  102. package/templates/model/test.tpl +4 -0
  103. package/types/adapters/adapter-manager.d.ts +95 -0
  104. package/types/adapters/adapter-options.d.ts +91 -0
  105. package/types/adapters/file-adapter.d.ts +94 -0
  106. package/types/adapters/file-session-adapter.d.ts +101 -0
  107. package/types/adapters/index.d.ts +20 -0
  108. package/types/adapters/maria-adapter.d.ts +115 -0
  109. package/types/adapters/mega-adapter.d.ts +215 -0
  110. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  111. package/types/adapters/mega-cache-adapter.d.ts +47 -0
  112. package/types/adapters/mega-db-adapter.d.ts +47 -0
  113. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  114. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  115. package/types/adapters/mega-session-adapter.d.ts +32 -0
  116. package/types/adapters/mongo-adapter.d.ts +139 -0
  117. package/types/adapters/nats-adapter.d.ts +108 -0
  118. package/types/adapters/postgres-adapter.d.ts +139 -0
  119. package/types/adapters/redis-adapter.d.ts +70 -0
  120. package/types/adapters/redis-session-adapter.d.ts +82 -0
  121. package/types/adapters/redlock-adapter.d.ts +149 -0
  122. package/types/adapters/registry.d.ts +46 -0
  123. package/types/adapters/sqlite-adapter.d.ts +106 -0
  124. package/types/auth/index.d.ts +24 -0
  125. package/types/cli/commands/console-cmd.d.ts +37 -0
  126. package/types/cli/commands/new.d.ts +16 -0
  127. package/types/cli/commands/routes.d.ts +36 -0
  128. package/types/cli/commands/scaffold.d.ts +78 -0
  129. package/types/cli/commands/test-cmd.d.ts +14 -0
  130. package/types/cli/generators/index.d.ts +112 -0
  131. package/types/cli/index.d.ts +249 -0
  132. package/types/cli/template-engine.d.ts +40 -0
  133. package/types/core/ajv-mapper.d.ts +27 -0
  134. package/types/core/boot.d.ts +233 -0
  135. package/types/core/cluster-metrics.d.ts +52 -0
  136. package/types/core/config-loader.d.ts +13 -0
  137. package/types/core/config-validator.d.ts +30 -0
  138. package/types/core/ctx-builder.d.ts +80 -0
  139. package/types/core/envelope.d.ts +79 -0
  140. package/types/core/error-mapper.d.ts +17 -0
  141. package/types/core/formbody.d.ts +41 -0
  142. package/types/core/hub-link.d.ts +264 -0
  143. package/types/core/i18n.d.ts +178 -0
  144. package/types/core/index.d.ts +28 -0
  145. package/types/core/mega-app.d.ts +529 -0
  146. package/types/core/mega-cluster.d.ts +104 -0
  147. package/types/core/mega-server.d.ts +91 -0
  148. package/types/core/mega-service.d.ts +31 -0
  149. package/types/core/migration/dialect-registry.d.ts +22 -0
  150. package/types/core/migration/dialects/maria.d.ts +99 -0
  151. package/types/core/migration/dialects/mongo.d.ts +89 -0
  152. package/types/core/migration/dialects/postgres.d.ts +117 -0
  153. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  154. package/types/core/migration/differ.d.ts +47 -0
  155. package/types/core/migration/generate.d.ts +56 -0
  156. package/types/core/migration/journal.d.ts +52 -0
  157. package/types/core/migration/model-scan.d.ts +19 -0
  158. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  159. package/types/core/migration/schema-builder.d.ts +197 -0
  160. package/types/core/migration/schema-validator.d.ts +20 -0
  161. package/types/core/migration-lock.d.ts +33 -0
  162. package/types/core/migration-runner.d.ts +101 -0
  163. package/types/core/multipart.d.ts +86 -0
  164. package/types/core/openapi.d.ts +62 -0
  165. package/types/core/pipeline.d.ts +92 -0
  166. package/types/core/router.d.ts +159 -0
  167. package/types/core/routes-loader.d.ts +21 -0
  168. package/types/core/scope-registry.d.ts +14 -0
  169. package/types/core/security.d.ts +77 -0
  170. package/types/core/services-loader.d.ts +27 -0
  171. package/types/core/session-cleanup-schedule.d.ts +19 -0
  172. package/types/core/session-store.d.ts +18 -0
  173. package/types/core/session.d.ts +77 -0
  174. package/types/core/static-assets.d.ts +73 -0
  175. package/types/core/template.d.ts +106 -0
  176. package/types/core/workers-manager.d.ts +79 -0
  177. package/types/core/ws-cluster.d.ts +208 -0
  178. package/types/core/ws-compression.d.ts +112 -0
  179. package/types/core/ws-controller.d.ts +65 -0
  180. package/types/core/ws-message.d.ts +106 -0
  181. package/types/core/ws-presence.d.ts +273 -0
  182. package/types/core/ws-roster.d.ts +96 -0
  183. package/types/core/ws-upgrade.d.ts +231 -0
  184. package/types/errors/config-error.d.ts +10 -0
  185. package/types/errors/http-errors.d.ts +120 -0
  186. package/types/errors/index.d.ts +3 -0
  187. package/types/errors/mega-error.d.ts +32 -0
  188. package/types/index.d.ts +39 -0
  189. package/types/lib/asp/config.d.ts +49 -0
  190. package/types/lib/asp/crypto.d.ts +43 -0
  191. package/types/lib/asp/errors.d.ts +30 -0
  192. package/types/lib/asp/nonce-cache.d.ts +52 -0
  193. package/types/lib/asp/plugin.d.ts +30 -0
  194. package/types/lib/asp/ws-terminator.d.ts +45 -0
  195. package/types/lib/env-mapper.d.ts +14 -0
  196. package/types/lib/hub-protocol.d.ts +106 -0
  197. package/types/lib/index.d.ts +22 -0
  198. package/types/lib/logger/telegram-core.d.ts +104 -0
  199. package/types/lib/logger/telegram-transport.d.ts +45 -0
  200. package/types/lib/mega-brute-force.d.ts +66 -0
  201. package/types/lib/mega-circuit-breaker.d.ts +241 -0
  202. package/types/lib/mega-cron.d.ts +66 -0
  203. package/types/lib/mega-hash.d.ts +32 -0
  204. package/types/lib/mega-health.d.ts +41 -0
  205. package/types/lib/mega-job-queue.d.ts +176 -0
  206. package/types/lib/mega-job-worker.d.ts +130 -0
  207. package/types/lib/mega-job.d.ts +138 -0
  208. package/types/lib/mega-logger.d.ts +45 -0
  209. package/types/lib/mega-metrics.d.ts +285 -0
  210. package/types/lib/mega-plugin.d.ts +245 -0
  211. package/types/lib/mega-retry.d.ts +85 -0
  212. package/types/lib/mega-schedule.d.ts +260 -0
  213. package/types/lib/mega-shutdown.d.ts +135 -0
  214. package/types/lib/mega-tracing.d.ts +224 -0
  215. package/types/lib/mega-worker.d.ts +127 -0
  216. package/types/lib/otel-resource.d.ts +16 -0
  217. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  218. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  219. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  220. package/types/lib/ws-hub.d.ts +234 -0
  221. package/types/models/crud-sql-builder.d.ts +48 -0
  222. package/types/models/index.d.ts +1 -0
  223. package/types/models/mega-model.d.ts +138 -0
  224. package/types/models/model-crud.d.ts +82 -0
  225. package/types/models/mongo-crud.d.ts +59 -0
  226. package/types/test/index.d.ts +84 -0
  227. package/.env +0 -127
  228. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  229. package/sample/crud/apps/main/models/note.js +0 -71
  230. package/sample/crud/apps/main/models/user.js +0 -86
  231. package/sample/crud/package-lock.json +0 -5665
  232. package/sample/crud/yarn.lock +0 -2142
  233. package/sample/simple/package-lock.json +0 -1851
@@ -73,9 +73,9 @@
73
73
  * @module adapters/maria-adapter
74
74
  */
75
75
  import { AsyncLocalStorage } from 'node:async_hooks'
76
- import { MegaValidationError } from '../errors/http-errors.js'
76
+ import { MegaInternalError, MegaValidationError } from '../errors/http-errors.js'
77
77
  import { MegaDbAdapter } from './mega-db-adapter.js'
78
- import { resolveConnection, normalizePool, assertPlainObject, MARIA_POOL_SPEC } from './adapter-options.js'
78
+ import { resolveConnection, normalizePool, assertPlainObject, resolveTxIsolation, MARIA_POOL_SPEC } from './adapter-options.js'
79
79
  import * as Registry from './registry.js'
80
80
 
81
81
  /**
@@ -165,6 +165,11 @@ function resolveBigIntStrategy(options) {
165
165
  }
166
166
 
167
167
  export class MegaMariaAdapter extends MegaDbAdapter {
168
+ /** driver 식별자 — dialect 디스패치(getDialect)·CRUD 의 단일 출처(ADR-212). @returns {'mariadb'} */
169
+ get driver() {
170
+ return 'mariadb'
171
+ }
172
+
168
173
  /** @type {import('mariadb').Pool | null} 연결된 Pool 인스턴스 (connect 후에만). */
169
174
  #pool = null
170
175
  /** @type {Record<string, unknown>} _connect 의 `createPool()` 인자 (url 도 discrete 로 파싱해 항상 객체, ADR-109). */
@@ -318,23 +323,41 @@ export class MegaMariaAdapter extends MegaDbAdapter {
318
323
  * RELEASE SAVEPOINT) 후 반환값을 그대로 돌려주고, throw 시 rollback(또는 ROLLBACK TO SAVEPOINT)
319
324
  * 후 원본 에러 re-throw.
320
325
  *
326
+ * `opts.isolation`(ADR-190) — top-level 이면 `beginTransaction()` **직전에** 같은 연결에서
327
+ * `SET TRANSACTION ISOLATION LEVEL` 을 실행한다(MariaDB 의 `SET TRANSACTION` 은 같은 세션의
328
+ * **다음** 트랜잭션 1회에만 적용 — 공식 문서). 격리수준은 트랜잭션 시작 시 고정되므로
329
+ * nested(SAVEPOINT) 호출에 지정하면 `adapter.nested_isolation_unsupported` 로 거부한다.
330
+ *
321
331
  * hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
322
332
  *
323
333
  * @template T
324
334
  * @param {(conn: import('mariadb').PoolConnection) => Promise<T> | T} fn
335
+ * @param {{ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' }} [opts] - 트랜잭션 옵션(ADR-190).
325
336
  * @returns {Promise<T>}
337
+ * @throws {MegaInternalError} `adapter.nested_isolation_unsupported` - nested 호출에 isolation 지정.
326
338
  */
327
- async withTransaction(fn) {
339
+ async withTransaction(fn, opts) {
340
+ // 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
341
+ const isolationSql = resolveTxIsolation(opts?.isolation, 'mariadb')
328
342
  return this._instrument('withTransaction', { table: undefined }, async () => {
329
343
  const existing = this.#txContext.getStore()
330
344
  // nested — 진행 중 트랜잭션이 있으면 같은 연결에 SAVEPOINT 를 건다(Sqlite 와 분기).
331
345
  if (existing !== undefined) {
346
+ if (isolationSql !== undefined) {
347
+ throw new MegaInternalError(
348
+ 'adapter.nested_isolation_unsupported',
349
+ `${this.constructor.name}.withTransaction() cannot set an isolation level on a nested (SAVEPOINT) transaction — isolation is fixed when the top-level transaction starts (ADR-190).`,
350
+ { details: { adapter: this.constructor.name, isolation: opts?.isolation } },
351
+ )
352
+ }
332
353
  return this.#runSavepoint(existing, fn)
333
354
  }
334
355
  // top-level — 풀에서 연결 1개 획득 후 beginTransaction/commit/rollback.
335
356
  const pool = /** @type {import('mariadb').Pool} */ (this.#pool)
336
357
  const conn = await pool.getConnection()
337
358
  try {
359
+ // SET TRANSACTION 은 다음 트랜잭션 1회에만 적용 — 반드시 beginTransaction 직전, 같은 연결에서.
360
+ if (isolationSql !== undefined) await conn.query(`SET TRANSACTION ISOLATION LEVEL ${isolationSql}`)
338
361
  await conn.beginTransaction()
339
362
  let result
340
363
  try {
@@ -30,11 +30,17 @@ export class MegaDbAdapter extends MegaAdapter {
30
30
  * driver 별 구현 (postgres `BEGIN/COMMIT/ROLLBACK`, MongoDB `session.withTransaction`).
31
31
  * nested 호출은 driver 별 (postgres SAVEPOINT, MongoDB throw `adapter.nested_transaction_unsupported`).
32
32
  *
33
+ * `opts.isolation`(ADR-190) — SQL 격리수준 옵트인. 미지정이면 driver 디폴트. driver 별 지원:
34
+ * postgres/maria 는 top-level 트랜잭션에 `SET TRANSACTION ISOLATION LEVEL` 로 반영(nested 엔 불가 —
35
+ * `adapter.nested_isolation_unsupported`), sqlite 는 항상 SERIALIZABLE 동작이라 'serializable' 만 수용,
36
+ * mongodb 는 SQL 격리수준 개념이 없어 지정 시 `adapter.invalid_option`.
37
+ *
33
38
  * @template T
34
39
  * @param {(db: any) => Promise<T>} _fn - 트랜잭션 컨텍스트의 `db` 를 받는 콜백.
40
+ * @param {{ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' }} [_opts] - 트랜잭션 옵션(ADR-190).
35
41
  * @returns {Promise<T>}
36
42
  */
37
- async withTransaction(_fn) {
43
+ async withTransaction(_fn, _opts) {
38
44
  return this._notImplemented('withTransaction')
39
45
  }
40
46
 
@@ -140,6 +140,11 @@ function buildMongoUri({ host, port, user, password }) {
140
140
  */
141
141
 
142
142
  export class MegaMongoAdapter extends MegaDbAdapter {
143
+ /** driver 식별자 — dialect 디스패치·CRUD 의 단일 출처(ADR-212). mongo 는 SQL CRUD 미지원(P3). @returns {'mongodb'} */
144
+ get driver() {
145
+ return 'mongodb'
146
+ }
147
+
143
148
  /** @type {import('mongodb').MongoClient | null} 연결된 MongoClient (connect 후에만). */
144
149
  #client = null
145
150
  /** @type {import('mongodb').Db | null} 선택된 Db 인스턴스 (connect 후에만). */
@@ -320,14 +325,27 @@ export class MegaMongoAdapter extends MegaDbAdapter {
320
325
  * throw 시 abort 후 원본 에러를 re-throw 한다. nested 호출은 ALS 로 감지해 거부(Sqlite 와 동일).
321
326
  * `session.endSession()` 은 `finally` 에서 반드시 호출(leak 방지).
322
327
  *
328
+ * `opts.isolation`(ADR-190) — MongoDB 트랜잭션에는 SQL 격리수준 개념이 없다(snapshot 의미는 driver/
329
+ * readConcern 관할). 지정 시 `adapter.invalid_option` 으로 명시 거부한다(조용히 무시 X — P4).
330
+ *
323
331
  * hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
324
332
  *
325
333
  * @template T
326
334
  * @param {(db: import('mongodb').Db, session: import('mongodb').ClientSession) => Promise<T> | T} fn
335
+ * @param {{ isolation?: never }} [opts] - 트랜잭션 옵션(ADR-190) — mongodb 는 isolation 미지원.
327
336
  * @returns {Promise<T>}
328
337
  * @throws {MegaInternalError} `adapter.nested_transaction_unsupported` - 이미 트랜잭션 진행 중.
338
+ * @throws {MegaValidationError} `adapter.invalid_option` - isolation 지정(미지원).
329
339
  */
330
- async withTransaction(fn) {
340
+ async withTransaction(fn, opts) {
341
+ // 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
342
+ if (opts?.isolation !== undefined) {
343
+ throw new MegaValidationError(
344
+ 'adapter.invalid_option',
345
+ 'mongodb: SQL isolation levels do not apply to MongoDB transactions — remove the "isolation" option (snapshot semantics are managed by the driver/readConcern).',
346
+ { details: { driver: 'mongodb', option: 'isolation', value: opts.isolation } },
347
+ )
348
+ }
331
349
  return this._instrument('withTransaction', { table: undefined }, async () => {
332
350
  // nested 거부 — 진행 중 트랜잭션이 있으면 즉시 throw (Sqlite 와 동일 정책·에러 코드, ADR-108).
333
351
  // 검사를 driver 호출 전에 두어, standalone(트랜잭션 미지원)에서도 nested 거부는 동작한다.
@@ -67,8 +67,9 @@
67
67
  * @module adapters/postgres-adapter
68
68
  */
69
69
  import { AsyncLocalStorage } from 'node:async_hooks'
70
+ import { MegaInternalError } from '../errors/http-errors.js'
70
71
  import { MegaDbAdapter } from './mega-db-adapter.js'
71
- import { resolveConnection, normalizePool, assertPlainObject, PG_POOL_SPEC } from './adapter-options.js'
72
+ import { resolveConnection, normalizePool, assertPlainObject, resolveTxIsolation, PG_POOL_SPEC } from './adapter-options.js'
72
73
  import * as Registry from './registry.js'
73
74
 
74
75
  /**
@@ -89,6 +90,11 @@ import * as Registry from './registry.js'
89
90
  */
90
91
 
91
92
  export class MegaPostgresAdapter extends MegaDbAdapter {
93
+ /** driver 식별자 — dialect 디스패치(getDialect)·CRUD 의 단일 출처(ADR-212). @returns {'postgres'} */
94
+ get driver() {
95
+ return 'postgres'
96
+ }
97
+
92
98
  /** @type {import('pg').Pool | null} 연결된 Pool 인스턴스 (connect 후에만). */
93
99
  #pool = null
94
100
  /** @type {import('pg').PoolConfig} _connect 에서 `new Pool()` 에 넘길 설정 (생성자에서 구성·고정). */
@@ -234,17 +240,32 @@ export class MegaPostgresAdapter extends MegaDbAdapter {
234
240
  * RELEASE SAVEPOINT) 후 `fn` 반환값을 그대로 돌려주고, throw 시 ROLLBACK(또는 ROLLBACK TO
235
241
  * SAVEPOINT) 후 원본 에러를 re-throw 한다.
236
242
  *
243
+ * `opts.isolation`(ADR-190) — top-level 이면 `BEGIN` 직후 `SET TRANSACTION ISOLATION LEVEL` 로
244
+ * 반영한다(PostgreSQL 은 트랜잭션 첫 쿼리 전이라 유효). 격리수준은 트랜잭션 시작 시 고정되므로
245
+ * nested(SAVEPOINT) 호출에 지정하면 `adapter.nested_isolation_unsupported` 로 거부한다.
246
+ *
237
247
  * hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
238
248
  *
239
249
  * @template T
240
250
  * @param {(client: import('pg').PoolClient) => Promise<T> | T} fn
251
+ * @param {{ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' }} [opts] - 트랜잭션 옵션(ADR-190).
241
252
  * @returns {Promise<T>}
253
+ * @throws {MegaInternalError} `adapter.nested_isolation_unsupported` - nested 호출에 isolation 지정.
242
254
  */
243
- async withTransaction(fn) {
255
+ async withTransaction(fn, opts) {
256
+ // 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
257
+ const isolationSql = resolveTxIsolation(opts?.isolation, 'postgres')
244
258
  return this._instrument('withTransaction', { table: undefined }, async () => {
245
259
  const existing = this.#txContext.getStore()
246
260
  // nested — 진행 중 트랜잭션이 있으면 같은 클라이언트에 SAVEPOINT 를 건다(Sqlite 와 분기).
247
261
  if (existing !== undefined) {
262
+ if (isolationSql !== undefined) {
263
+ throw new MegaInternalError(
264
+ 'adapter.nested_isolation_unsupported',
265
+ `${this.constructor.name}.withTransaction() cannot set an isolation level on a nested (SAVEPOINT) transaction — isolation is fixed when the top-level transaction starts (ADR-190).`,
266
+ { details: { adapter: this.constructor.name, isolation: opts?.isolation } },
267
+ )
268
+ }
248
269
  return this.#runSavepoint(existing, fn)
249
270
  }
250
271
  // top-level — 풀에서 클라이언트 1개 획득 후 BEGIN/COMMIT/ROLLBACK.
@@ -252,6 +273,8 @@ export class MegaPostgresAdapter extends MegaDbAdapter {
252
273
  const client = await pool.connect()
253
274
  try {
254
275
  await client.query('BEGIN')
276
+ // 격리수준은 트랜잭션 첫 쿼리 전에만 설정 가능 — BEGIN 직후 반영(화이트리스트 SQL 조각이라 안전).
277
+ if (isolationSql !== undefined) await client.query(`SET TRANSACTION ISOLATION LEVEL ${isolationSql}`)
255
278
  let result
256
279
  try {
257
280
  result = await this.#txContext.run({ client, depth: 0 }, () => fn(client))
@@ -65,6 +65,11 @@ import * as Registry from './registry.js'
65
65
  */
66
66
 
67
67
  export class MegaSqliteAdapter extends MegaDbAdapter {
68
+ /** driver 식별자 — dialect 디스패치(getDialect)·CRUD 의 단일 출처(ADR-212). @returns {'sqlite'} */
69
+ get driver() {
70
+ return 'sqlite'
71
+ }
72
+
68
73
  /** @type {import('better-sqlite3').Database | null} 연결된 Database 인스턴스 (connect 후에만). */
69
74
  #db = null
70
75
  /** @type {string} DB 파일 경로 또는 ':memory:'. */
@@ -237,14 +242,28 @@ export class MegaSqliteAdapter extends MegaDbAdapter {
237
242
  * 성공 시 COMMIT 후 `fn` 반환값을 그대로 돌려주고, `fn` 이 throw 하면 ROLLBACK 후 원본 에러를
238
243
  * re-throw 한다. nested / 동시 호출은 `db.inTransaction` 으로 감지해 거부(ADR-105).
239
244
  *
245
+ * `opts.isolation`(ADR-190) — SQLite 트랜잭션은 항상 SERIALIZABLE 동작(단일 writer, 공식 문서)이라
246
+ * `'serializable'` 만 수용(no-op)하고 다른 값은 `adapter.invalid_option` 으로 명시 거부한다
247
+ * (조용히 무시하면 사용자가 다른 격리수준이 적용된 줄 오인 — P4).
248
+ *
240
249
  * hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
241
250
  *
242
251
  * @template T
243
252
  * @param {(db: import('better-sqlite3').Database) => Promise<T> | T} fn
253
+ * @param {{ isolation?: 'serializable' }} [opts] - 트랜잭션 옵션(ADR-190).
244
254
  * @returns {Promise<T>}
245
255
  * @throws {MegaInternalError} `adapter.nested_transaction_unsupported` - 이미 트랜잭션 진행 중.
256
+ * @throws {MegaValidationError} `adapter.invalid_option` - 'serializable' 외 isolation 값.
246
257
  */
247
- async withTransaction(fn) {
258
+ async withTransaction(fn, opts) {
259
+ // 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
260
+ if (opts?.isolation !== undefined && opts.isolation !== 'serializable') {
261
+ throw new MegaValidationError(
262
+ 'adapter.invalid_option',
263
+ `sqlite transactions are always SERIALIZABLE — withTransaction "isolation" accepts only 'serializable'. Got ${JSON.stringify(opts.isolation)}.`,
264
+ { details: { driver: 'sqlite', option: 'isolation', value: opts.isolation } },
265
+ )
266
+ }
248
267
  return this._instrument('withTransaction', { table: undefined }, async () => {
249
268
  const db = /** @type {import('better-sqlite3').Database} */ (this.#db)
250
269
  if (db.inTransaction) {
@@ -15,6 +15,7 @@
15
15
  * @module cli/commands/new
16
16
  */
17
17
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { randomBytes } from 'node:crypto'
18
19
  import { dirname, join, relative, resolve, sep } from 'node:path'
19
20
  import { fileURLToPath } from 'node:url'
20
21
  import { nameVariants } from '../template-engine.js'
@@ -76,10 +77,11 @@ function walk(dir, base, acc) {
76
77
  }
77
78
 
78
79
  /**
79
- * 토큰 치환 — `sample-crud` 를 프로젝트명으로, package.json 의 모노레포 dep(`file:../..`)을 실제 버전으로.
80
+ * 토큰 치환 — `sample-crud` 를 프로젝트명으로, package.json 의 모노레포 dep(`file:../..`)을 실제 버전으로,
81
+ * `.env` 의 SESSION_SECRET 을 프로젝트별 신규 시크릿으로.
80
82
  * @param {string} rel - 파일 상대경로.
81
83
  * @param {string} content - 원본 텍스트.
82
- * @param {{ name: string, version: string }} vars
84
+ * @param {{ name: string, version: string, sessionSecret: string }} vars
83
85
  * @returns {string}
84
86
  */
85
87
  function transformText(rel, content, vars) {
@@ -88,6 +90,12 @@ function transformText(rel, content, vars) {
88
90
  // 모노레포 로컬 링크 → 퍼블리시된 프레임워크 버전 핀. 스캐폴드된 프로젝트는 독립 패키지라 file: 가 안 풀린다.
89
91
  out = out.replace('"file:../.."', `"^${vars.version}"`)
90
92
  }
93
+ if (rel === '.env') {
94
+ // 세션 쿠키 HMAC 시크릿은 데모 값을 복제하면 모든 `mega new` 프로젝트가 같은 서명 키를 공유한다
95
+ // (세션 위조·cross-app 쿠키 수용 위험) — 프로젝트마다 신규 생성으로 치환한다. `.env.example` 은
96
+ // placeholder("change-me-...") 그대로 둔다(문서 역할).
97
+ out = out.replace(/^SESSION_SECRET=.*$/m, `SESSION_SECRET=${vars.sessionSecret}`)
98
+ }
91
99
  return out
92
100
  }
93
101
 
@@ -104,7 +112,9 @@ export function scaffoldProject(targetDir, { name, force = false } = {}) {
104
112
  const projectName = nameVariants(name ?? root.split(/[/\\]/).pop() ?? 'mega-app').kebab
105
113
  /** @type {string} */
106
114
  const version = JSON.parse(readFileSync(FRAMEWORK_PKG, 'utf8')).version
107
- const vars = { name: projectName, version }
115
+ // 프로젝트별 세션 시크릿 base64url(영숫자·-·_ 만)이라 .env 값·쉘 복붙에 안전(=/+// 회피).
116
+ const sessionSecret = randomBytes(32).toString('base64url')
117
+ const vars = { name: projectName, version, sessionSecret }
108
118
 
109
119
  /** @type {string[]} */ const written = []
110
120
  /** @type {string[]} */ const skipped = []
@@ -1,19 +1,25 @@
1
1
  // @ts-check
2
2
  /**
3
- * scaffold/dev 명령군 (`new` / `generate`(g) / `routes` / `test` / `console`) commander 로 묶는다
4
- * (ADR-142 — CLI 파서 dep 채택, ADR-123 의 zero-dep 런타임 명령과 공존). 런타임 명령(start/worker/
5
- * scheduler/plugin)은 기존 zero-dep 디스패치(`src/cli/index.js`) 그대로 처리한다.
3
+ * scaffold/dev 명령군 (`new` / `generate`(g) / `routes` / `test` / `console`) commander 등록.
4
+ *
5
+ * ADR-142 commander 를 채택한 명령군이며, ADR-195(commander 전면 일원화) 이후 런타임 명령
6
+ * (start/worker/scheduler/migrate)과 **같은 program 트리**에 등록된다 — `registerScaffoldCommands` 를
7
+ * `cli/index.js` 의 `buildProgram` 이 호출한다. `runScaffoldCommand` 는 scaffold 명령군만 담은 독립
8
+ * program 을 돌리는 기존 진입점으로 유지한다(하위호환·단위 테스트 경계).
6
9
  *
7
10
  * @module cli/commands/scaffold
8
11
  */
9
12
  import { Command } from 'commander'
10
- import { generate, GENERATOR_KINDS } from '../generators/index.js'
13
+ import { existsSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { pathToFileURL } from 'node:url'
16
+ import { generate, generateFromScaffoldDef, GENERATOR_KINDS } from '../generators/index.js'
11
17
  import { scaffoldProject } from './new.js'
12
18
  import { runRoutesCommand } from './routes.js'
13
19
  import { runTestCommand } from './test-cmd.js'
14
20
  import { startConsole } from './console-cmd.js'
15
21
 
16
- /** scaffold/dev 명령 이름(별칭 포함). runCli 가 이 집합으로 라우팅한다. */
22
+ /** scaffold/dev 명령 이름(별칭 포함). */
17
23
  export const SCAFFOLD_COMMANDS = new Set(['new', 'generate', 'g', 'routes', 'test', 'console'])
18
24
 
19
25
  /**
@@ -26,27 +32,52 @@ function reportFiles(out, r, root) {
26
32
  }
27
33
 
28
34
  /**
29
- * scaffold/dev 명령을 실행한다. commander 파싱하되 `process.exit` 부르지 않고 exit code 반환한다
30
- * (runCli 계약 정합).
31
- * @param {string[]} argv - `process.argv.slice(2)` (첫 토큰 = 명령).
35
+ * `g model --adapter <key>` adapter 키/driver 해석 mega.config.js services.databases
36
+ * best-effort 로 읽는다(스캐폴드는 config 가 아직 없는 초기 프로젝트에서도 돌아야 하므로 로드
37
+ * 실패는 fail 아니라 기본값 + 경고 1줄). 미지정 시: 선언 db 가 1개면 그 키, 아니면 'primary'.
38
+ *
39
+ * @param {string} projectRoot @param {string | undefined} explicitKey
40
+ * @param {(msg: string) => void} out
41
+ * @returns {Promise<{ key: string, driver: string | undefined }>}
42
+ */
43
+ async function resolveModelAdapter(projectRoot, explicitKey, out) {
44
+ /** @type {Record<string, any>} */
45
+ let databases = {}
46
+ const configPath = join(projectRoot, 'mega.config.js')
47
+ if (existsSync(configPath)) {
48
+ try {
49
+ const mod = await import(pathToFileURL(configPath).href)
50
+ databases = mod.default?.services?.databases ?? {}
51
+ } catch (err) {
52
+ // config 문법 오류 등 — 스캐폴드는 계속 가능해야 하므로 기본 템플릿으로 폴백하되 명시 안내(P4).
53
+ out(`mega: mega.config.js 를 읽지 못해 adapter driver 를 해석하지 못했습니다(${/** @type {any} */ (err).message}) — SQL 템플릿으로 생성합니다.`)
54
+ }
55
+ }
56
+ const keys = Object.keys(databases)
57
+ const key = explicitKey ?? (keys.length === 1 ? keys[0] : 'primary')
58
+ if (explicitKey !== undefined && keys.length > 0 && !keys.includes(explicitKey)) {
59
+ out(`mega: --adapter '${explicitKey}' 가 services.databases 에 없습니다(선언: [${keys.join(', ')}]) — 키를 그대로 쓰고 SQL 템플릿으로 생성합니다.`)
60
+ }
61
+ return { key, driver: databases[key]?.driver }
62
+ }
63
+
64
+ /**
65
+ * scaffold/dev 명령 5종을 commander program 에 등록한다 — 명령 정의의 단일 정본(ADR-195).
66
+ * action 은 `process.exit` 를 부르지 않고 `setExit(code)` 로 종료 코드를 보고한다(runCli 계약).
67
+ *
68
+ * @param {import('commander').Command} program - 등록 대상 program.
32
69
  * @param {object} deps
33
70
  * @param {(msg: string) => void} deps.out
34
- * @param {(msg: string) => void} deps.err
35
71
  * @param {string} deps.projectRoot - 이미 `--root` 해석된 기준 디렉토리.
36
72
  * @param {{ debug?: Function, info?: Function, warn?: Function }} [deps.logger]
37
- * @returns {Promise<number>} exit code.
73
+ * @param {(kind: string) => Promise<{ dir: string, files: Array<{ path: string, template: string }> } | undefined>} [deps.resolvePluginGenerator] -
74
+ * 빌트인이 아닌 kind 의 플러그인 scaffold manifest 조회(`mega.scaffold.register`, 03-api-spec §11).
75
+ * 호출측(runCli)이 config 로드 + 플러그인 install 을 감싼 함수를 주입한다 — 본 모듈이 cli/index.js 를
76
+ * import 하면 순환이라 주입식으로 푼다. 미주입/미발견이면 unknown kind 에러.
77
+ * @param {(code: number) => void} deps.setExit - 명령 종료 코드 보고 콜백.
78
+ * @returns {void}
38
79
  */
39
- export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }) {
40
- let exitCode = 0
41
- const program = new Command()
42
- program.name('mega').exitOverride()
43
- program.configureOutput({
44
- writeOut: (s) => out(s.replace(/\n+$/, '')),
45
- writeErr: (s) => err(s.replace(/\n+$/, '')),
46
- })
47
- // --root 는 runCli 가 이미 해석함 — commander 가 "unknown option" 으로 막지 않도록 받아만 둔다.
48
- program.option('--root <dir>', '프로젝트 루트(runCli 가 해석)')
49
-
80
+ export function registerScaffoldCommands(program, { out, projectRoot, logger, resolvePluginGenerator, setExit }) {
50
81
  program
51
82
  .command('new <project>')
52
83
  .description('sample/crud 데모앱(14기능) 전체를 빈 폴더에 스캐폴드')
@@ -64,24 +95,54 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
64
95
  program
65
96
  .command('generate <kind> <name>')
66
97
  .alias('g')
67
- .description(`코드+테스트 생성 (kind: ${GENERATOR_KINDS.join('|')})`)
98
+ .description(`코드+테스트 생성 (kind: ${GENERATOR_KINDS.join('|')} 또는 플러그인 등록 generator)`)
68
99
  .option('--app <app>', '대상 앱', 'main')
69
100
  .option('--version <v>', '컨트롤러 API 버전(예 v2, ADR-069)')
70
101
  .option('--kind <adapterKind>', 'adapter 종류(db|cache|bus|session|log)')
102
+ .option('--adapter <key>', 'model 전용 — services.databases 키(driver 가 mongodb 면 mongo 템플릿, 기본: 유일 선언 db 또는 primary)')
71
103
  .option('--lng <lng>', 'locale 언어(기본 en)')
72
104
  .option('--force', '기존 파일 덮어쓰기')
73
- .action((/** @type {string} */ kind, /** @type {string} */ name, /** @type {any} */ opts) => {
74
- const r = generate(kind, name, { app: opts.app, version: opts.version, kind: opts.kind, lng: opts.lng, force: opts.force === true }, projectRoot)
105
+ .action(async (/** @type {string} */ kind, /** @type {string} */ name, /** @type {any} */ opts) => {
106
+ /** @type {{ kind: string, name: string, written: string[], skipped: string[] }} */
107
+ let r
108
+ if (GENERATOR_KINDS.includes(/** @type {any} */ (kind))) {
109
+ /** @type {{ key: string, driver: string | undefined }} */
110
+ let modelAdapter = { key: 'primary', driver: undefined }
111
+ if (kind === 'model') modelAdapter = await resolveModelAdapter(projectRoot, opts.adapter, out)
112
+ r = generate(
113
+ kind,
114
+ name,
115
+ { app: opts.app, version: opts.version, kind: opts.kind, lng: opts.lng, force: opts.force === true, adapter: modelAdapter.key, adapterDriver: modelAdapter.driver },
116
+ projectRoot,
117
+ )
118
+ } else {
119
+ // 빌트인이 아니면 플러그인 등록 generator(manifest) 조회(03-api-spec §11 `mega g <name>` 계약).
120
+ // config 로드 실패(프로젝트 밖 등)는 unknown kind 메시지에 원인을 병기해 오도 없이 보고한다.
121
+ /** @type {{ dir: string, files: Array<{ path: string, template: string }> } | undefined} */
122
+ let def
123
+ try {
124
+ def = await resolvePluginGenerator?.(kind)
125
+ } catch (e) {
126
+ throw new Error(
127
+ `Unknown generator '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')}. ` +
128
+ `(플러그인 generator 확인 실패: ${/** @type {any} */ (e).message ?? e})`,
129
+ )
130
+ }
131
+ if (def === undefined) {
132
+ throw new Error(`Unknown generator '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')} (또는 플러그인 등록 generator).`)
133
+ }
134
+ r = generateFromScaffoldDef(kind, def, name, { app: opts.app, force: opts.force === true }, projectRoot)
135
+ }
75
136
  out(`mega: generated ${r.kind} '${r.name}'`)
76
137
  reportFiles(out, r, projectRoot)
77
- if (r.written.length === 0) exitCode = 1
138
+ if (r.written.length === 0) setExit(1)
78
139
  })
79
140
 
80
141
  program
81
142
  .command('routes')
82
143
  .description('등록된 라우트 트리 출력')
83
144
  .action(async () => {
84
- exitCode = await runRoutesCommand(projectRoot, { out })
145
+ setExit(await runRoutesCommand(projectRoot, { out }))
85
146
  })
86
147
 
87
148
  program
@@ -90,7 +151,7 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
90
151
  .allowUnknownOption()
91
152
  .argument('[args...]', 'vitest 인자')
92
153
  .action(async (/** @type {string[]} */ args) => {
93
- exitCode = await runTestCommand(projectRoot, args ?? [], { out })
154
+ setExit(await runTestCommand(projectRoot, args ?? [], { out }))
94
155
  })
95
156
 
96
157
  program
@@ -99,16 +160,59 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
99
160
  .action(async () => {
100
161
  await startConsole(projectRoot, { logger, out })
101
162
  })
163
+ }
164
+
165
+ /**
166
+ * commander 의 exitOverride 예외를 exit code 로 환산한다 — help/version 출력은 정상 종료(0),
167
+ * CommanderError 는 자체 exitCode, 그 외 에러는 메시지 출력 후 1. commander 가 아닌 에러를 그대로
168
+ * 삼키지 않도록 `rethrowUnknown` 옵션을 둔다(runCli 가 부팅·config 에러를 bin 으로 전파할 때 사용).
169
+ *
170
+ * @param {unknown} e - parseAsync 가 던진 예외.
171
+ * @param {(msg: string) => void} err
172
+ * @param {{ rethrowUnknown?: boolean }} [opts] - true 면 CommanderError 가 아닌 예외를 재throw.
173
+ * @returns {number} exit code.
174
+ */
175
+ export function commanderErrorToExitCode(e, err, { rethrowUnknown = false } = {}) {
176
+ const anyErr = /** @type {any} */ (e)
177
+ if (typeof anyErr?.code === 'string' && anyErr.code.startsWith('commander.')) {
178
+ // help/version 출력은 정상 종료(exitOverride 가 던지는 특수 코드).
179
+ if (anyErr.code === 'commander.helpDisplayed' || anyErr.code === 'commander.help' || anyErr.code === 'commander.version') return 0
180
+ return typeof anyErr.exitCode === 'number' ? anyErr.exitCode : 1
181
+ }
182
+ if (rethrowUnknown) throw e
183
+ err(`mega: ${anyErr?.message ?? e}`)
184
+ return 1
185
+ }
186
+
187
+ /**
188
+ * scaffold/dev 명령만 담은 독립 program 으로 실행한다(하위호환 진입점 — 단위 테스트 경계 유지).
189
+ * commander 로 파싱하되 `process.exit` 를 부르지 않고 exit code 를 반환한다(runCli 계약 정합).
190
+ *
191
+ * @param {string[]} argv - `process.argv.slice(2)` (첫 토큰 = 명령).
192
+ * @param {object} deps
193
+ * @param {(msg: string) => void} deps.out
194
+ * @param {(msg: string) => void} deps.err
195
+ * @param {string} deps.projectRoot - 이미 `--root` 해석된 기준 디렉토리.
196
+ * @param {{ debug?: Function, info?: Function, warn?: Function }} [deps.logger]
197
+ * @param {(kind: string) => Promise<{ dir: string, files: Array<{ path: string, template: string }> } | undefined>} [deps.resolvePluginGenerator]
198
+ * @returns {Promise<number>} exit code.
199
+ */
200
+ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger, resolvePluginGenerator }) {
201
+ let exitCode = 0
202
+ const program = new Command()
203
+ program.name('mega').exitOverride()
204
+ program.configureOutput({
205
+ writeOut: (s) => out(s.replace(/\n+$/, '')),
206
+ writeErr: (s) => err(s.replace(/\n+$/, '')),
207
+ })
208
+ // --root 는 호출측(runCli)이 이미 해석함 — commander 가 "unknown option" 으로 막지 않도록 받아만 둔다.
209
+ program.option('--root <dir>', '프로젝트 루트(호출측이 해석)')
210
+ registerScaffoldCommands(program, { out, projectRoot, logger, resolvePluginGenerator, setExit: (c) => (exitCode = c) })
102
211
 
103
212
  try {
104
213
  await program.parseAsync(argv, { from: 'user' })
105
214
  return exitCode
106
215
  } catch (e) {
107
- // commander 의 help/version 출력은 정상 종료(exitOverride 가 던지는 특수 코드).
108
- const code = /** @type {any} */ (e).exitCode
109
- if (/** @type {any} */ (e).code === 'commander.helpDisplayed' || /** @type {any} */ (e).code === 'commander.help') return 0
110
- if (typeof code === 'number') return code
111
- err(`mega: ${/** @type {any} */ (e).message ?? e}`)
112
- return 1
216
+ return commanderErrorToExitCode(e, err)
113
217
  }
114
218
  }
@@ -38,6 +38,20 @@ export const GENERATOR_KINDS = /** @type {const} */ ([
38
38
  'migration',
39
39
  ])
40
40
 
41
+ /**
42
+ * 플러그인 scaffold manifest 의 토큰 계약(ADR-199) — `files[].path`/`files[].template` 의 `{{token}}`
43
+ * 에 쓸 수 있는 이름과 의미. 빌트인 generator 의 base 토큰과 동일 집합이라 템플릿 작성 관례가 하나다.
44
+ * 미정의 토큰은 renderTemplate 가 throw 한다(P4 — silent 치환 누락 방지).
45
+ * @type {Readonly<Record<string, string>>}
46
+ */
47
+ export const SCAFFOLD_TOKENS = Object.freeze({
48
+ Name: 'PascalCase 이름 (예: userCard → UserCard)',
49
+ name: 'kebab-case 이름 (user-card)',
50
+ camelName: 'camelCase 이름 (userCard)',
51
+ snake: 'snake_case 이름 (user_card)',
52
+ app: '대상 앱 이름 (--app, 기본 main)',
53
+ })
54
+
41
55
  /** adapter `--kind` → 베이스 클래스(mega-framework export 명). */
42
56
  const ADAPTER_BASES = /** @type {Record<string, string>} */ ({
43
57
  db: 'MegaDbAdapter',
@@ -121,8 +135,19 @@ export function planArtifacts(kind, rawName, opts, projectRoot) {
121
135
  }
122
136
 
123
137
  switch (kind) {
124
- case 'model':
125
- return pair({ codeRel: `apps/${app}/models/${v.kebab}.js`, vars: { table: v.snake } })
138
+ case 'model': {
139
+ // --adapter <key>: services.databases 키(static adapter 값). driver mongodb 해석되면
140
+ // mongo 변형 템플릿(_id 자동·도큐먼트 API — ADR-209)을 쓴다. 해석은 scaffold 명령이
141
+ // best-effort 로 수행해 opts.adapterDriver 로 전달한다(config 부재 시 SQL 템플릿 기본).
142
+ const adapter = typeof opts.adapter === 'string' && opts.adapter.length > 0 ? opts.adapter : 'primary'
143
+ const isMongo = opts.adapterDriver === 'mongodb'
144
+ return pair({
145
+ codeRel: `apps/${app}/models/${v.kebab}.js`,
146
+ vars: { table: v.snake, adapter },
147
+ codeTpl: isMongo ? 'code-mongo.tpl' : 'code.tpl',
148
+ testTpl: isMongo ? 'test-mongo.tpl' : 'test.tpl',
149
+ })
150
+ }
126
151
 
127
152
  case 'service':
128
153
  return pair({ codeRel: `apps/${app}/services/${v.kebab}-service.js`, vars: {} })
@@ -347,6 +372,61 @@ export function writeArtifacts(artifacts, { force = false } = {}) {
347
372
  return { written, skipped }
348
373
  }
349
374
 
375
+ /**
376
+ * 플러그인 scaffold manifest(`mega.scaffold.register(name, { dir, files, description? })`,
377
+ * 03-api-spec §11 / ADR-199)를 artifact 목록으로 계획한다 — 빌트인 13종과 같은 plan→write 2단 분리.
378
+ * 토큰 계약은 {@link SCAFFOLD_TOKENS} 가 정본이며 미정의 토큰은 renderTemplate 가 throw(P4).
379
+ * `files[].path` 에도 토큰을 쓸 수 있다(예 `services/{{name}}-service.js`).
380
+ *
381
+ * @param {{ dir: string, files: Array<{ path: string, template: string }> }} def - 플러그인 등록 정의.
382
+ * @param {string} rawName - `mega g <kind> <name>` 의 name.
383
+ * @param {Record<string, any>} opts - { app }.
384
+ * @param {string} projectRoot - 출력 기준 루트. `def.dir` 는 이 루트 상대.
385
+ * @returns {Artifact[]}
386
+ * @throws {Error} def 파일 항목이 `{ path, template }` 문자열 쌍이 아니거나, 출력 경로가 projectRoot 를
387
+ * 벗어나면(경로 탐색 차단 — template.js resolveViewPath 정합) fail-fast.
388
+ */
389
+ export function planScaffoldDef(def, rawName, opts, projectRoot) {
390
+ const app = typeof opts.app === 'string' && opts.app.length > 0 ? opts.app : 'main'
391
+ const v = nameVariants(rawName)
392
+ const vars = { Name: v.pascal, name: v.kebab, camelName: v.camel, snake: v.snake, app }
393
+ const rootAbs = resolve(projectRoot)
394
+ const baseDir = resolve(rootAbs, def.dir)
395
+ /** @type {Artifact[]} */
396
+ const out = []
397
+ for (const file of def.files) {
398
+ if (!file || typeof file.path !== 'string' || file.path.length === 0 || typeof file.template !== 'string') {
399
+ throw new Error(`scaffold def: files entries must be { path: string, template: string }. Got ${JSON.stringify(file)}.`)
400
+ }
401
+ const outAbs = resolve(baseDir, renderTemplate(file.path, vars))
402
+ // 출력은 projectRoot 내부로 제한 — def.dir/path 의 `..` 가 프로젝트 밖에 쓰는 걸 차단.
403
+ if (outAbs !== rootAbs && !outAbs.startsWith(rootAbs + sep)) {
404
+ throw new Error(`scaffold def: output '${file.path}' escapes the project root (path traversal blocked).`)
405
+ }
406
+ out.push({ outAbs, role: 'code', content: renderTemplate(file.template, vars) })
407
+ }
408
+ return out
409
+ }
410
+
411
+ /**
412
+ * 플러그인 등록 scaffold generator 실행 — `mega g <plugin-generator> <name>` 의 본체. 빌트인 `generate`
413
+ * 와 같은 계획 → 쓰기 → 결과 계약(존재 파일 skip, `--force` 덮어쓰기).
414
+ * @param {string} kindName - 플러그인이 등록한 generator 이름(결과 보고용).
415
+ * @param {{ dir: string, files: Array<{ path: string, template: string }> }} def
416
+ * @param {string} rawName
417
+ * @param {object} [opts] - { app, force }
418
+ * @param {string} [projectRoot]
419
+ * @returns {{ kind: string, name: string, written: string[], skipped: string[] }}
420
+ */
421
+ export function generateFromScaffoldDef(kindName, def, rawName, opts = {}, projectRoot = process.cwd()) {
422
+ if (typeof rawName !== 'string' || rawName.trim().length === 0) {
423
+ throw new Error(`mega g ${kindName}: a name is required (e.g. 'mega g ${kindName} users').`)
424
+ }
425
+ const artifacts = planScaffoldDef(def, rawName, /** @type {any} */ (opts), projectRoot)
426
+ const { written, skipped } = writeArtifacts(artifacts, { force: /** @type {any} */ (opts).force === true })
427
+ return { kind: kindName, name: rawName, written, skipped }
428
+ }
429
+
350
430
  /**
351
431
  * `mega g <kind> <name>` 실행 — 계획 → 쓰기 → 결과 반환.
352
432
  * @param {string} kind