mega-framework 0.1.6 → 0.1.8

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 (248) hide show
  1. package/README.md +9 -0
  2. package/bin/mega-ws-hub.js +2 -2
  3. package/package.json +33 -9
  4. package/sample/crud/.env +10 -1
  5. package/sample/crud/.env.example +10 -1
  6. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  7. package/sample/crud/.mega/journal/snapshot.json +261 -0
  8. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  9. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  10. package/sample/crud/apps/main/locales/server/en.json +12 -1
  11. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  12. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  13. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  14. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  15. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  16. package/sample/crud/apps/main/models/note-model.js +79 -0
  17. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  18. package/sample/crud/apps/main/models/user-model.js +146 -0
  19. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  20. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  21. package/sample/crud/apps/main/routes/users.js +55 -10
  22. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  23. package/sample/crud/apps/main/services/auth-service.js +39 -24
  24. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  25. package/sample/crud/apps/main/services/note-service.js +6 -6
  26. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  27. package/sample/crud/apps/main/services/user-service.js +62 -21
  28. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  29. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  30. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  31. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  32. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  33. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  34. package/sample/crud/mega.config.js +10 -2
  35. package/sample/crud/package.json +3 -3
  36. package/sample/crud/scripts/start-ws-hub.sh +20 -6
  37. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/adapter-manager.js +2 -1
  40. package/src/adapters/adapter-options.js +44 -3
  41. package/src/adapters/file-adapter.js +9 -5
  42. package/src/adapters/file-session-adapter.js +4 -3
  43. package/src/adapters/maria-adapter.js +33 -7
  44. package/src/adapters/mega-cache-adapter.js +83 -6
  45. package/src/adapters/mega-db-adapter.js +10 -1
  46. package/src/adapters/mongo-adapter.js +40 -8
  47. package/src/adapters/postgres-adapter.js +33 -6
  48. package/src/adapters/redis-adapter.js +7 -3
  49. package/src/adapters/sqlite-adapter.js +26 -3
  50. package/src/cli/commands/console-cmd.js +3 -1
  51. package/src/cli/commands/new.js +13 -3
  52. package/src/cli/commands/scaffold.js +173 -33
  53. package/src/cli/generators/index.js +140 -3
  54. package/src/cli/index.js +437 -155
  55. package/src/cli/watch.js +188 -0
  56. package/src/core/ajv-mapper.js +30 -3
  57. package/src/core/boot.js +464 -245
  58. package/src/core/cluster-metrics.js +13 -4
  59. package/src/core/ctx-builder.js +65 -3
  60. package/src/core/envelope.js +119 -12
  61. package/src/core/hub-link.js +89 -18
  62. package/src/core/i18n.js +11 -1
  63. package/src/core/index.js +7 -3
  64. package/src/core/mega-app.js +253 -505
  65. package/src/core/mega-cluster.js +4 -1
  66. package/src/core/mega-server.js +40 -9
  67. package/src/core/migration/dialect-registry.js +107 -0
  68. package/src/core/migration/dialects/README.md +62 -0
  69. package/src/core/migration/dialects/maria.js +496 -0
  70. package/src/core/migration/dialects/mongo.js +824 -0
  71. package/src/core/migration/dialects/postgres.js +563 -0
  72. package/src/core/migration/dialects/sqlite.js +476 -0
  73. package/src/core/migration/differ.js +456 -0
  74. package/src/core/migration/generate.js +508 -0
  75. package/src/core/migration/journal.js +167 -0
  76. package/src/core/migration/model-scan.js +84 -0
  77. package/src/core/migration/mongo-migration-db.js +97 -0
  78. package/src/core/migration/schema-builder.js +400 -0
  79. package/src/core/migration/schema-validator.js +315 -0
  80. package/src/core/migration-lock.js +205 -0
  81. package/src/core/migration-runner.js +166 -38
  82. package/src/core/multipart.js +28 -5
  83. package/src/core/pipeline.js +131 -0
  84. package/src/core/router.js +70 -65
  85. package/src/core/scope-registry.js +1 -0
  86. package/src/core/security.js +70 -12
  87. package/src/core/session-store.js +14 -1
  88. package/src/core/workers-manager.js +12 -1
  89. package/src/core/ws-cluster.js +10 -3
  90. package/src/core/ws-message.js +48 -4
  91. package/src/core/ws-presence.js +636 -0
  92. package/src/core/ws-roster.js +50 -8
  93. package/src/core/ws-upgrade.js +223 -12
  94. package/src/index.js +1 -1
  95. package/src/lib/hub-protocol.js +29 -0
  96. package/src/lib/mega-circuit-breaker.js +5 -3
  97. package/src/lib/mega-health.js +35 -4
  98. package/src/lib/mega-job-queue.js +151 -34
  99. package/src/lib/mega-job.js +37 -1
  100. package/src/lib/mega-metrics.js +31 -13
  101. package/src/lib/mega-plugin.js +34 -3
  102. package/src/lib/mega-schedule.js +40 -22
  103. package/src/lib/mega-shutdown.js +114 -39
  104. package/src/lib/mega-tracing.js +66 -19
  105. package/src/lib/mega-worker.js +33 -6
  106. package/src/lib/otel-resource.js +36 -0
  107. package/src/{cli → lib}/ws-hub.js +139 -15
  108. package/src/models/crud-sql-builder.js +133 -0
  109. package/src/models/mega-model.js +82 -2
  110. package/src/models/model-crud.js +483 -0
  111. package/src/models/mongo-crud.js +285 -0
  112. package/templates/adr/code.tpl +23 -0
  113. package/templates/model/code-mongo.tpl +35 -0
  114. package/templates/model/code.tpl +15 -1
  115. package/templates/model/test-mongo.tpl +38 -0
  116. package/templates/model/test.tpl +4 -0
  117. package/types/adapters/adapter-manager.d.ts +95 -0
  118. package/types/adapters/adapter-options.d.ts +93 -0
  119. package/types/adapters/file-adapter.d.ts +105 -0
  120. package/types/adapters/file-session-adapter.d.ts +103 -0
  121. package/types/adapters/index.d.ts +20 -0
  122. package/types/adapters/maria-adapter.d.ts +117 -0
  123. package/types/adapters/mega-adapter.d.ts +215 -0
  124. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  125. package/types/adapters/mega-cache-adapter.d.ts +73 -0
  126. package/types/adapters/mega-db-adapter.d.ts +50 -0
  127. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  128. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  129. package/types/adapters/mega-session-adapter.d.ts +32 -0
  130. package/types/adapters/mongo-adapter.d.ts +150 -0
  131. package/types/adapters/nats-adapter.d.ts +108 -0
  132. package/types/adapters/postgres-adapter.d.ts +141 -0
  133. package/types/adapters/redis-adapter.d.ts +78 -0
  134. package/types/adapters/redis-session-adapter.d.ts +82 -0
  135. package/types/adapters/redlock-adapter.d.ts +149 -0
  136. package/types/adapters/registry.d.ts +46 -0
  137. package/types/adapters/sqlite-adapter.d.ts +112 -0
  138. package/types/auth/index.d.ts +24 -0
  139. package/types/cli/commands/console-cmd.d.ts +37 -0
  140. package/types/cli/commands/new.d.ts +16 -0
  141. package/types/cli/commands/routes.d.ts +36 -0
  142. package/types/cli/commands/scaffold.d.ts +78 -0
  143. package/types/cli/commands/test-cmd.d.ts +14 -0
  144. package/types/cli/generators/index.d.ts +122 -0
  145. package/types/cli/index.d.ts +234 -0
  146. package/types/cli/template-engine.d.ts +40 -0
  147. package/types/cli/watch.d.ts +59 -0
  148. package/types/core/ajv-mapper.d.ts +27 -0
  149. package/types/core/boot.d.ts +233 -0
  150. package/types/core/cluster-metrics.d.ts +52 -0
  151. package/types/core/config-loader.d.ts +13 -0
  152. package/types/core/config-validator.d.ts +30 -0
  153. package/types/core/ctx-builder.d.ts +103 -0
  154. package/types/core/envelope.d.ts +79 -0
  155. package/types/core/error-mapper.d.ts +17 -0
  156. package/types/core/formbody.d.ts +41 -0
  157. package/types/core/hub-link.d.ts +266 -0
  158. package/types/core/i18n.d.ts +178 -0
  159. package/types/core/index.d.ts +28 -0
  160. package/types/core/mega-app.d.ts +529 -0
  161. package/types/core/mega-cluster.d.ts +104 -0
  162. package/types/core/mega-server.d.ts +91 -0
  163. package/types/core/mega-service.d.ts +31 -0
  164. package/types/core/migration/dialect-registry.d.ts +22 -0
  165. package/types/core/migration/dialects/maria.d.ts +99 -0
  166. package/types/core/migration/dialects/mongo.d.ts +89 -0
  167. package/types/core/migration/dialects/postgres.d.ts +117 -0
  168. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  169. package/types/core/migration/differ.d.ts +47 -0
  170. package/types/core/migration/generate.d.ts +56 -0
  171. package/types/core/migration/journal.d.ts +52 -0
  172. package/types/core/migration/model-scan.d.ts +19 -0
  173. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  174. package/types/core/migration/schema-builder.d.ts +197 -0
  175. package/types/core/migration/schema-validator.d.ts +20 -0
  176. package/types/core/migration-lock.d.ts +33 -0
  177. package/types/core/migration-runner.d.ts +101 -0
  178. package/types/core/multipart.d.ts +86 -0
  179. package/types/core/openapi.d.ts +62 -0
  180. package/types/core/pipeline.d.ts +93 -0
  181. package/types/core/router.d.ts +159 -0
  182. package/types/core/routes-loader.d.ts +21 -0
  183. package/types/core/scope-registry.d.ts +14 -0
  184. package/types/core/security.d.ts +77 -0
  185. package/types/core/services-loader.d.ts +27 -0
  186. package/types/core/session-cleanup-schedule.d.ts +19 -0
  187. package/types/core/session-store.d.ts +25 -0
  188. package/types/core/session.d.ts +77 -0
  189. package/types/core/static-assets.d.ts +73 -0
  190. package/types/core/template.d.ts +106 -0
  191. package/types/core/workers-manager.d.ts +79 -0
  192. package/types/core/ws-cluster.d.ts +208 -0
  193. package/types/core/ws-compression.d.ts +112 -0
  194. package/types/core/ws-controller.d.ts +65 -0
  195. package/types/core/ws-message.d.ts +106 -0
  196. package/types/core/ws-presence.d.ts +273 -0
  197. package/types/core/ws-roster.d.ts +108 -0
  198. package/types/core/ws-upgrade.d.ts +260 -0
  199. package/types/errors/config-error.d.ts +10 -0
  200. package/types/errors/http-errors.d.ts +120 -0
  201. package/types/errors/index.d.ts +3 -0
  202. package/types/errors/mega-error.d.ts +32 -0
  203. package/types/index.d.ts +39 -0
  204. package/types/lib/asp/config.d.ts +49 -0
  205. package/types/lib/asp/crypto.d.ts +43 -0
  206. package/types/lib/asp/errors.d.ts +30 -0
  207. package/types/lib/asp/nonce-cache.d.ts +52 -0
  208. package/types/lib/asp/plugin.d.ts +30 -0
  209. package/types/lib/asp/ws-terminator.d.ts +45 -0
  210. package/types/lib/env-mapper.d.ts +14 -0
  211. package/types/lib/hub-protocol.d.ts +106 -0
  212. package/types/lib/index.d.ts +22 -0
  213. package/types/lib/logger/telegram-core.d.ts +104 -0
  214. package/types/lib/logger/telegram-transport.d.ts +45 -0
  215. package/types/lib/mega-brute-force.d.ts +66 -0
  216. package/types/lib/mega-circuit-breaker.d.ts +243 -0
  217. package/types/lib/mega-cron.d.ts +66 -0
  218. package/types/lib/mega-hash.d.ts +32 -0
  219. package/types/lib/mega-health.d.ts +48 -0
  220. package/types/lib/mega-job-queue.d.ts +188 -0
  221. package/types/lib/mega-job-worker.d.ts +130 -0
  222. package/types/lib/mega-job.d.ts +145 -0
  223. package/types/lib/mega-logger.d.ts +45 -0
  224. package/types/lib/mega-metrics.d.ts +285 -0
  225. package/types/lib/mega-plugin.d.ts +245 -0
  226. package/types/lib/mega-retry.d.ts +85 -0
  227. package/types/lib/mega-schedule.d.ts +260 -0
  228. package/types/lib/mega-shutdown.d.ts +135 -0
  229. package/types/lib/mega-tracing.d.ts +224 -0
  230. package/types/lib/mega-worker.d.ts +129 -0
  231. package/types/lib/otel-resource.d.ts +16 -0
  232. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  233. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  234. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  235. package/types/lib/ws-hub.d.ts +259 -0
  236. package/types/models/crud-sql-builder.d.ts +48 -0
  237. package/types/models/index.d.ts +1 -0
  238. package/types/models/mega-model.d.ts +138 -0
  239. package/types/models/model-crud.d.ts +82 -0
  240. package/types/models/mongo-crud.d.ts +59 -0
  241. package/types/test/index.d.ts +84 -0
  242. package/.env +0 -127
  243. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  244. package/sample/crud/apps/main/models/note.js +0 -71
  245. package/sample/crud/apps/main/models/user.js +0 -86
  246. package/sample/crud/package-lock.json +0 -5665
  247. package/sample/crud/yarn.lock +0 -2142
  248. package/sample/simple/package-lock.json +0 -1851
