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,563 @@
1
+ // @ts-check
2
+ /**
3
+ * PostgreSQL dialect (ADR-204) — differ 가 산출한 변경 연산(op)을 SQL 문자열(up/down 쌍)로 렌더한다.
4
+ *
5
+ * # 명명 표준 (diff 안정성의 핵심 — 이름이 흔들리면 변경이 drop+add 로 오판된다)
6
+ * - 인덱스: `idx_<table>_<col1>_<col2>` (UNIQUE 인덱스: `uniq_<table>_<cols>`)
7
+ * - UNIQUE 제약: `uniq_<table>_<col>`
8
+ * - FK: `fk_<table>_<col>_<reftable>`
9
+ * - CHECK: `chk_<table>_<col>` (사용자 지정 name 우선)
10
+ * - PRIMARY KEY(복합): `pk_<table>`
11
+ *
12
+ * # 렌더 원칙
13
+ * - 식별자는 필요할 때만 따옴표(`"camelCase"` 등) — 소문자 snake 는 bare 로 가독성 유지.
14
+ * - enum 은 `TEXT` + `CHECK (col IN (...))` — native enum 타입 미사용(값 변경이 제약 교체로 단순).
15
+ * - FK 는 CREATE TABLE 인라인이 아니라 **별도 ADD CONSTRAINT** — 같은 마이그레이션 안에서
16
+ * 상호 참조하는 새 테이블들의 생성 순서 문제를 제거한다(differ 가 createTable 뒤에 addFk 를 배치).
17
+ * - 파괴적 변경(DROP TABLE/COLUMN)·위험 캐스트(ALTER TYPE)는 SQL 안에 경고/TODO 주석을 박는다
18
+ * (silent 손실 금지 — 사용자가 적용 전 검토).
19
+ *
20
+ * @module core/migration/dialects/postgres
21
+ */
22
+ import { MegaConfigError } from '../../../errors/config-error.js'
23
+
24
+ // ── dialect 표준 인터페이스 속성 (ADR-205 — dialects/README.md 가 contract 정본) ─────────────
25
+ /** 식별자 최대 길이(byte) — postgres NAMEDATALEN-1. 초과는 절단·충돌 위험이라 fail-fast(정책 2). */
26
+ export const identifierMaxBytes = 63
27
+ /** CREATE INDEX CONCURRENTLY 지원(트랜잭션 밖 실행 필요 — generate --concurrent 옵트인). */
28
+ export const supportsConcurrentIndex = true
29
+ /** enum 표현 전략 — 'check' = TEXT + CHECK(col IN (...)). (maria 는 'enum-type' 예정.) */
30
+ export const enumStrategy = 'check'
31
+ /** RENAME COLUMN 의 트랜잭션 내 실행 지원. */
32
+ export const supportsRenameInTx = true
33
+ /** DROP COLUMN 의 트랜잭션 내 롤백 가능(maria 는 DDL 암묵 commit 으로 false 예정). */
34
+ export const canDropColumnInTx = true
35
+ /** 일부 ALTER 가 테이블 재생성을 요구하는지(sqlite true 예정). */
36
+ export const dependsOnRebuild = false
37
+ /** ALTER TABLE ... RENAME CONSTRAINT 지원(postgres 9.2+). 미지원 dialect 는 DROP+ADD 로 대체. */
38
+ export const supportsRenameConstraint = true
39
+ /** ALTER 로 FK 추가/삭제 가능 — false(sqlite)면 FK 는 CREATE TABLE 인라인 + 변경은 rebuild. */
40
+ export const supportsAlterAddFk = true
41
+ /** FK 걸린 컬럼 DROP 전 dropFk 선행 필요 여부 — postgres 는 의존 객체 cascade 라 불필요. */
42
+ export const requiresDropFkBeforeDropColumn = false
43
+ /** SQL DDL 렌더 dialect — 생성 파일이 `db.query(sql)` 계약을 쓴다(mongo 와 분기, ADR-209). */
44
+ export const usesSqlDdl = true
45
+
46
+ /** bare 로 둘 수 있는 식별자 — 그 외(camelCase·예약어 충돌 가능성)는 따옴표. */
47
+ const BARE_IDENT_RE = /^[a-z_][a-z0-9_]*$/
48
+
49
+ /**
50
+ * SQL 식별자 인용 — 필요할 때만 `"..."`. **모든 식별자가 지나는 단일 깔때기**라 여기서
51
+ * 63byte 초과를 fail-fast 한다 — postgres 는 NOTICE 와 함께 조용히 절단해 이름 충돌·
52
+ * `migrate:status` 표기 불일치를 만들기 때문(자동 축약 없이 거부, 정책 2).
53
+ *
54
+ * @param {string} name @returns {string}
55
+ * @throws {MegaConfigError} `migration.identifier_too_long` - 63byte 초과(합성 이름 포함).
56
+ */
57
+ export function quoteIdent(name) {
58
+ if (Buffer.byteLength(name, 'utf8') > identifierMaxBytes) {
59
+ throw new MegaConfigError(
60
+ 'migration.identifier_too_long',
61
+ `식별자 '${name}' 가 postgres 한도(${identifierMaxBytes}byte)를 초과합니다 — 절단 시 이름 충돌 위험이 있어 거부합니다. ` +
62
+ '테이블/컬럼 이름을 줄이거나, 합성 이름(idx_/fk_ 등)이 문제라면 .unique({name})/.references(...,{name})/t.index(...,{name}) 으로 짧은 이름을 명시하세요.',
63
+ { details: { identifier: name, bytes: Buffer.byteLength(name, 'utf8'), max: identifierMaxBytes } },
64
+ )
65
+ }
66
+ if (BARE_IDENT_RE.test(name)) return name
67
+ return `"${name.replaceAll('"', '""')}"`
68
+ }
69
+
70
+ /**
71
+ * DEFAULT/CHECK 용 literal 렌더 — 문자열은 단일따옴표 escape, `{ raw }` 는 검증된 식 그대로.
72
+ * @param {unknown} value @returns {string}
73
+ */
74
+ export function renderLiteral(value) {
75
+ if (value === null) return 'NULL'
76
+ if (typeof value === 'number') {
77
+ // NaN/Infinity 는 SQL literal 이 아니다 — invalid SQL 을 만들기 전에 거부.
78
+ if (!Number.isFinite(value)) {
79
+ throw new MegaConfigError('migration.schema_invalid', `default 값이 유한한 수가 아닙니다: ${value}`, { details: { value: String(value) } })
80
+ }
81
+ return String(value)
82
+ }
83
+ if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
84
+ if (typeof value === 'object' && value !== null && typeof (/** @type {any} */ (value).raw) === 'string') {
85
+ return /** @type {any} */ (value).raw
86
+ }
87
+ return `'${String(value).replaceAll("'", "''")}'`
88
+ }
89
+
90
+ /** dialect 표준 인터페이스 별칭 — 값 인용(SQL injection 방지 깔때기). @type {typeof renderLiteral} */
91
+ export const quoteLiteral = renderLiteral
92
+
93
+ /**
94
+ * 컬럼 타입 → postgres SQL 타입.
95
+ * @param {Record<string, any>} def @param {{ forAlter?: boolean }} [opts] - forAlter: ALTER TYPE 대상
96
+ * (SERIAL 은 타입이 아니라 시퀀스 단축문법이라 INTEGER/BIGINT 로 강등).
97
+ * @returns {string}
98
+ */
99
+ export function typeSql(def, { forAlter = false } = {}) {
100
+ switch (def.type) {
101
+ case 'serial':
102
+ return forAlter ? 'INTEGER' : 'SERIAL'
103
+ case 'bigSerial':
104
+ return forAlter ? 'BIGINT' : 'BIGSERIAL'
105
+ case 'integer':
106
+ return 'INTEGER'
107
+ case 'bigInteger':
108
+ return 'BIGINT'
109
+ case 'smallInteger':
110
+ return 'SMALLINT'
111
+ case 'real':
112
+ return 'REAL'
113
+ case 'doublePrecision':
114
+ return 'DOUBLE PRECISION'
115
+ case 'decimal':
116
+ return `NUMERIC(${def.precision}, ${def.scale})`
117
+ case 'varchar':
118
+ return `VARCHAR(${def.length})`
119
+ case 'text':
120
+ return 'TEXT'
121
+ case 'char':
122
+ return `CHAR(${def.length})`
123
+ case 'boolean':
124
+ return 'BOOLEAN'
125
+ case 'timestamp':
126
+ return 'TIMESTAMP'
127
+ case 'timestamptz':
128
+ return 'TIMESTAMPTZ'
129
+ case 'date':
130
+ return 'DATE'
131
+ case 'time':
132
+ return 'TIME'
133
+ case 'uuid':
134
+ return 'UUID'
135
+ case 'json':
136
+ return 'JSON'
137
+ case 'jsonb':
138
+ return 'JSONB'
139
+ case 'enum':
140
+ return 'TEXT'
141
+ case 'bytea':
142
+ return 'BYTEA'
143
+ default:
144
+ throw new MegaConfigError('migration.schema_invalid', `postgres dialect: 알 수 없는 타입 '${def.type}'.`, { details: { type: def.type } })
145
+ }
146
+ }
147
+
148
+ // ── 명명 표준 ────────────────────────────────────────────────────────────────
149
+ /** @param {string} table @param {string[]} cols @param {boolean} [unique] @returns {string} */
150
+ export function indexName(table, cols, unique = false) {
151
+ return `${unique ? 'uniq' : 'idx'}_${table}_${cols.join('_')}`
152
+ }
153
+ /** @param {string} table @param {Record<string, any>} ix @returns {string} 인덱스 정의의 유효 이름. */
154
+ export function resolveIndexName(table, ix) {
155
+ if (ix.name !== undefined) return ix.name
156
+ if (ix.expression !== undefined) {
157
+ throw new MegaConfigError('migration.schema_invalid', `표현식 인덱스는 name 이 필수입니다 (table '${table}', expression '${ix.expression}').`, {
158
+ details: { table, expression: ix.expression },
159
+ })
160
+ }
161
+ return indexName(table, ix.columns, ix.unique === true)
162
+ }
163
+ /** @param {string} table @param {string} col @returns {string} */
164
+ export function uniqueName(table, col) {
165
+ return `uniq_${table}_${col}`
166
+ }
167
+ /** @param {string} table @param {string} col @param {string} refTable @returns {string} */
168
+ export function fkName(table, col, refTable) {
169
+ return `fk_${table}_${col}_${refTable}`
170
+ }
171
+ /** @param {string} table @param {string} col @returns {string} */
172
+ export function checkName(table, col) {
173
+ return `chk_${table}_${col}`
174
+ }
175
+ /** @param {string} table @returns {string} */
176
+ export function pkName(table) {
177
+ return `pk_${table}`
178
+ }
179
+ /** CREATE TABLE 인라인 단일 PK 의 서버 자동 명명. @param {string} table @returns {string} */
180
+ export function inlinePkName(table) {
181
+ return `${table}_pkey`
182
+ }
183
+
184
+ /**
185
+ * enum 컬럼의 CHECK 식 — **유일한 enum CHECK 렌더 지점**(differ 는 values 만 전달, ADR-205 H-1).
186
+ * @param {string} col @param {string[]} values @returns {string}
187
+ */
188
+ export function enumCheckExpr(col, values) {
189
+ return `${quoteIdent(col)} IN (${values.map((v) => renderLiteral(v)).join(', ')})`
190
+ }
191
+
192
+ /**
193
+ * 컬럼 정의 조각(타입 + NOT NULL + DEFAULT [+ PRIMARY KEY]) — CREATE TABLE/ADD COLUMN 공용.
194
+ * unique/check/fk 는 별도 제약 연산으로 렌더한다(명명 표준 유지·드롭 가능).
195
+ * @param {string} name @param {Record<string, any>} def @param {{ inlinePrimary?: boolean }} [opts]
196
+ * @returns {string}
197
+ */
198
+ function columnClause(name, def, { inlinePrimary = true } = {}) {
199
+ let sql = `${quoteIdent(name)} ${typeSql(def)}`
200
+ if (def.notNull === true) sql += ' NOT NULL'
201
+ if (def.default !== undefined) sql += ` DEFAULT ${renderLiteral(def.default)}`
202
+ if (inlinePrimary && def.primary === true) sql += ' PRIMARY KEY'
203
+ return sql
204
+ }
205
+
206
+ /**
207
+ * 한 컬럼의 부속 제약(UNIQUE/CHECK/enum CHECK) ADD CONSTRAINT 문 목록 — FK 는 differ 가 별도 op 로.
208
+ * @param {string} table @param {string} col @param {Record<string, any>} def
209
+ * @returns {string[]}
210
+ */
211
+ function columnConstraintStatements(table, col, def) {
212
+ /** @type {string[]} */
213
+ const out = []
214
+ if (def.unique !== undefined) {
215
+ const name = def.unique === true ? uniqueName(table, col) : def.unique.name
216
+ out.push(`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(name)} UNIQUE (${quoteIdent(col)})`)
217
+ }
218
+ if (def.type === 'enum') {
219
+ const name = def.enumName ?? checkName(table, col)
220
+ out.push(`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(name)} CHECK (${enumCheckExpr(col, def.values)})`)
221
+ }
222
+ if (def.check !== undefined) {
223
+ const name = def.check.name ?? checkName(table, col)
224
+ out.push(`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(name)} CHECK (${def.check.expr})`)
225
+ }
226
+ return out
227
+ }
228
+
229
+ /** FK ADD CONSTRAINT 문. @param {string} table @param {string} col @param {Record<string, any>} ref @returns {string} */
230
+ function fkAddStatement(table, col, ref) {
231
+ const name = ref.name ?? fkName(table, col, ref.table)
232
+ let sql = `ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(name)} FOREIGN KEY (${quoteIdent(col)}) REFERENCES ${quoteIdent(ref.table)} (${quoteIdent(ref.column)})`
233
+ if (ref.onDelete !== undefined) sql += ` ON DELETE ${ref.onDelete.toUpperCase()}`
234
+ if (ref.onUpdate !== undefined) sql += ` ON UPDATE ${ref.onUpdate.toUpperCase()}`
235
+ return sql
236
+ }
237
+
238
+ /** FK DROP CONSTRAINT 문. @param {string} table @param {string} col @param {Record<string, any>} ref @returns {string} */
239
+ function fkDropStatement(table, col, ref) {
240
+ return `ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(ref.name ?? fkName(table, col, ref.table))}`
241
+ }
242
+
243
+ /**
244
+ * CREATE INDEX 문. concurrent=true 면 `CONCURRENTLY`(쓰기 비블로킹 — 단 트랜잭션 밖 실행 필요,
245
+ * generate --concurrent 가 no-tx 마이그레이션으로 묶는다).
246
+ * @param {string} table @param {Record<string, any>} ix @param {{ concurrent?: boolean }} [opts]
247
+ * @returns {string}
248
+ */
249
+ function indexCreateStatement(table, ix, { concurrent = false } = {}) {
250
+ const name = resolveIndexName(table, ix)
251
+ const target = ix.expression !== undefined ? `(${ix.expression})` : `(${ix.columns.map(quoteIdent).join(', ')})`
252
+ let sql = `CREATE ${ix.unique === true ? 'UNIQUE ' : ''}INDEX ${concurrent ? 'CONCURRENTLY ' : ''}${quoteIdent(name)} ON ${quoteIdent(table)}`
253
+ if (ix.using !== undefined) sql += ` USING ${ix.using}`
254
+ sql += ` ${target}`
255
+ if (ix.where !== undefined) sql += ` WHERE ${ix.where}`
256
+ return sql
257
+ }
258
+
259
+ /** COMMENT ON COLUMN 문. @param {string} table @param {string} col @param {string | null} text @returns {string} */
260
+ function commentStatement(table, col, text) {
261
+ return `COMMENT ON COLUMN ${quoteIdent(table)}.${quoteIdent(col)} IS ${text === null ? 'NULL' : renderLiteral(text)}`
262
+ }
263
+
264
+ /**
265
+ * CREATE TABLE 문(+부속 제약/코멘트) — FK·인덱스 제외(별도 op).
266
+ * @param {Record<string, any>} record
267
+ * @returns {string[]}
268
+ */
269
+ function createTableStatements(record) {
270
+ const table = record.table
271
+ /** @type {string[]} */
272
+ const lines = []
273
+ for (const [name, def] of Object.entries(record.columns)) {
274
+ lines.push(' ' + columnClause(name, def))
275
+ }
276
+ if (record.primaryKey !== undefined) {
277
+ lines.push(` CONSTRAINT ${quoteIdent(pkName(table))} PRIMARY KEY (${record.primaryKey.map(quoteIdent).join(', ')})`)
278
+ }
279
+ /** @type {string[]} */
280
+ const out = [`CREATE TABLE ${quoteIdent(table)} (\n${lines.join(',\n')}\n)`]
281
+ for (const [name, def] of Object.entries(record.columns)) {
282
+ out.push(...columnConstraintStatements(table, name, def))
283
+ if (def.comment !== undefined) out.push(commentStatement(table, name, def.comment))
284
+ }
285
+ return out
286
+ }
287
+
288
+ /**
289
+ * ALTER TYPE 시 implicit cast 가 보장되지 않아 USING 식 검토가 필요한지. 안전 목록(정수 확장·
290
+ * varchar↔text·decimal 정밀도·timestamp↔timestamptz·길이 변경) 밖이면 true.
291
+ * @param {Record<string, any>} from @param {Record<string, any>} to @returns {boolean}
292
+ */
293
+ export function isRiskyCast(from, to) {
294
+ const f = from.type
295
+ const t = to.type
296
+ if (f === t) return false // 같은 타입의 파라미터 변경(길이·정밀도)
297
+ const intFamily = ['smallInteger', 'integer', 'bigInteger', 'serial', 'bigSerial']
298
+ if (intFamily.includes(f) && intFamily.includes(t)) return false
299
+ const textFamily = ['varchar', 'text', 'char', 'enum']
300
+ if (textFamily.includes(f) && textFamily.includes(t)) return false
301
+ if ((f === 'timestamp' && t === 'timestamptz') || (f === 'timestamptz' && t === 'timestamp')) return false
302
+ if ((f === 'real' || f === 'doublePrecision' || f === 'decimal') && (t === 'real' || t === 'doublePrecision' || t === 'decimal')) return false
303
+ return true
304
+ }
305
+
306
+ /**
307
+ * 같은 타입의 길이/정밀도 **축소** 인지 — varchar(200→50)·decimal(12,2→8,2) 등. implicit cast 는
308
+ * 존재해 isRiskyCast 로는 안 잡히지만, 기존 데이터가 한도를 넘으면 적용이 실패하거나(varchar)
309
+ * 반올림·잘림이 생긴다 — 경고 주석 대상.
310
+ * @param {Record<string, any>} from @param {Record<string, any>} to @returns {boolean}
311
+ */
312
+ export function isNarrowing(from, to) {
313
+ if (from.type !== to.type) return false
314
+ if (typeof from.length === 'number' && typeof to.length === 'number' && to.length < from.length) return true
315
+ if (typeof from.precision === 'number' && typeof to.precision === 'number' && to.precision < from.precision) return true
316
+ if (typeof from.scale === 'number' && typeof to.scale === 'number' && to.scale < from.scale) return true
317
+ return false
318
+ }
319
+
320
+ /**
321
+ * ALTER TYPE 문(위험 캐스트면 TODO, 같은-타입 축소면 경고 주석 동반).
322
+ * @param {string} table @param {string} col @param {Record<string, any>} from @param {Record<string, any>} to
323
+ * @returns {string}
324
+ */
325
+ function alterTypeStatement(table, col, from, to) {
326
+ const target = typeSql(to, { forAlter: true })
327
+ let sql = `ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(col)} TYPE ${target}`
328
+ if (isRiskyCast(from, to)) {
329
+ sql = `-- TODO: implicit cast 가 없으면 적용이 실패합니다 — USING ${quoteIdent(col)}::${target} 등 캐스트 식을 검토·추가하세요.\n${sql}`
330
+ }
331
+ if (isNarrowing(from, to)) {
332
+ sql = `-- 경고: 길이/정밀도 축소 — 한도를 넘는 기존 데이터가 있으면 적용이 실패하거나 잘림/반올림이 발생합니다. 적용 전 데이터 분포를 확인하세요.\n${sql}`
333
+ }
334
+ return sql
335
+ }
336
+
337
+ /**
338
+ * 변경 연산(op) 1개 → `{ up: string[], down: string[] }`.
339
+ * op 종류·필드는 {@link module:core/migration/differ} 가 정본.
340
+ *
341
+ * @param {Record<string, any>} op
342
+ * @returns {{ up: string[], down: string[] }}
343
+ */
344
+ export function renderOp(op) {
345
+ const table = op.table
346
+ switch (op.kind) {
347
+ case 'createTable':
348
+ return {
349
+ up: createTableStatements(op.record),
350
+ down: [`DROP TABLE IF EXISTS ${quoteIdent(table)}`],
351
+ }
352
+ case 'dropTable': {
353
+ // down = 구조 복원 — 테이블·제약·코멘트(createTableStatements) + 인덱스(DROP TABLE 이 암묵
354
+ // 제거하므로 여기서 재생). 보유 FK 는 differ 가 별도 dropFk op 를 선행 발행하므로(의존 순서
355
+ // 보장) 그 op 의 down 이 복원한다 — 여기서 또 추가하면 down 에서 중복 ADD CONSTRAINT 가 된다.
356
+ const down = createTableStatements(op.record)
357
+ for (const ix of op.record.indexes ?? []) down.push(indexCreateStatement(table, ix))
358
+ return {
359
+ up: [
360
+ `-- 경고: 테이블과 모든 데이터가 영구 삭제됩니다 — down 으로 구조(제약·인덱스 포함)는 복원되지만 데이터는 복원되지 않습니다.\nDROP TABLE IF EXISTS ${quoteIdent(table)}`,
361
+ ],
362
+ down,
363
+ }
364
+ }
365
+ case 'addColumn': {
366
+ /** @type {string[]} */
367
+ const up = []
368
+ let add = `ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${columnClause(op.column, op.def, { inlinePrimary: false })}`
369
+ if (op.def.notNull === true && op.def.default === undefined) {
370
+ add = `-- 경고: 기존 행이 있으면 NOT NULL 컬럼 추가가 실패합니다 — DEFAULT 를 지정하거나 backfill 후 NOT NULL 을 거세요.\n${add}`
371
+ }
372
+ up.push(add)
373
+ up.push(...columnConstraintStatements(table, op.column, op.def))
374
+ if (op.def.comment !== undefined) up.push(commentStatement(table, op.column, op.def.comment))
375
+ return {
376
+ up,
377
+ down: [`-- 경고: 컬럼 데이터가 영구 삭제됩니다.\nALTER TABLE ${quoteIdent(table)} DROP COLUMN ${quoteIdent(op.column)}`],
378
+ }
379
+ }
380
+ case 'dropColumn': {
381
+ // 컬럼 DROP 은 부속 제약(unique/check/fk)도 함께 제거된다(postgres 의존 객체 정리).
382
+ /** @type {string[]} */
383
+ const down = [
384
+ `-- 경고: 구조만 복원됩니다 — 삭제된 컬럼 데이터는 down 으로 복원되지 않습니다.\nALTER TABLE ${quoteIdent(table)} ADD COLUMN ${columnClause(op.column, op.def, { inlinePrimary: false })}`,
385
+ ]
386
+ down.push(...columnConstraintStatements(table, op.column, op.def))
387
+ if (op.def.references !== undefined) down.push(fkAddStatement(table, op.column, op.def.references))
388
+ if (op.def.comment !== undefined) down.push(commentStatement(table, op.column, op.def.comment))
389
+ return {
390
+ up: [`-- 경고: 컬럼 데이터가 영구 삭제됩니다.\nALTER TABLE ${quoteIdent(table)} DROP COLUMN ${quoteIdent(op.column)}`],
391
+ down,
392
+ }
393
+ }
394
+ case 'renameColumn':
395
+ return {
396
+ up: [`ALTER TABLE ${quoteIdent(table)} RENAME COLUMN ${quoteIdent(op.from)} TO ${quoteIdent(op.to)}`],
397
+ down: [`ALTER TABLE ${quoteIdent(table)} RENAME COLUMN ${quoteIdent(op.to)} TO ${quoteIdent(op.from)}`],
398
+ }
399
+ case 'alterType':
400
+ return {
401
+ up: [alterTypeStatement(table, op.column, op.from, op.to)],
402
+ down: [alterTypeStatement(table, op.column, op.to, op.from)],
403
+ }
404
+ case 'setNotNull':
405
+ return {
406
+ up: [`ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} SET NOT NULL`],
407
+ down: [`ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} DROP NOT NULL`],
408
+ }
409
+ case 'dropNotNull':
410
+ return {
411
+ up: [`ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} DROP NOT NULL`],
412
+ down: [`ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} SET NOT NULL`],
413
+ }
414
+ case 'setDefault':
415
+ return {
416
+ up: [`ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} SET DEFAULT ${renderLiteral(op.value)}`],
417
+ down: [
418
+ op.prev === undefined
419
+ ? `ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} DROP DEFAULT`
420
+ : `ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} SET DEFAULT ${renderLiteral(op.prev)}`,
421
+ ],
422
+ }
423
+ case 'dropDefault':
424
+ return {
425
+ up: [`ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} DROP DEFAULT`],
426
+ down: [`ALTER TABLE ${quoteIdent(table)} ALTER COLUMN ${quoteIdent(op.column)} SET DEFAULT ${renderLiteral(op.prev)}`],
427
+ }
428
+ case 'addUnique':
429
+ return {
430
+ up: [`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(op.name)} UNIQUE (${quoteIdent(op.column)})`],
431
+ down: [`ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(op.name)}`],
432
+ }
433
+ case 'dropUnique':
434
+ return {
435
+ up: [`ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(op.name)}`],
436
+ down: [`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(op.name)} UNIQUE (${quoteIdent(op.column)})`],
437
+ }
438
+ // CHECK 식: enum 은 op.values 로 받아 **dialect 가 렌더**한다(식별자 인용 일원화 — differ 의
439
+ // 비인용 식이 camelCase 컬럼에서 적용 실패하던 결함 교정). 명시 check 는 expr 그대로.
440
+ case 'addCheck': {
441
+ const expr = op.values !== undefined ? enumCheckExpr(op.column, op.values) : op.expr
442
+ return {
443
+ up: [`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(op.name)} CHECK (${expr})`],
444
+ down: [`ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(op.name)}`],
445
+ }
446
+ }
447
+ case 'dropCheck': {
448
+ const expr = op.values !== undefined ? enumCheckExpr(op.column, op.values) : op.expr
449
+ return {
450
+ up: [`ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(op.name)}`],
451
+ down: [`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(op.name)} CHECK (${expr})`],
452
+ }
453
+ }
454
+ case 'addFk':
455
+ return {
456
+ up: [fkAddStatement(table, op.column, op.ref)],
457
+ down: [fkDropStatement(table, op.column, op.ref)],
458
+ }
459
+ case 'dropFk':
460
+ return {
461
+ up: [fkDropStatement(table, op.column, op.ref)],
462
+ down: [fkAddStatement(table, op.column, op.ref)],
463
+ }
464
+ // 컬럼 rename 시 FK 기본 이름(fk_<table>_<col>_<ref>) 동기화 — 실 DB 제약 이름과 명명 표준의
465
+ // 드리프트 방지(이후 FK 변경 마이그레이션이 옛 이름을 못 찾는 결함 교정).
466
+ case 'renameFk':
467
+ return {
468
+ up: [`ALTER TABLE ${quoteIdent(table)} RENAME CONSTRAINT ${quoteIdent(op.from)} TO ${quoteIdent(op.to)}`],
469
+ down: [`ALTER TABLE ${quoteIdent(table)} RENAME CONSTRAINT ${quoteIdent(op.to)} TO ${quoteIdent(op.from)}`],
470
+ }
471
+ // PK 제약 이름: 복합/후행 추가 = pk_<table>(우리 명명), CREATE TABLE 인라인 단일 PK = postgres
472
+ // 자동 명명 '<table>_pkey' — differ 가 출처를 알고 op.name 으로 확정해 넘긴다.
473
+ case 'addPk': {
474
+ const name = op.name ?? pkName(table)
475
+ return {
476
+ up: [`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(name)} PRIMARY KEY (${op.columns.map(quoteIdent).join(', ')})`],
477
+ down: [`ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(name)}`],
478
+ }
479
+ }
480
+ case 'dropPk': {
481
+ const name = op.name ?? pkName(table)
482
+ return {
483
+ up: [`ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(name)}`],
484
+ down: [`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(name)} PRIMARY KEY (${op.columns.map(quoteIdent).join(', ')})`],
485
+ }
486
+ }
487
+ // PK 교체 — postgres 는 DROP/ADD CONSTRAINT 2문이 그대로 안전(트랜잭셔널 DDL).
488
+ case 'changePk': {
489
+ const stmts = (/** @type {any} */ from, /** @type {any} */ to) => [
490
+ `ALTER TABLE ${quoteIdent(table)} DROP CONSTRAINT ${quoteIdent(from.name)}`,
491
+ `ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(to.name)} PRIMARY KEY (${to.columns.map(quoteIdent).join(', ')})`,
492
+ ]
493
+ return { up: stmts(op.from, op.to), down: stmts(op.to, op.from) }
494
+ }
495
+ case 'addIndex':
496
+ return {
497
+ up: [indexCreateStatement(table, op.index, { concurrent: op.concurrent === true })],
498
+ down: [`DROP INDEX IF EXISTS ${quoteIdent(resolveIndexName(table, op.index))}`],
499
+ }
500
+ case 'dropIndex':
501
+ return {
502
+ up: [`DROP INDEX IF EXISTS ${quoteIdent(resolveIndexName(table, op.index))}`],
503
+ down: [indexCreateStatement(table, op.index)],
504
+ }
505
+ case 'setComment':
506
+ return {
507
+ up: [commentStatement(table, op.column, op.comment)],
508
+ down: [commentStatement(table, op.column, op.prev ?? null)],
509
+ }
510
+ default:
511
+ throw new MegaConfigError('migration.schema_invalid', `postgres dialect: 알 수 없는 변경 연산 '${op.kind}'.`, { details: { kind: op.kind } })
512
+ }
513
+ }
514
+
515
+ /**
516
+ * 연산 목록 → `{ up: string[], down: string[] }`. down 은 **역순**(가장 마지막 변경부터 되돌림).
517
+ * @param {Array<Record<string, any>>} ops
518
+ * @returns {{ up: string[], down: string[] }}
519
+ */
520
+ export function renderOps(ops) {
521
+ /** @type {string[]} */
522
+ const up = []
523
+ /** @type {string[][]} */
524
+ const downs = []
525
+ for (const op of ops) {
526
+ const r = renderOp(op)
527
+ up.push(...r.up)
528
+ downs.push(r.down)
529
+ }
530
+ return { up, down: downs.reverse().flat() }
531
+ }
532
+
533
+ // ── DML facet (ADR-212) — DDL 마이그레이션 계약과 별개로 CRUD(model-crud)가 쓰는 DML 보조. ──
534
+ // 같은 모듈에 두어 driver 진실을 1곳에 유지(중복 레지스트리 신설 아님). quoteIdent 재사용.
535
+
536
+ /** placeholder 스타일 — postgres 는 번호식 `$1`. */
537
+ export const paramStyle = 'numbered'
538
+
539
+ /** @param {number} i - 1-base placeholder 인덱스. @returns {string} */
540
+ export function placeholder(i) {
541
+ return `$${i}`
542
+ }
543
+
544
+ /** RETURNING 지원(insert/upsert 레코드 반환). */
545
+ export const supportsReturning = true
546
+ /** insertMany RETURNING 지원. */
547
+ export const supportsBulkReturning = true
548
+
549
+ /** SELECT native 결과(pg `{ rows }`) → record[]. @param {any} res @returns {any[]} */
550
+ export function parseReadResult(res) {
551
+ return Array.isArray(res?.rows) ? res.rows : []
552
+ }
553
+
554
+ /** write native 결과(pg `{ rowCount, rows }`) → 정규화. @param {any} res @returns {{ count: number, rows?: any[] }} */
555
+ export function parseWriteResult(res) {
556
+ return { count: typeof res?.rowCount === 'number' ? res.rowCount : 0, rows: res?.rows }
557
+ }
558
+
559
+ /** upsert 절 — `ON CONFLICT (...) DO UPDATE SET ...`. @param {{ conflictCols: string[], updateCols: string[] }} o @returns {string} */
560
+ export function upsertClause({ conflictCols, updateCols }) {
561
+ const set = updateCols.map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`).join(', ')
562
+ return `ON CONFLICT (${conflictCols.map(quoteIdent).join(', ')}) DO UPDATE SET ${set}`
563
+ }