@@ -0,0 +1,508 @@
1
+ // @ts-check
2
+ /**
3
+ * `mega migrate:generate` 호스트 (ADR-204) — journal 방식 자동 마이그레이션 생성의 오케스트레이션.
4
+ *
5
+ * 1) `apps/<app>/models/**` file-scan → `static schema` 모델 수집(옵트인)
6
+ * 2) 빌더 실행 → adapter(globalKey) 별 record 그룹화 → 검증(FK 해석 포함)
7
+ * 3) `.mega/journal/snapshot.json`(직전 상태)과 diff — rename 은 사용자 확인으로만 합성(P1)
8
+ * 4) dialect(driver 별 — postgres PoC)로 up/down SQL 렌더 → `apps/<app>/migrations/<ts>-<slug>.js` 생성
9
+ * 5) snapshot 갱신 + `history/<ts>-<slug>.json` 보존
10
+ *
11
+ * **DB 연결이 필요 없다** — 적용은 기존 `mega migrate`(러너)가 담당하며 락·트랜잭션·checksum
12
+ * (ADR-149/190)이 자동 생성 파일에도 동일하게 적용된다(같은 `up(db)/down(db)` 계약).
13
+ *
14
+ * @module core/migration/generate
15
+ */
16
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, renameSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { MegaConfigError } from '../../errors/config-error.js'
19
+ import { loadAndValidateConfig } from '../config-loader.js'
20
+ import { MIGRATION_FILE_RE } from '../migration-runner.js'
21
+ import { scanSchemaModels } from './model-scan.js'
22
+ import { buildModelRecord } from './schema-builder.js'
23
+ import { validateModels } from './schema-validator.js'
24
+ import { readSnapshot, writeSnapshot, appendHistory, acquireGenerateLock, SNAPSHOT_VERSION } from './journal.js'
25
+ import { diffModels, parseRenamesSpec } from './differ.js'
26
+ import { getDialect, SUPPORTED_DRIVERS } from './dialect-registry.js'
27
+
28
+ /** 14자리 타임스탬프(UTC) — 러너 파일 규약({@link MIGRATION_FILE_RE})의 prefix. @param {Date} [d] @returns {string} */
29
+ export function migrationTimestamp(d = new Date()) {
30
+ const p = (/** @type {number} */ n, /** @type {number} */ w = 2) => String(n).padStart(w, '0')
31
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
32
+ }
33
+
34
+ /**
35
+ * 대상 migrations 디렉토리의 기존 파일 prefix 보다 **항상 큰** 타임스탬프 보장 — 같은 초의 연속
36
+ * generate(스크립트·테스트)나 시계 역행 시 파일 정렬(=러너 적용 순서)이 slug 사전순으로 붕괴하는
37
+ * 것을 막는다. 충돌 시 최대 prefix + 1 (숫자 순서만 의미 — 실제 시각일 필요 없음).
38
+ * @param {string} dir - migrations 디렉토리(부재 가능). @param {string} ts - 후보 14자리 prefix.
39
+ * @returns {string}
40
+ */
41
+ function ensureMonotonicTimestamp(dir, ts) {
42
+ if (!existsSync(dir)) return ts
43
+ let max = null
44
+ for (const f of readdirSync(dir)) {
45
+ const m = f.match(/^(\d{14})-/)
46
+ if (m !== null && (max === null || m[1] > max)) max = m[1]
47
+ }
48
+ if (max === null || ts > max) return ts
49
+ return String(Number(max) + 1).padStart(14, '0')
50
+ }
51
+
52
+ /** 임의 문자열 → 파일 규약에 맞는 kebab slug. @param {string} s @returns {string} */
53
+ function toSlug(s) {
54
+ const slug = s
55
+ .toLowerCase()
56
+ .replace(/[^a-z0-9]+/g, '-')
57
+ .replace(/^-+|-+$/g, '')
58
+ return slug.length > 0 ? slug : 'schema-update'
59
+ }
60
+
61
+ /**
62
+ * ops 로부터 slug 자동 추론 — 변경 종류를 분류해 파일명이 내용을 오도하지 않게 한다
63
+ * (예: 인덱스만 추가인데 create-* 가 되던 것 교정).
64
+ * @param {any[]} ops @returns {string}
65
+ */
66
+ function inferSlug(ops) {
67
+ const tables = [...new Set(ops.map((o) => o.table))]
68
+ if (tables.length !== 1) return 'schema-update'
69
+ const table = tables[0]
70
+ const kinds = new Set(ops.map((o) => o.kind))
71
+ // 인덱스 전용이 최우선 — createTable 분류(addIndex 동반 허용)에 잘못 흡수되지 않도록.
72
+ if ([...kinds].every((k) => k === 'addIndex' || k === 'dropIndex')) {
73
+ if (!kinds.has('dropIndex')) return toSlug(`add-index-${table}`)
74
+ if (!kinds.has('addIndex')) return toSlug(`drop-index-${table}`)
75
+ return toSlug(`index-${table}`)
76
+ }
77
+ if (kinds.has('createTable')) return toSlug(`create-${table}`)
78
+ if (kinds.has('dropTable')) return toSlug(`drop-${table}`)
79
+ return toSlug(`alter-${table}`)
80
+ }
81
+
82
+ /** SQL 을 template literal 에 안전하게 싣기 위한 escape. @param {string} sql @returns {string} */
83
+ function escapeForTemplate(sql) {
84
+ return sql.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('${', '\\${')
85
+ }
86
+
87
+ /**
88
+ * 마이그레이션 파일 본문 합성 — 러너 계약 `up(db)/down(db)` (ADR-149, 레거시 raw SQL 과 동일
89
+ * escape hatch 인터페이스 — 생성 후 자유롭게 편집 가능, 편집은 checksum 드리프트로 표면화).
90
+ * noTransaction 사유('concurrent' | 'rebuild')에 따라 `export const transaction = false` 와
91
+ * **사유별 헤더**(CONCURRENTLY 안내 vs sqlite rebuild 의 트리거/뷰·foreign_key_check 안내)를 싣는다 —
92
+ * "적용 전 검토" 가 계약인 도구에서 첫 문단이 오정보면 안 된다(ADR-208 M-3).
93
+ * mongo dialect(`usesSqlDdl: false`)는 SQL 이 아니라 mongo command JS 문을 받으므로 본문을
94
+ * `db.query(...)` 래핑 없이 그대로 싣고, `db` 는 mongodb `Db` 인스턴스다(ADR-209). mongo DDL 은
95
+ * 트랜잭션 안에서 실행할 수 없어 파일은 항상 no-tx('mongo' 사유 헤더)다.
96
+ * @param {{ base: string, slug: string, ts: string, up: string[], down: string[], noTransaction?: false | 'concurrent' | 'rebuild' | 'mongo', rebuildTriggers?: string[], style?: 'sql' | 'mongo' }} o
97
+ * @returns {string}
98
+ */
99
+ function renderMigrationFile({ base, slug, ts, up, down, noTransaction = false, rebuildTriggers = [], style = 'sql' }) {
100
+ // mongo 문은 이미 완성된 JS 문(await …, 경고 주석 동반 가능) — 들여쓰기만 입힌다.
101
+ const stmt =
102
+ style === 'mongo'
103
+ ? (/** @type {string} */ code) => code.split('\n').map((l) => ` ${l}`).join('\n')
104
+ : (/** @type {string} */ sql) => ` await db.query(\`${escapeForTemplate(sql)}\`)`
105
+ // no-tx 사유별 헤더 — 검토자가 읽는 첫 문단이 실제 내용과 맞아야 한다(오정보 금지).
106
+ /** @type {string} */
107
+ let noTxBlock = ''
108
+ if (noTransaction === 'concurrent') {
109
+ noTxBlock = `
110
+ /**
111
+ * CONCURRENTLY 는 트랜잭션 안에서 실행할 수 없어 러너의 자동 트랜잭션을 끈다(ADR-205) —
112
+ * 부분 실패 시 롤백되지 않으므로 본 파일에는 인덱스 변경만 담는다. 실패한 CONCURRENTLY 인덱스는
113
+ * INVALID 상태로 남을 수 있다 — \`DROP INDEX\` 후 재적용하세요(PostgreSQL 공식 문서).
114
+ */
115
+ export const transaction = false
116
+ `
117
+ } else if (noTransaction === 'rebuild') {
118
+ const triggerLine =
119
+ rebuildTriggers.length > 0
120
+ ? ` * ⚠️ 이 DB 에는 재생성 대상 테이블의 **트리거가 존재**한다: [${rebuildTriggers.join(', ')}] —\n * 재생성과 함께 소실되므로 본 파일 끝(또는 후속 raw 마이그레이션)에 재생성 SQL 을 추가하세요.\n`
121
+ : ` * ⚠️ 재생성 테이블에 트리거가 걸려 있다면 DROP TABLE 과 함께 **소실**된다 — 적용 전\n * \`SELECT name FROM sqlite_master WHERE type='trigger'\` 로 확인하고 재생성 SQL 을 추가하세요.\n`
122
+ noTxBlock = `
123
+ /**
124
+ * sqlite 테이블 재생성(12-step, ADR-207) — \`PRAGMA foreign_keys\` 는 트랜잭션 안에서 변경할 수
125
+ * 없어 러너의 자동 트랜잭션을 끄고 본문이 자체 BEGIN IMMEDIATE/COMMIT 으로 진행한다.
126
+ ${triggerLine} * 테이블을 참조하는 **뷰**가 있으면 RENAME 시점에 재파싱 실패로 적용이 불가하다 — 뷰를 먼저
127
+ * DROP 하고 적용 후 재생성하세요. \`PRAGMA foreign_key_check\` 는 위반 행을 반환만 하므로(에러 아님)
128
+ * 적용 후 결과를 확인하세요.
129
+ */
130
+ export const transaction = false
131
+ `
132
+ } else if (noTransaction === 'mongo') {
133
+ noTxBlock = `
134
+ /**
135
+ * mongo DDL(createCollection/collMod/drop/인덱스)은 multi-document 트랜잭션 안에서 실행할 수
136
+ * 없어(공식 문서) 러너의 자동 트랜잭션을 끈다 — 중간 실패 시 롤백되지 않으므로 적용 전 검토 필수.
137
+ * validator 는 기존 도큐먼트를 변환하지 않는다 — 새 required 필드의 backfill(updateMany)은 본문의
138
+ * 경고 주석 안내를 따라 직접 추가하세요. cross-field 검증($expr)·renameCollection 등 $jsonSchema
139
+ * 범위 밖 변경도 본 파일 raw 편집으로 추가한다(편집은 checksum 드리프트로 표면화, ADR-209).
140
+ */
141
+ export const transaction = false
142
+ `
143
+ }
144
+ const dbParamDoc =
145
+ style === 'mongo'
146
+ ? ` * @param {import('mongodb').Db} db - 대상 DB(native Db — db.collection(...) / db.command(...)).`
147
+ : ` * @param {{ query: (sql: string, params?: any[]) => Promise<any> }} db - 대상 DB 어댑터(트랜잭션 내 query).`
148
+ const reviewLine =
149
+ style === 'mongo'
150
+ ? ' * 적용 전 명령 검토 필수 — 파괴적 변경(// 경고)·backfill 안내 주석이 있으면 직접 확정하세요.'
151
+ : ' * 적용 전 SQL 검토 필수 — 파괴적 변경(-- 경고)·캐스트(-- TODO) 주석이 있으면 직접 확정하세요.'
152
+ return `// @ts-check${noTxBlock}
153
+ /**
154
+ * 마이그레이션 ${slug} (${ts}) — \`mega migrate:generate\` 자동 생성 (ADR-204).
155
+ * 기준 스냅샷: .mega/journal/history/${base}.json
156
+ ${reviewLine}
157
+ * 적용은 \`mega migrate\`(락·checksum 은 러너 관리, ADR-149/190), 롤백은 \`mega migrate:down\`.
158
+ *
159
+ ${dbParamDoc}
160
+ * @returns {Promise<void>}
161
+ */
162
+ export async function up(db) {
163
+ ${up.map(stmt).join('\n')}
164
+ }
165
+
166
+ /**
167
+ ${dbParamDoc}
168
+ * @returns {Promise<void>}
169
+ */
170
+ export async function down(db) {
171
+ ${down.map(stmt).join('\n')}
172
+ }
173
+ `
174
+ }
175
+
176
+ /**
177
+ * 기본 rename 확인기 — TTY 면 `prompts` 로 후보를 1쌍씩 확인, 비 TTY 면 확인 불가(null 반환 →
178
+ * 호출부가 drop+add 유지 + --renames 안내). 테스트는 `confirmRenames` 주입으로 대체.
179
+ *
180
+ * @param {Array<{ table: string, dropped: string[], added: string[] }>} candidates
181
+ * @returns {Promise<Record<string, Record<string, string>> | null>}
182
+ */
183
+ async function promptRenames(candidates) {
184
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return null
185
+ const { default: prompts } = await import('prompts')
186
+ // 취소(ESC/Ctrl+C)를 "삭제 확정"으로 흡수하면 안 된다 — onCancel 미지정 시 prompts 는 빈 응답으로
187
+ // 계속 진행한다. 취소는 generate 전체 중단으로 해석한다(P4 — silent 강행 금지).
188
+ const onCancel = () => {
189
+ throw new MegaConfigError('migration.rename_cancelled', 'rename 확인이 취소됐습니다 — migrate:generate 를 중단합니다(파일·journal 무변경).', {
190
+ details: { candidates },
191
+ })
192
+ }
193
+ /** @type {Record<string, Record<string, string>>} */
194
+ const out = {}
195
+ for (const c of candidates) {
196
+ const remaining = new Set(c.added)
197
+ for (const dropped of c.dropped) {
198
+ if (remaining.size === 0) break
199
+ const { target } = /** @type {{ target: string | null }} */ (await prompts({
200
+ type: 'select',
201
+ name: 'target',
202
+ message: `table '${c.table}': 컬럼 '${dropped}' 삭제가 rename 인가요?`,
203
+ choices: [
204
+ { title: `아니오 — '${dropped}' 는 정말 삭제 (데이터 손실)`, value: null },
205
+ ...[...remaining].map((a) => ({ title: `예 — '${dropped}' → '${a}' rename (데이터 보존)`, value: a })),
206
+ ],
207
+ }, { onCancel }))
208
+ if (typeof target === 'string') {
209
+ out[c.table] = { ...(out[c.table] ?? {}), [dropped]: target }
210
+ remaining.delete(target)
211
+ }
212
+ }
213
+ }
214
+ return out
215
+ }
216
+
217
+ /**
218
+ * `mega migrate:generate` 실행.
219
+ *
220
+ * @param {string} projectRoot
221
+ * @typedef {{
222
+ * name?: string,
223
+ * renames?: string,
224
+ * dryRun?: boolean,
225
+ * check?: boolean,
226
+ * concurrent?: boolean,
227
+ * allowDestroyDrops?: boolean,
228
+ * adapter?: string,
229
+ * app?: string,
230
+ * out?: (msg: string) => void,
231
+ * logger?: { debug?: Function, info?: Function, warn?: Function },
232
+ * confirmRenames?: (candidates: Array<{ table: string, dropped: string[], added: string[] }>) => Promise<Record<string, Record<string, string>> | null>,
233
+ * now?: () => Date,
234
+ * }} GenerateOpts
235
+ */
236
+
237
+ /**
238
+ * @param {string} projectRoot
239
+ * @param {GenerateOpts} [opts]
240
+ * @returns {Promise<{ changed: boolean, files: string[], adapters: string[] }>}
241
+ */
242
+ export async function runMigrateGenerate(projectRoot, opts = {}) {
243
+ // 락은 config 검증 뒤(잘못된 디렉토리에 .mega 생성 방지) generateInner 가 잡고, 해제는 여기서
244
+ // 보장한다 — 쓰기 경로(파일·journal)만 락 대상(dry-run/--check 는 읽기 전용 + 원자 rename 덕에 안전).
245
+ /** @type {{ release: (() => void) | null }} */
246
+ const lock = { release: null }
247
+ try {
248
+ return await generateInner(projectRoot, opts, lock)
249
+ } finally {
250
+ lock.release?.()
251
+ }
252
+ }
253
+
254
+ /**
255
+ * @param {string} projectRoot
256
+ * @param {GenerateOpts} opts
257
+ * @param {{ release: (() => void) | null }} lock
258
+ * @returns {Promise<{ changed: boolean, files: string[], adapters: string[] }>}
259
+ */
260
+ async function generateInner(projectRoot, opts, lock) {
261
+ const { name, renames, dryRun = false, check = false, concurrent = false, allowDestroyDrops = false, adapter: adapterFilter, app = 'main', out = console.log, logger, confirmRenames = promptRenames, now = () => new Date() } = opts
262
+
263
+ const { global, apps } = await loadAndValidateConfig(projectRoot)
264
+ const appNames = apps.map((/** @type {any} */ a) => a.name)
265
+ if (!appNames.includes(app)) {
266
+ throw new MegaConfigError('migration.app_unknown', `--app '${app}' 이 config apps 에 없습니다. 선언됨: [${appNames.join(', ')}].`, {
267
+ details: { app, declared: appNames },
268
+ })
269
+ }
270
+ const databases = /** @type {Record<string, any>} */ (/** @type {any} */ (global)?.services?.databases ?? {})
271
+
272
+ // 1) 스캔 + record 빌드 + 검증(FK 대상 테이블 해석 + dialect 식별자 한도 포함).
273
+ const scanned = await scanSchemaModels({ projectRoot, appNames })
274
+ const models = scanned.map((m) => ({ name: m.name, table: m.table, adapter: m.adapter, app: m.app, file: m.file, record: buildModelRecord(m.Model) }))
275
+ validateModels(models, {
276
+ // 지원 dialect 만 한도 적용 — 미지원 driver 는 diff 단계의 dialect_unsupported 가 정본 에러
277
+ // (--adapter 필터로 의도적으로 제외된 그룹을 검증이 먼저 죽이지 않도록).
278
+ identifierMaxBytesOf: (adapter) => {
279
+ const driver = databases[adapter]?.driver
280
+ return typeof driver === 'string' && SUPPORTED_DRIVERS.includes(driver) ? getDialect(driver).identifierMaxBytes : Infinity
281
+ },
282
+ })
283
+ logger?.debug?.({ models: models.length }, 'migrate.generate models scanned')
284
+
285
+ // 2) adapter(globalKey) 별 그룹화 + driver 해석(미선언 어댑터는 fail-fast).
286
+ /** @type {Map<string, { driver: string, models: Array<{ name: string, table: string, record: any }> }>} */
287
+ const groups = new Map()
288
+ for (const m of models) {
289
+ if (!groups.has(m.adapter)) {
290
+ const driver = databases[m.adapter]?.driver
291
+ if (typeof driver !== 'string') {
292
+ throw new MegaConfigError(
293
+ 'migration.adapter_not_declared',
294
+ `model '${m.name}' 의 adapter '${m.adapter}' 가 services.databases 에 없습니다. 선언됨: [${Object.keys(databases).join(', ') || '(none)'}].`,
295
+ { details: { model: m.name, adapter: m.adapter, declared: Object.keys(databases) } },
296
+ )
297
+ }
298
+ groups.set(m.adapter, { driver, models: [] })
299
+ }
300
+ /** @type {any} */ (groups.get(m.adapter)).models.push({ name: m.name, table: m.table, record: m.record })
301
+ }
302
+
303
+ // 3) 직전 snapshot 과 diff — 대상 adapter = 현재 ∪ 직전(전체 모델 제거도 감지), --adapter 필터 적용.
304
+ // 쓰기 모드는 읽기 전에 generate 락 선점 — 동시 generate 의 snapshot 갱신 유실 차단.
305
+ if (!dryRun && !check) lock.release = acquireGenerateLock(projectRoot)
306
+ const prevSnapshot = readSnapshot(projectRoot)
307
+ const adapterKeys = [...new Set([...Object.keys(prevSnapshot.adapters), ...groups.keys()])]
308
+ .filter((k) => adapterFilter === undefined || k === adapterFilter)
309
+ .sort()
310
+ if (adapterFilter !== undefined && !adapterKeys.includes(adapterFilter)) {
311
+ throw new MegaConfigError('migration.adapter_not_declared', `--adapter '${adapterFilter}' 에 schema 모델도 journal 이력도 없습니다.`, {
312
+ details: { adapter: adapterFilter },
313
+ })
314
+ }
315
+
316
+ /** @type {Array<{ key: string, driver: string, dialect: Record<string, any>, ops: any[], candidates: any[], currModels: any[] }>} */
317
+ const diffs = []
318
+ for (const key of adapterKeys) {
319
+ const curr = groups.get(key)
320
+ const prev = prevSnapshot.adapters[key]
321
+ const driver = curr?.driver ?? prev?.driver
322
+ const dialect = getDialect(/** @type {string} */ (driver)) // 미지원 driver/contract 위반은 여기서 명시 fail-fast(P7)
323
+ const { ops, renameCandidates } = diffModels(prev?.models ?? [], curr?.models ?? [], { dialect })
324
+ diffs.push({ key, driver: /** @type {string} */ (driver), dialect, ops, candidates: renameCandidates, currModels: curr?.models ?? [] })
325
+ }
326
+
327
+ // 4) rename 확정 — --renames 우선, 없으면 confirm(기본: TTY prompt). 확정되면 diff 재계산.
328
+ // 비인터랙티브에서 확인 수단이 없으면 fail-fast — 모르고 DROP COLUMN(데이터 손실) 마이그레이션이
329
+ // 생성되는 것을 차단한다. 의식적 drop 의도는 --allow-destroy-drops 로만 통과.
330
+ const allCandidates = diffs.flatMap((d) => d.candidates)
331
+ if (allCandidates.length > 0 && !check) {
332
+ /** @type {Record<string, Record<string, string>> | null} */
333
+ let mapping = null
334
+ if (renames !== undefined) {
335
+ mapping = parseRenamesSpec(renames, allCandidates)
336
+ } else {
337
+ mapping = await confirmRenames(allCandidates)
338
+ if (mapping === null) {
339
+ if (allowDestroyDrops !== true) {
340
+ throw new MegaConfigError(
341
+ 'migration.rename_unconfirmed',
342
+ 'rename 후보가 있으나 비인터랙티브 환경이라 확인할 수 없습니다 — 그대로 진행하면 무경고 DROP COLUMN(데이터 손실) 마이그레이션이 생성됩니다. ' +
343
+ `rename 이면 --renames "${allCandidates.map((c) => `${c.dropped[0]}:${c.added[0]}`).join(',')}" 로 지정하고, ` +
344
+ '정말 삭제+추가 의도라면 --allow-destroy-drops 로 명시하세요. ' +
345
+ `후보: ${allCandidates.map((c) => `${c.table}(drop: [${c.dropped.join(', ')}], add: [${c.added.join(', ')}])`).join(' / ')}`,
346
+ { details: { candidates: allCandidates } },
347
+ )
348
+ }
349
+ out('[migrate:generate] --allow-destroy-drops — rename 후보를 drop+add 로 진행합니다(데이터 손실 주의).')
350
+ mapping = {}
351
+ }
352
+ }
353
+ for (const d of diffs) {
354
+ const prev = prevSnapshot.adapters[d.key]
355
+ const curr = groups.get(d.key)
356
+ const { ops } = diffModels(prev?.models ?? [], curr?.models ?? [], { renames: mapping, dialect: d.dialect })
357
+ d.ops = ops
358
+ }
359
+ }
360
+
361
+ // 모델 제거/table 변경 = 테이블(컬렉션) 전체 DROP — 컬럼 rename 후보(1컬럼)도 게이트하면서
362
+ // 훨씬 파괴적인 전체 drop 이 무게이트인 비대칭을 해소한다(ADR-210 L-1, 전 dialect 공통).
363
+ // 파일을 쓰는 모드만 게이트(--check 는 보고 전용, dry-run 은 출력 전용 — CI 프리뷰 무중단).
364
+ if (!dryRun && !check && allowDestroyDrops !== true) {
365
+ const dropTables = diffs.flatMap((d) => d.ops.filter((/** @type {any} */ o) => o.kind === 'dropTable').map((/** @type {any} */ o) => `${d.key}:${o.table}`))
366
+ if (dropTables.length > 0) {
367
+ throw new MegaConfigError(
368
+ 'migration.drop_unconfirmed',
369
+ `테이블(컬렉션) 전체 DROP 이 산출됐습니다: [${dropTables.join(', ')}] — 모든 행(도큐먼트)이 영구 삭제되는 ` +
370
+ '마이그레이션입니다. 의도한 제거라면 --allow-destroy-drops 로 명시하고, 이름 변경 의도라면 모델을 되돌린 뒤 ' +
371
+ 'raw 마이그레이션(rename)으로 처리하세요.',
372
+ { details: { tables: dropTables } },
373
+ )
374
+ }
375
+ }
376
+
377
+ const changed = diffs.filter((d) => d.ops.length > 0)
378
+ if (changed.length === 0) {
379
+ out(check ? '[migrate:generate] --check — 드리프트 없음.' : '[migrate:generate] 변경 없음.')
380
+ return { changed: false, files: [], adapters: [] }
381
+ }
382
+
383
+ // --check: CI 드리프트 게이트 — 변경 요약만 출력하고 파일/journal 무변경. 호출자(CLI)가
384
+ // changed=true 를 exit non-0 으로 변환한다("모델은 바뀌었는데 generate 안 돌림" 검출).
385
+ if (check) {
386
+ for (const d of changed) {
387
+ const tables = [...new Set(d.ops.map((/** @type {any} */ o) => o.table))]
388
+ out(`[migrate:generate] --check 드리프트: adapter '${d.key}' — 변경 연산 ${d.ops.length}건 (tables: ${tables.join(', ')})`)
389
+ }
390
+ out("[migrate:generate] 모델 schema 와 journal 이 어긋났습니다 — 'mega migrate:generate' 를 실행해 마이그레이션을 생성·커밋하세요.")
391
+ return { changed: true, files: [], adapters: changed.map((d) => d.key) }
392
+ }
393
+
394
+ // --concurrent: 인덱스 전용 생성만 허용 — CONCURRENTLY 는 트랜잭션 밖 실행이 필요해 파일 전체가
395
+ // no-tx 가 되므로, 다른(롤백 보호가 필요한) 변경과 섞이면 안전하지 않다.
396
+ if (concurrent) {
397
+ for (const d of changed) {
398
+ if (d.dialect.supportsConcurrentIndex !== true) {
399
+ throw new MegaConfigError('migration.concurrent_unsupported', `--concurrent: driver '${d.driver}' 는 동시(온라인) 인덱스 생성을 지원하지 않습니다.`, {
400
+ details: { adapter: d.key, driver: d.driver },
401
+ })
402
+ }
403
+ const nonIndex = d.ops.filter((/** @type {any} */ o) => o.kind !== 'addIndex' && o.kind !== 'dropIndex')
404
+ if (nonIndex.length > 0) {
405
+ throw new MegaConfigError(
406
+ 'migration.concurrent_mixed',
407
+ `--concurrent 는 인덱스 변경만 담을 수 있습니다(파일 전체가 트랜잭션 없이 실행됨). ` +
408
+ `섞인 변경 ${nonIndex.length}건(${[...new Set(nonIndex.map((/** @type {any} */ o) => o.kind))].join(', ')}) — ` +
409
+ '먼저 --concurrent 없이 generate→적용으로 모델 변경을 반영한 뒤, 인덱스 추가만 남겨 다시 실행하세요.',
410
+ { details: { adapter: d.key, nonIndex: nonIndex.map((/** @type {any} */ o) => o.kind) } },
411
+ )
412
+ }
413
+ for (const op of d.ops) if (op.kind === 'addIndex') op.concurrent = true
414
+ }
415
+ }
416
+
417
+ // 5) 렌더 + 파일/journal 기록 (dry-run 은 출력만). 타임스탬프는 기존 파일보다 항상 크게 —
418
+ // 같은 초 연속 생성이 적용 순서를 깨지 않도록. crash window 최소화: 본문은 .tmp 로 전부 쓴 뒤
419
+ // 마이그레이션 파일 rename → history → snapshot 순으로 확정한다(best effort — snapshot 이
420
+ // 마지막이라 중간 crash 는 "파일은 있고 기준은 옛 상태"로 남고, 다음 generate 의 중복 ops 가
421
+ // 파일명 충돌·검토 단계에서 드러난다).
422
+ const ts = ensureMonotonicTimestamp(join(projectRoot, 'apps', app, 'migrations'), migrationTimestamp(now()))
423
+ /** @type {string[]} */
424
+ const files = []
425
+ /** @type {Array<{ tmp: string, file: string, rel: string, d: any, base: string, up: number, down: number }>} */
426
+ const staged = []
427
+ for (const d of changed) {
428
+ const dialect = d.dialect
429
+ const { up, down } = dialect.renderOps(d.ops)
430
+ const baseSlug = name !== undefined ? toSlug(name) : inferSlug(d.ops)
431
+ const slug = changed.length > 1 ? toSlug(`${baseSlug}-${d.key}`) : baseSlug
432
+ const base = `${ts}-${slug}`
433
+ if (!MIGRATION_FILE_RE.test(`${base}.js`)) {
434
+ throw new MegaConfigError('migration.schema_invalid', `생성 파일명 '${base}.js' 이 마이그레이션 파일 규약에 맞지 않습니다.`, {
435
+ details: { base },
436
+ })
437
+ }
438
+
439
+ const isMongo = dialect.usesSqlDdl === false
440
+
441
+ if (dryRun) {
442
+ out(`[migrate:generate] (dry-run) adapter '${d.key}' (${d.driver}) — up ${up.length}문 / down ${down.length}문`)
443
+ out('-- up ----------------------------------------')
444
+ for (const s of up) out(isMongo ? s : s + ';')
445
+ out('-- down --------------------------------------')
446
+ for (const s of down) out(isMongo ? s : s + ';')
447
+ continue
448
+ }
449
+
450
+ const dir = join(projectRoot, 'apps', app, 'migrations')
451
+ mkdirSync(dir, { recursive: true })
452
+ const file = join(dir, `${base}.js`)
453
+ if (existsSync(file)) {
454
+ throw new MegaConfigError('migration.schema_invalid', `생성 대상 파일이 이미 존재합니다: ${file}`, { details: { file } })
455
+ }
456
+ // no-tx 파일: --concurrent(CONCURRENTLY) 또는 sqlite rebuild(PRAGMA foreign_keys 는 트랜잭션 내
457
+ // 변경 불가 — 12-step 이 자체 BEGIN IMMEDIATE/COMMIT 보유). 헤더는 사유별로 다르다(M-3).
458
+ const rebuildOps = d.ops.filter((/** @type {any} */ o) => o.kind === 'rebuildTable')
459
+ // mongo 는 DDL 이 트랜잭션 불가라 사유가 항상 'mongo'(--concurrent 여부와 무관 — 헤더 오정보 방지).
460
+ /** @type {false | 'concurrent' | 'rebuild' | 'mongo'} */
461
+ const noTransaction = isMongo ? 'mongo' : concurrent ? 'concurrent' : rebuildOps.length > 0 ? 'rebuild' : false
462
+ /** @type {string[]} */
463
+ let rebuildTriggers = []
464
+ if (rebuildOps.length > 0 && d.driver === 'sqlite' && typeof d.dialect.inspectRebuildHazards === 'function') {
465
+ // best-effort 사전 검사 — sqlite 는 파일 DB 라 generate 가 직접 읽을 수 있다(서버 무연결 유지).
466
+ const tables = rebuildOps.map((/** @type {any} */ o) => o.table)
467
+ const hazards = await d.dialect.inspectRebuildHazards(databases[d.key]?.filename, tables)
468
+ if (hazards.conflictViews.length > 0) {
469
+ throw new MegaConfigError(
470
+ 'migration.rebuild_view_conflict',
471
+ `sqlite rebuild 는 대상 테이블(${tables.join(', ')})을 참조하는 뷰가 있으면 자동 처리할 수 없습니다 — ` +
472
+ `RENAME 시점에 뷰 재파싱이 실패해 적용 자체가 불가합니다. 뷰 [${hazards.conflictViews.join(', ')}] 를 ` +
473
+ 'raw 마이그레이션으로 먼저 DROP 하고, 적용 후 재생성하세요.',
474
+ { details: { tables, views: hazards.conflictViews } },
475
+ )
476
+ }
477
+ rebuildTriggers = hazards.triggers
478
+ if (rebuildTriggers.length > 0) {
479
+ out(`[migrate:generate] 경고: 재생성 테이블의 트리거 [${rebuildTriggers.join(', ')}] 는 적용 시 소실됩니다 — 파일 헤더 안내를 따라 재생성 SQL 을 추가하세요.`)
480
+ }
481
+ }
482
+ const tmp = `${file}.tmp`
483
+ writeFileSync(tmp, renderMigrationFile({ base, slug, ts, up, down, noTransaction, rebuildTriggers, style: isMongo ? 'mongo' : 'sql' }))
484
+ staged.push({ tmp, file, rel: join('apps', app, 'migrations', `${base}.js`), d, base, up: up.length, down: down.length })
485
+ }
486
+
487
+ if (!dryRun) {
488
+ // 확정 단계 — rename(원자) 순서: 마이그레이션 파일 → history → snapshot.
489
+ for (const s of staged) {
490
+ renameSync(s.tmp, s.file)
491
+ files.push(s.file)
492
+ // journal 갱신 — 처리한 adapter 의 그룹을 현재 상태로 교체(모델 0 이면 그룹 제거).
493
+ if (s.d.currModels.length > 0) {
494
+ prevSnapshot.adapters[s.d.key] = { driver: s.d.driver, models: s.d.currModels }
495
+ } else {
496
+ delete prevSnapshot.adapters[s.d.key]
497
+ }
498
+ out(`[migrate:generate] 생성: ${s.rel} (adapter '${s.d.key}', up ${s.up}문 / down ${s.down}문)`)
499
+ appendHistory(projectRoot, s.base, { version: SNAPSHOT_VERSION, generatedAt: now().toISOString(), adapters: prevSnapshot.adapters })
500
+ }
501
+ prevSnapshot.version = SNAPSHOT_VERSION
502
+ prevSnapshot.generatedAt = now().toISOString()
503
+ writeSnapshot(projectRoot, prevSnapshot)
504
+ out(`[migrate:generate] journal 갱신: ${join('.mega', 'journal', 'snapshot.json')}`)
505
+ }
506
+
507
+ return { changed: true, files, adapters: changed.map((d) => d.key) }
508
+ }
@@ -0,0 +1,167 @@
1
+ // @ts-check
2
+ /**
3
+ * Journal snapshot 저장소 (ADR-204) — Drizzle 식 journal 방식의 "직전 상태" 정본.
4
+ *
5
+ * - `.mega/journal/snapshot.json` — 마지막 `migrate:generate` 시점의 전 모델 record.
6
+ * diff 의 비교 기준이며 **git 추적 대상**(정본 일부 — DB introspection 없이 오프라인 diff).
7
+ * - `.mega/journal/history/<ts>-<slug>.json` — 각 생성 시점 스냅샷 보존(감사·수동 롤백 참고).
8
+ *
9
+ * DB 연결이 전혀 필요 없다 — 파일 시스템만 본다(러너의 파일 스캔·결정론 패턴 정합).
10
+ *
11
+ * @module core/migration/journal
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, linkSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { MegaConfigError } from '../../errors/config-error.js'
16
+
17
+ /** journal 디렉토리 (projectRoot 상대). */
18
+ export const JOURNAL_DIR = join('.mega', 'journal')
19
+
20
+ /** snapshot 포맷 버전 — 포맷 변경 시 증가(하위 포맷은 마이그레이션 가이드와 함께 거부). */
21
+ export const SNAPSHOT_VERSION = 1
22
+
23
+ /**
24
+ * @typedef {{ version: number, generatedAt: string | null, adapters: Record<string, { driver: string, models: Array<{ name: string, table: string, record: any }> }> }} Snapshot
25
+ */
26
+
27
+ /** 빈 스냅샷(첫 generate — 모든 모델이 신규로 diff 됨). @returns {Snapshot} */
28
+ export function emptySnapshot() {
29
+ return { version: SNAPSHOT_VERSION, generatedAt: null, adapters: {} }
30
+ }
31
+
32
+ /**
33
+ * snapshot.json 로드. 부재 시 빈 스냅샷(정상 — 첫 실행). 손상/버전 불일치는 fail-fast —
34
+ * 잘못된 기준으로 diff 하면 파괴적 SQL 이 잘못 생성된다(P7).
35
+ *
36
+ * @param {string} projectRoot
37
+ * @returns {Snapshot}
38
+ * @throws {MegaConfigError} `migration.journal_corrupt` | `migration.journal_version`
39
+ */
40
+ export function readSnapshot(projectRoot) {
41
+ const file = join(projectRoot, JOURNAL_DIR, 'snapshot.json')
42
+ if (!existsSync(file)) return emptySnapshot()
43
+ let parsed
44
+ try {
45
+ parsed = JSON.parse(readFileSync(file, 'utf8'))
46
+ } catch (err) {
47
+ throw new MegaConfigError(
48
+ 'migration.journal_corrupt',
49
+ `journal snapshot 파싱 실패: ${file} — 손상된 스냅샷으로 diff 하면 잘못된 SQL 이 생성됩니다. git 이력에서 복원하세요.`,
50
+ { cause: err, details: { file } },
51
+ )
52
+ }
53
+ if (parsed?.version !== SNAPSHOT_VERSION) {
54
+ throw new MegaConfigError(
55
+ 'migration.journal_version',
56
+ `journal snapshot 버전 불일치: ${parsed?.version} (지원: ${SNAPSHOT_VERSION}) — ${file}`,
57
+ { details: { file, version: parsed?.version ?? null, supported: SNAPSHOT_VERSION } },
58
+ )
59
+ }
60
+ if (parsed.adapters === null || typeof parsed.adapters !== 'object') {
61
+ throw new MegaConfigError('migration.journal_corrupt', `journal snapshot 형식 오류(adapters 부재): ${file}`, { details: { file } })
62
+ }
63
+ return parsed
64
+ }
65
+
66
+ /**
67
+ * snapshot.json 기록(+디렉토리 생성). 모델은 이름순 정렬해 직렬화 — diff 노이즈 없는 결정적 출력.
68
+ * @param {string} projectRoot @param {Snapshot} snapshot
69
+ * @returns {string} 기록한 파일 경로.
70
+ */
71
+ export function writeSnapshot(projectRoot, snapshot) {
72
+ const dir = join(projectRoot, JOURNAL_DIR)
73
+ mkdirSync(dir, { recursive: true })
74
+ const normalized = {
75
+ version: snapshot.version,
76
+ generatedAt: snapshot.generatedAt,
77
+ adapters: Object.fromEntries(
78
+ Object.entries(snapshot.adapters)
79
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
80
+ .map(([key, group]) => [key, { driver: group.driver, models: [...group.models].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) }]),
81
+ ),
82
+ }
83
+ const file = join(dir, 'snapshot.json')
84
+ atomicWrite(file, JSON.stringify(normalized, null, 2) + '\n')
85
+ return file
86
+ }
87
+
88
+ /**
89
+ * temp 파일 기록 후 rename — 같은 파일시스템에서 rename 은 원자적이라 중간 crash 가
90
+ * 절반 기록된(파싱 불가) snapshot 을 남기지 않는다.
91
+ * @param {string} file @param {string} content
92
+ */
93
+ function atomicWrite(file, content) {
94
+ const tmp = `${file}.tmp`
95
+ writeFileSync(tmp, content)
96
+ renameSync(tmp, file)
97
+ }
98
+
99
+ /** generate 동시 실행 차단 lock 파일 경로. @param {string} projectRoot @returns {string} */
100
+ function lockPath(projectRoot) {
101
+ return join(projectRoot, JOURNAL_DIR, 'generate.lock')
102
+ }
103
+
104
+ /**
105
+ * generate 락 획득 — snapshot 읽기→쓰기 구간(프롬프트 대기 포함)이 길어 동시 generate 가
106
+ * 한쪽 갱신을 유실시킬 수 있다(CI 병렬 잡 등). `wx` 플래그(존재 시 실패)가 원자적 선점.
107
+ *
108
+ * @param {string} projectRoot
109
+ * @returns {() => void} 해제 함수(finally 에서 호출).
110
+ * @throws {MegaConfigError} `migration.generate_locked` - 다른 generate 진행 중(또는 stale lock).
111
+ */
112
+ export function acquireGenerateLock(projectRoot) {
113
+ const dir = join(projectRoot, JOURNAL_DIR)
114
+ mkdirSync(dir, { recursive: true })
115
+ const file = lockPath(projectRoot)
116
+ // 내용을 임시 파일에 먼저 완성하고 link 로 원자 선점한다 — `wx` 직접 기록은 "파일 생성 ↔ 내용
117
+ // 기록" 사이의 빈 파일을 패자가 읽어 보유자 PID 가 공란으로 보이는 race 가 있었다(ADR-208 L-2).
118
+ const tmp = `${file}.${process.pid}.tmp`
119
+ writeFileSync(tmp, `${process.pid} ${new Date().toISOString()}\n`)
120
+ try {
121
+ linkSync(tmp, file)
122
+ } catch (err) {
123
+ try {
124
+ unlinkSync(tmp)
125
+ } catch (cleanupErr) {
126
+ // 임시 파일 정리 실패는 비치명적 — 원인 에러(락 충돌 등)가 진짜 신호다.
127
+ if (/** @type {any} */ (cleanupErr).code !== 'ENOENT') throw cleanupErr
128
+ }
129
+ if (/** @type {any} */ (err).code !== 'EEXIST') throw err
130
+ let holder = ''
131
+ try {
132
+ holder = readFileSync(file, 'utf8').trim()
133
+ } catch {
134
+ // 막 해제된(삭제 경합) lock — 보유자 정보 없이도 아래 안내는 유효하다.
135
+ holder = '(unknown)'
136
+ }
137
+ throw new MegaConfigError(
138
+ 'migration.generate_locked',
139
+ `다른 migrate:generate 가 진행 중입니다(lock 보유: ${holder}). 동시 실행은 journal 갱신을 유실시킵니다. ` +
140
+ `진행 중인 프로세스가 없는데도 반복되면 stale lock — '${file}' 을 삭제 후 재시도하세요.`,
141
+ { details: { file, holder } },
142
+ )
143
+ }
144
+ unlinkSync(tmp)
145
+ return () => {
146
+ try {
147
+ unlinkSync(file)
148
+ } catch (err) {
149
+ if (/** @type {any} */ (err).code !== 'ENOENT') throw err
150
+ // 이미 사라진 lock(수동 정리 등)은 해제 목적이 달성된 상태 — 무해.
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * history 스냅샷 보존 — 마이그레이션 파일과 같은 base 이름의 .json.
157
+ * @param {string} projectRoot @param {string} baseName - `<ts>-<slug>` (확장자 없이).
158
+ * @param {Snapshot} snapshot
159
+ * @returns {string} 기록한 파일 경로.
160
+ */
161
+ export function appendHistory(projectRoot, baseName, snapshot) {
162
+ const dir = join(projectRoot, JOURNAL_DIR, 'history')
163
+ mkdirSync(dir, { recursive: true })
164
+ const file = join(dir, `${baseName}.json`)
165
+ atomicWrite(file, JSON.stringify(snapshot, null, 2) + '\n')
166
+ return file
167
+ }