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
@@ -23,7 +23,7 @@
23
23
  * ADR-094 의 ASP echo 데모 hub 를 본 12-타입 hub 로 교체(ADR-097). 클라↔bridge ASP
24
24
  * round-trip 검증은 embedded 종단(`core/ws-upgrade.js`, ws-upgrade.integration)으로 이전됨.
25
25
  *
26
- * @module cli/ws-hub
26
+ * @module lib/ws-hub
27
27
  */
28
28
  import { createHash, timingSafeEqual } from 'node:crypto'
29
29
  import { WebSocketServer } from 'ws'
@@ -32,8 +32,10 @@ import { buildPerMessageDeflate, COMPRESSION_DEFAULTS } from '../core/ws-compres
32
32
  import {
33
33
  HUB_MESSAGE_TYPES,
34
34
  HUB_CLOSE_CODES,
35
+ HUB_PROTOCOL_VERSION,
35
36
  createHubMessage,
36
37
  validateHubMessage,
38
+ negotiateHubProtocolVersion,
37
39
  } from '../lib/hub-protocol.js'
38
40
  import { MegaShutdown } from '../lib/mega-shutdown.js'
39
41
 
@@ -45,6 +47,12 @@ export const DEFAULT_HEARTBEAT_MS = 25_000
45
47
  /** 기본 최대 프레임 크기 (bytes, L3). 1 MiB — 정상 envelope 은 수 KB 이므로 넉넉하다. */
46
48
  export const DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576
47
49
 
50
+ /**
51
+ * bridge 별 송신 버퍼(bufferedAmount) 기본 상한(바이트) — 16 MiB. 초과 = 느린 bridge 가 fan-out 을 못
52
+ * 따라오는 것 → terminate(백프레셔). 클라↔bridge 종단(`ws-upgrade.js` DEFAULT_MAX_BUFFERED_BYTES)과 대칭.
53
+ */
54
+ export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
55
+
48
56
  /**
49
57
  * 토큰 timing-safe 비교 — sha256 으로 길이 정규화 후 `timingSafeEqual`. 후보 전체를 순회하여
50
58
  * 조기 반환 timing 누출도 줄인다(early-return 안 함).
@@ -68,19 +76,23 @@ export class MegaWsHub {
68
76
  * @param {string[]} [opts.acceptedTokens] - Bridge Bearer 토큰 화이트리스트 (비거나 누락 시 throw).
69
77
  * @param {number} [opts.heartbeatMs=25000] - register_ok 로 알려줄 heartbeat 주기.
70
78
  * @param {number} [opts.maxPayloadBytes=1048576] - WS 프레임 최대 크기(L3). 초과 시 ws 가 1009 close.
79
+ * @param {number} [opts.maxBufferedBytes=16777216] - bridge 별 송신 버퍼(bufferedAmount) 상한(바이트).
80
+ * 초과한 bridge 는 느린 소비자로 보고 terminate 한다 — fan-out 허브에서 bridge 1개가 느려도 모든
81
+ * 채널 메시지가 그 소켓 버퍼(힙)에 무한 적재돼 OOM 으로 가는 것을 막는다(백프레셔).
71
82
  * @param {string} [opts.hubId] - hub 식별자. 기본 ULID 자동 생성.
72
83
  * @param {import('../core/ws-compression.js').WsCompressionConfig} [opts.compression] - Bridge↔Hub
73
84
  * link per-message deflate 압축(ADR-078 / wsHub.compression). 디폴트 OFF.
74
85
  * bridge(MegaHubLink)와 양쪽이 협상해야 활성. 잘못된 threshold/windowBits 면 즉시 throw.
75
86
  * @param {{ warn?: Function, debug?: Function, info?: Function, error?: Function }} [opts.logger]
76
87
  */
77
- constructor({ acceptedTokens, heartbeatMs = DEFAULT_HEARTBEAT_MS, maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES, hubId, compression, logger } = {}) {
88
+ constructor({ acceptedTokens, heartbeatMs = DEFAULT_HEARTBEAT_MS, maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES, maxBufferedBytes = DEFAULT_MAX_BUFFERED_BYTES, hubId, compression, logger } = {}) {
78
89
  if (!Array.isArray(acceptedTokens) || acceptedTokens.length === 0) {
79
90
  throw new Error('MegaWsHub: acceptedTokens must be a non-empty array (ADR-059).')
80
91
  }
81
92
  this._acceptedTokens = [...acceptedTokens]
82
93
  this._heartbeatMs = Number.isInteger(heartbeatMs) && heartbeatMs > 0 ? heartbeatMs : DEFAULT_HEARTBEAT_MS
83
94
  this._maxPayloadBytes = Number.isInteger(maxPayloadBytes) && maxPayloadBytes > 0 ? maxPayloadBytes : DEFAULT_MAX_PAYLOAD_BYTES
95
+ this._maxBufferedBytes = Number.isInteger(maxBufferedBytes) && maxBufferedBytes > 0 ? maxBufferedBytes : DEFAULT_MAX_BUFFERED_BYTES
84
96
  this._hubId = typeof hubId === 'string' && hubId.length > 0 ? hubId : `hub-${generateMessageId()}`
85
97
  // Bridge↔Hub link 압축(ADR-078). enabled=false → false. 잘못된 값은 생성자에서 즉시 throw.
86
98
  /** @type {false | Object} WebSocketServer perMessageDeflate 로 전달(start). */
@@ -91,7 +103,7 @@ export class MegaWsHub {
91
103
  this._wss = null
92
104
  /** heartbeat liveness 체크 interval (M3). @type {ReturnType<typeof setInterval> | null} */
93
105
  this._livenessTimer = null
94
- /** 등록된 bridge 연결. connId → { socket, lastSeen }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number }>} */
106
+ /** 등록된 bridge 연결. connId → { socket, lastSeen, protocolVersion, presenceFanout }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number, presenceFanout?: boolean }>} */
95
107
  this._bridges = new Map()
96
108
  /** presence. sessionId → { bridgeConnId, userId, channels:Set, metadata }. @type {Map<string, { bridgeConnId: string, userId: string, channels: Set<string>, metadata: Object }>} */
97
109
  this._sessions = new Map()
@@ -99,6 +111,13 @@ export class MegaWsHub {
99
111
  this._channelSessions = new Map()
100
112
  /** userId → sessionId 집합 (DIRECT fan-out, ADR-035). @type {Map<string, Set<string>>} */
101
113
  this._userSessions = new Map()
114
+ /**
115
+ * 역인덱스: channel → (bridgeConnId → 그 bridge 의 채널 내 세션 수). BROADCAST 의 bridge 선정을
116
+ * 세션 수 무관 O(bridge 수)로 만든다 — 멤버 10k 채널에서 메시지당 수백 µs 가 µs 대로 떨어진다.
117
+ * `_addSession`/`_removeSession` 이 증분 유지(카운트 0 → 키 제거), 메모리는 채널×bridge 수준.
118
+ * @type {Map<string, Map<string, number>>}
119
+ */
120
+ this._channelBridges = new Map()
102
121
  }
103
122
 
104
123
  /** hub 식별자. */
@@ -158,6 +177,20 @@ export class MegaWsHub {
158
177
  let isRegistered = false
159
178
  this._log?.debug?.({ connId }, 'ws-hub connection (awaiting register)')
160
179
 
180
+ // 미등록 연결 register 타임아웃 — liveness(_checkLiveness)는 등록된 bridge 만 스캔하므로,
181
+ // REGISTER 를 안 보내는 연결(오설정 bridge·포트 스캐너·half-open)은 이 타이머가 없으면
182
+ // fd/메모리로 영구 잔존한다. heartbeatMs 안에 등록 못 하면 1008 로 닫는다(fail-closed).
183
+ const registerTimer = setTimeout(() => {
184
+ if (isRegistered) return
185
+ this._log?.warn?.({ connId, timeoutMs: this._heartbeatMs }, 'ws-hub register timeout — closing unregistered connection (1008)')
186
+ try {
187
+ socket.close(1008, 'register timeout')
188
+ } catch (err) {
189
+ this._log?.debug?.({ err, connId }, 'ws-hub register-timeout close failed (already closing)')
190
+ }
191
+ }, this._heartbeatMs)
192
+ registerTimer.unref?.()
193
+
161
194
  socket.on('message', (raw) => {
162
195
  const frame = Array.isArray(raw) ? Buffer.concat(raw).toString('utf8') : raw.toString('utf8')
163
196
  let msg
@@ -184,12 +217,14 @@ export class MegaWsHub {
184
217
  return
185
218
  }
186
219
  isRegistered = this._handleRegister(connId, socket, msg)
220
+ if (isRegistered) clearTimeout(registerTimer)
187
221
  return
188
222
  }
189
223
  this._route(connId, socket, msg)
190
224
  })
191
225
 
192
226
  socket.on('close', () => {
227
+ clearTimeout(registerTimer) // 미등록 타임아웃 정리(등록 전 절단·정상 종료 공통).
193
228
  if (isRegistered) this._handleBridgeGone(connId)
194
229
  this._log?.debug?.({ connId }, 'ws-hub connection closed')
195
230
  })
@@ -209,16 +244,33 @@ export class MegaWsHub {
209
244
  * @private
210
245
  */
211
246
  _handleRegister(connId, socket, msg) {
212
- const payload = /** @type {{ instanceId: string, token: string, capabilities: string[] }} */ (/** @type {any} */ (msg).payload)
247
+ const payload = /** @type {{ instanceId: string, token: string, capabilities: string[], protocolVersion?: number }} */ (/** @type {any} */ (msg).payload)
213
248
  if (!isTokenAccepted(payload.token, this._acceptedTokens)) {
214
249
  this._log?.warn?.({ connId, instanceId: payload.instanceId }, 'ws-hub register denied — bad token (ADR-059)')
215
250
  this._safeSend(socket, createHubMessage({ type: T.ERROR, error: { code: 'hub.unauthorized', message: 'invalid bridge token' }, ref: /** @type {any} */ (msg).id }))
216
251
  socket.close(1008, 'unauthorized') // RFC 6455 1008 = policy violation
217
252
  return false
218
253
  }
219
- this._bridges.set(connId, { socket, lastSeen: Date.now() })
220
- this._log?.info?.({ connId, instanceId: payload.instanceId, hubId: this._hubId }, 'ws-hub bridge registered')
221
- this._safeSend(socket, createHubMessage({ type: T.REGISTER_OK, payload: { hubId: this._hubId, acceptedAt: Date.now(), heartbeatMs: this._heartbeatMs } }))
254
+ // 프로토콜 버전 협상 — bridge 가 최대 지원 버전을 실으면 상호 최고 버전(min) 채택해 회신한다.
255
+ // 부재 = 레거시 bridge v1 고정 + register_ok 에 필드 미포함(strict 스키마 bridge 가 안 깨지게).
256
+ const hasVersionRequest = payload.protocolVersion !== undefined
257
+ const protocolVersion = hasVersionRequest
258
+ ? negotiateHubProtocolVersion(payload.protocolVersion)
259
+ : HUB_PROTOCOL_VERSION
260
+ // presence fan-out 은 옵트인 — roster 가 redis 로 분리(ADR-177)된 뒤 bridge 는 JOIN/LEAVE/METADATA
261
+ // 를 no-op 으로 버리므로, `presence-fanout` capability 를 선언한 bridge 에만 보낸다(죽은 트래픽 제거).
262
+ const presenceFanout = Array.isArray(payload.capabilities) && payload.capabilities.includes('presence-fanout')
263
+ this._bridges.set(connId, { socket, lastSeen: Date.now(), protocolVersion, presenceFanout })
264
+ this._log?.info?.({ connId, instanceId: payload.instanceId, hubId: this._hubId, protocolVersion }, 'ws-hub bridge registered')
265
+ this._safeSend(socket, createHubMessage({
266
+ type: T.REGISTER_OK,
267
+ payload: {
268
+ hubId: this._hubId,
269
+ acceptedAt: Date.now(),
270
+ heartbeatMs: this._heartbeatMs,
271
+ ...(hasVersionRequest ? { protocolVersion } : {}),
272
+ },
273
+ }))
222
274
  return true
223
275
  }
224
276
 
@@ -236,12 +288,12 @@ export class MegaWsHub {
236
288
  switch (type) {
237
289
  case T.JOIN:
238
290
  this._addSession(connId, payload)
239
- // 클러스터 presence 공유 — 다른 모든 bridge 같은 JOIN fan-out (07 §2).
240
- this._fanOutToOthers(connId, msg)
291
+ // 클러스터 presence 공유 — `presence-fanout` 선언 bridge 에만 (07 §2 + ADR-177 죽은 트래픽 제거).
292
+ this._fanOutPresence(connId, msg)
241
293
  break
242
294
  case T.LEAVE: {
243
295
  const removed = this._removeSession(payload.sessionId)
244
- if (removed) this._fanOutToOthers(connId, msg)
296
+ if (removed) this._fanOutPresence(connId, msg)
245
297
  break
246
298
  }
247
299
  case T.BULK_LEAVE: {
@@ -263,7 +315,7 @@ export class MegaWsHub {
263
315
  const session = this._sessions.get(payload.sessionId)
264
316
  if (session) {
265
317
  session.metadata = payload.metadata
266
- this._fanOutToOthers(connId, msg) // presence 메타 동기화
318
+ this._fanOutPresence(connId, msg) // presence 메타 동기화 — 옵트인 bridge 만.
267
319
  }
268
320
  break
269
321
  }
@@ -334,6 +386,7 @@ export class MegaWsHub {
334
386
  this._channelSessions.set(ch, set)
335
387
  }
336
388
  set.add(entry.sessionId)
389
+ this._bumpChannelBridge(ch, connId, +1)
337
390
  }
338
391
  let uset = this._userSessions.get(entry.userId)
339
392
  if (!uset) {
@@ -343,6 +396,27 @@ export class MegaWsHub {
343
396
  uset.add(entry.sessionId)
344
397
  }
345
398
 
399
+ /**
400
+ * channel→bridge 역인덱스 증분 갱신. 카운트가 0 이 되면 키를 지워 인덱스가 stale 하지 않게 한다.
401
+ * @param {string} channel @param {string} connId @param {1|-1} delta
402
+ * @private
403
+ */
404
+ _bumpChannelBridge(channel, connId, delta) {
405
+ let counts = this._channelBridges.get(channel)
406
+ if (!counts) {
407
+ if (delta < 0) return // 제거인데 인덱스 없음 — 정합상 없을 일이지만 방어.
408
+ counts = new Map()
409
+ this._channelBridges.set(channel, counts)
410
+ }
411
+ const next = (counts.get(connId) ?? 0) + delta
412
+ if (next > 0) {
413
+ counts.set(connId, next)
414
+ } else {
415
+ counts.delete(connId)
416
+ if (counts.size === 0) this._channelBridges.delete(channel)
417
+ }
418
+ }
419
+
346
420
  /**
347
421
  * presence 에서 세션 제거. 빈 인덱스는 정리.
348
422
  * @param {string} sessionId
@@ -359,6 +433,7 @@ export class MegaWsHub {
359
433
  set.delete(sessionId)
360
434
  if (set.size === 0) this._channelSessions.delete(ch)
361
435
  }
436
+ this._bumpChannelBridge(ch, session.bridgeConnId, -1)
362
437
  }
363
438
  const uset = this._userSessions.get(session.userId)
364
439
  if (uset) {
@@ -389,7 +464,8 @@ export class MegaWsHub {
389
464
  }
390
465
 
391
466
  /**
392
- * origin 을 제외한 모든 등록 bridge 로 송신 (presence 공유용). envelope 는 1회만 직렬화(L5).
467
+ * origin 을 제외한 모든 등록 bridge 로 송신. envelope 는 1회만 직렬화(L5).
468
+ * BULK_LEAVE(bridge-gone 정리 통지)처럼 전 bridge 가 받아야 하는 통지에 쓴다.
393
469
  * @param {string} exceptConnId
394
470
  * @param {Object} envelope
395
471
  * @private
@@ -401,6 +477,25 @@ export class MegaWsHub {
401
477
  }
402
478
  }
403
479
 
480
+ /**
481
+ * presence(JOIN/LEAVE/METADATA) fan-out — `presence-fanout` capability 를 선언한 bridge 에만 송신.
482
+ * roster 가 redis 로 분리(ADR-177)된 뒤 표준 bridge 는 이 타입들을 no-op 으로 버리므로, 옵트인하지
483
+ * 않은 bridge 에는 보내지 않는다 — 세션 churn × bridge 수 만큼의 죽은 프레임을 제거한다.
484
+ * 구버전 bridge 호환: hub presence 를 실제로 쓰려는 bridge 는 capability 로 명시 선언한다.
485
+ * @param {string} exceptConnId
486
+ * @param {Object} envelope
487
+ * @private
488
+ */
489
+ _fanOutPresence(exceptConnId, envelope) {
490
+ /** @type {string | null} 첫 대상에서 1회 직렬화(L5) — 대상 0 이면 직렬화도 안 함. */
491
+ let data = null
492
+ for (const [connId, bridge] of this._bridges) {
493
+ if (connId === exceptConnId || !(/** @type {any} */ (bridge).presenceFanout)) continue
494
+ if (data === null) data = JSON.stringify(envelope)
495
+ this._sendSerialized(bridge.socket, data)
496
+ }
497
+ }
498
+
404
499
  /**
405
500
  * 한 채널의 세션을 가진 bridge 들로 fan-out (origin 제외, 중복 bridge 1회). 직렬화 1회(L5).
406
501
  *
@@ -415,12 +510,24 @@ export class MegaWsHub {
415
510
  * @private
416
511
  */
417
512
  _fanOutChannel(channel, exceptConnId, envelope, exceptSessionIds) {
513
+ const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
514
+ if (!except) {
515
+ // 일반 경로(대부분의 메시지): channel→bridge 역인덱스로 선정이 O(bridge 수) — 세션 수 무관.
516
+ const counts = this._channelBridges.get(channel)
517
+ if (!counts || counts.size === 0) return
518
+ const data = JSON.stringify(envelope)
519
+ for (const connId of counts.keys()) {
520
+ if (connId !== exceptConnId) this._sendTo(connId, data)
521
+ }
522
+ return
523
+ }
524
+ // exceptSessionIds 경로(드묾): "제외 세션만 가진 bridge 통째 스킵" 판정에 세션 단위 정보가
525
+ // 필요하므로 기존 멤버 순회를 유지한다.
418
526
  const sids = this._channelSessions.get(channel)
419
527
  if (!sids) return
420
- const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
421
528
  const targets = new Set()
422
529
  for (const sid of sids) {
423
- if (except && except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
530
+ if (except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
424
531
  const session = this._sessions.get(sid)
425
532
  if (session && session.bridgeConnId !== exceptConnId) targets.add(session.bridgeConnId)
426
533
  }
@@ -478,6 +585,22 @@ export class MegaWsHub {
478
585
  * @private
479
586
  */
480
587
  _sendSerialized(socket, serialized) {
588
+ // 느린 bridge 백프레셔 가드: 송신 버퍼가 상한을 넘으면 더 쌓지 않고 연결을 끊는다 — fan-out 허브는
589
+ // bridge 1개만 느려도 모든 채널 메시지가 그 소켓 버퍼(힙)에 적재돼 OOM 으로 가는 최악 지점이다.
590
+ // terminate 후 'close' 이벤트가 `_handleBridgeGone` 을 불러 presence 정리 + BULK_LEAVE fan-out 이
591
+ // 따라온다(재연결은 bridge 의 retry 책임, ADR-098).
592
+ if (socket.bufferedAmount > this._maxBufferedBytes) {
593
+ this._log?.warn?.(
594
+ { bufferedAmount: socket.bufferedAmount, max: this._maxBufferedBytes },
595
+ 'ws-hub slow bridge — terminating (backpressure)',
596
+ )
597
+ try {
598
+ socket.terminate()
599
+ } catch (err) {
600
+ this._log?.debug?.({ err }, 'ws-hub backpressure terminate failed (already closing)')
601
+ }
602
+ return
603
+ }
481
604
  try {
482
605
  socket.send(serialized)
483
606
  } catch (err) {
@@ -575,7 +698,8 @@ export async function runWsHubCli() {
575
698
  const hub = new MegaWsHub({ acceptedTokens, heartbeatMs, maxPayloadBytes, compression, logger: console })
576
699
  const addr = await hub.start({ port, host })
577
700
  // 독립 hub 프로세스 graceful shutdown(L2 + drain) — SIGTERM/SIGINT → hub.stop({ drain: true }).
578
- MegaShutdown.register('mega-ws-hub', async () => hub.stop({ drain: true }))
701
+ // 'server' stage hub 도 수용 종단이라 가장 먼저 drain 종료한다.
702
+ MegaShutdown.register('mega-ws-hub', async () => hub.stop({ drain: true }), { stage: 'server' })
579
703
  MegaShutdown.setupSignals()
580
704
  console.log(`[mega:ws-hub] listening on ${addr.host}:${addr.port} (hubId=${hub.hubId})`)
581
705
  return hub
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ /**
3
+ * CRUD SQL 조각 빌더 (ADR-212) — **순수 함수**(DB·어댑터 불요, 단위 테스트 용이).
4
+ *
5
+ * 모든 식별자(컬럼·테이블)는 `static schema` 화이트리스트에서만 도출하고 `dialect.quoteIdent` 로 인용한다.
6
+ * 값은 100% 파라미터 바인딩(`dialect.placeholder(i)` + params). **문자열 인터폴레이션 0**(ADR-009 안전 경계).
7
+ *
8
+ * @module models/crud-sql-builder
9
+ */
10
+ import { MegaInternalError } from '../errors/http-errors.js'
11
+
12
+ /**
13
+ * @typedef {Object} CrudDialect - dialect 모듈의 DML facet(ADR-212) 일부.
14
+ * @property {(name: string) => string} quoteIdent
15
+ * @property {(i: number) => string} placeholder
16
+ */
17
+
18
+ /** 컬럼이 schema 화이트리스트에 있는지 — 없으면 throw. @param {string} col @param {Set<string>} cols @param {string} model @param {string} where */
19
+ export function assertColumn(col, cols, model, where) {
20
+ if (!cols.has(col)) {
21
+ throw new MegaInternalError(
22
+ 'model.unknown_column',
23
+ `${model}: '${col}' 은 static schema 컬럼이 아닙니다(${where}). 선언된 컬럼만 CRUD 에 쓸 수 있습니다 — 임의 SQL 은 this.query 사용.`,
24
+ { details: { model, column: col, where } },
25
+ )
26
+ }
27
+ }
28
+
29
+ /** limit/offset 정수 검증 — 음수/비정수 throw, 통과하면 그 정수 반환. @param {unknown} v @param {string} name @param {string} model @returns {number} */
30
+ export function assertNonNegInt(v, name, model) {
31
+ if (!Number.isInteger(v) || /** @type {number} */ (v) < 0) {
32
+ throw new MegaInternalError(
33
+ 'model.invalid_pagination',
34
+ `${model}: ${name} 은 0 이상의 정수여야 합니다(got ${JSON.stringify(v)}).`,
35
+ {
36
+ details: { model, [name]: v },
37
+ },
38
+ )
39
+ }
40
+ return /** @type {number} */ (v)
41
+ }
42
+
43
+ /**
44
+ * filter → WHERE 조각(접두 'WHERE' 없음) + params + 다음 placeholder 인덱스.
45
+ * 등호 AND + `IN`(배열) + `IS NULL`(null) 만(ADR-212 경계). `undefined` 값은 throw, 빈 배열은 `1=0`(매칭 0).
46
+ *
47
+ * @param {Record<string, any>} filter
48
+ * @param {Set<string>} cols - schema 컬럼 화이트리스트
49
+ * @param {CrudDialect} dialect
50
+ * @param {string} model - 에러 메시지용
51
+ * @param {number} [startIndex=1] - placeholder 시작 인덱스(SET 뒤 WHERE 처럼 이어붙일 때)
52
+ * @returns {{ clause: string, params: any[], nextIndex: number }}
53
+ */
54
+ export function buildWhere(filter, cols, dialect, model, startIndex = 1) {
55
+ /** @type {string[]} */
56
+ const parts = []
57
+ /** @type {any[]} */
58
+ const params = []
59
+ let i = startIndex
60
+ for (const key of Object.keys(filter)) {
61
+ assertColumn(key, cols, model, 'filter')
62
+ const v = filter[key]
63
+ const id = dialect.quoteIdent(key)
64
+ if (v === undefined) {
65
+ throw new MegaInternalError(
66
+ 'model.invalid_filter',
67
+ `${model}: filter['${key}'] 가 undefined 입니다(null 과 구분 — 의도 모호).`,
68
+ {
69
+ details: { model, column: key },
70
+ },
71
+ )
72
+ }
73
+ if (v === null) {
74
+ parts.push(`${id} IS NULL`)
75
+ continue
76
+ }
77
+ if (Array.isArray(v)) {
78
+ if (v.length === 0) {
79
+ parts.push('1=0') // 빈 IN → 매칭 0(안전).
80
+ continue
81
+ }
82
+ const phs = v.map(() => dialect.placeholder(i++))
83
+ parts.push(`${id} IN (${phs.join(', ')})`)
84
+ params.push(...v)
85
+ continue
86
+ }
87
+ parts.push(`${id} = ${dialect.placeholder(i++)}`)
88
+ params.push(v)
89
+ }
90
+ return { clause: parts.join(' AND '), params, nextIndex: i }
91
+ }
92
+
93
+ /**
94
+ * orderBy 옵션 → `ORDER BY ...` 조각(없으면 ''). 컬럼 화이트리스트 + dir enum.
95
+ * @param {string | Array<{ column: string, dir?: 'asc'|'desc' }> | undefined} orderBy
96
+ * @param {Set<string>} cols @param {CrudDialect} dialect @param {string} model
97
+ * @returns {string}
98
+ */
99
+ export function buildOrderBy(orderBy, cols, dialect, model) {
100
+ if (orderBy === undefined) return ''
101
+ const list = typeof orderBy === 'string' ? [{ column: orderBy }] : orderBy
102
+ if (!Array.isArray(list) || list.length === 0) return ''
103
+ const items = list.map((o) => {
104
+ const col = typeof o === 'string' ? o : o.column
105
+ assertColumn(col, cols, model, 'orderBy')
106
+ const dir = typeof o === 'string' ? 'asc' : (o.dir ?? 'asc')
107
+ if (dir !== 'asc' && dir !== 'desc') {
108
+ throw new MegaInternalError(
109
+ 'model.invalid_pagination',
110
+ `${model}: orderBy dir 은 'asc'|'desc' 만 — got '${dir}'.`,
111
+ { details: { model, dir } },
112
+ )
113
+ }
114
+ return `${dialect.quoteIdent(col)} ${dir.toUpperCase()}`
115
+ })
116
+ return ` ORDER BY ${items.join(', ')}`
117
+ }
118
+
119
+ /**
120
+ * select 옵션 → 컬럼 목록 조각(`*` 또는 인용 컬럼). 화이트리스트 검증.
121
+ * @param {string[] | undefined} select @param {Set<string>} cols @param {CrudDialect} dialect @param {string} model
122
+ * @returns {string}
123
+ */
124
+ export function buildSelectList(select, cols, dialect, model) {
125
+ if (select === undefined) return '*'
126
+ if (!Array.isArray(select) || select.length === 0) return '*'
127
+ return select
128
+ .map((c) => {
129
+ assertColumn(c, cols, model, 'select')
130
+ return dialect.quoteIdent(c)
131
+ })
132
+ .join(', ')
133
+ }
@@ -41,6 +41,7 @@
41
41
  */
42
42
  import { MegaInternalError } from '../errors/http-errors.js'
43
43
  import * as MegaAdapterManager from '../adapters/adapter-manager.js'
44
+ import * as crud from './model-crud.js'
44
45
 
45
46
  export class MegaModel {
46
47
  /**
@@ -85,12 +86,16 @@ export class MegaModel {
85
86
  * `fn` 인자 수는 어댑터별로 다르다 — SQL 어댑터는 `(client)` 1개, Mongo 어댑터는 `(db, session)`
86
87
  * 2개를 넘긴다(ADR-108). 따라서 가변 인자로 타입을 둔다(어댑터가 인자 형태의 정본).
87
88
  *
89
+ * `opts.isolation`(ADR-190) — SQL 격리수준 옵트인. driver 별 지원·제약은 어댑터가 정본
90
+ * (postgres/maria=top-level 만, sqlite='serializable' 만, mongodb=미지원 throw).
91
+ *
88
92
  * @template T
89
93
  * @param {(...args: any[]) => Promise<T>} fn - 트랜잭션 컨텍스트 native handle(들)을 받는 콜백.
94
+ * @param {{ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' }} [opts] - 트랜잭션 옵션(ADR-190).
90
95
  * @returns {Promise<T>}
91
96
  */
92
- static async withTransaction(fn) {
93
- return this._resolveAdapter().withTransaction(fn)
97
+ static async withTransaction(fn, opts) {
98
+ return this._resolveAdapter().withTransaction(fn, opts)
94
99
  }
95
100
 
96
101
  /**
@@ -109,6 +114,81 @@ export class MegaModel {
109
114
  return this._resolveAdapter().query(sql, params)
110
115
  }
111
116
 
117
+ // ── 공통 CRUD (ADR-212) ────────────────────────────────────────────────
118
+ //
119
+ // `static schema`(ADR-204)를 선언한 모델에 한해, 컬럼·PK 메타에서 도출된 **bounded** CRUD 를
120
+ // 제공한다. 모든 식별자는 schema 화이트리스트 + `quoteIdent`, 값은 100% 파라미터 바인딩이라
121
+ // SQL 인젝션 표면이 없다(crud-sql-builder). raw 트랙(`this.query`/`this.db`)은 그대로 살아있다 —
122
+ // CRUD 는 추가 표면일 뿐 ADR-009 native 형태를 바꾸지 않는다. driver→dialect(DML facet) 디스패치는
123
+ // 단일 base(MegaModel)에서 런타임에 결정한다(A안). mongo 는 미지원(P3) — 호출 시 명시 throw.
124
+ //
125
+ // 본문·정규화·에러는 model-crud 에 위임하고 여기서는 `this`(Model 클래스)만 넘긴다.
126
+
127
+ /** 단건 조회(없으면 null). filter 는 등호 AND + 배열 IN + null IS NULL. @param {Record<string, any>} filter @param {{ select?: string[] }} [opts] @returns {Promise<any|null>} */
128
+ static async findOne(filter, opts) {
129
+ return crud.findOne(this, filter, opts)
130
+ }
131
+
132
+ /** PK 단건 조회(단일 PK 필요, 없으면 model.no_primary_key). @param {any} id @param {{ select?: string[] }} [opts] @returns {Promise<any|null>} */
133
+ static async findById(id, opts) {
134
+ return crud.findById(this, id, opts)
135
+ }
136
+
137
+ /** 다건 조회(기본 limit 없음 — 명시할 때만, ADR-212 #2). @param {Record<string, any>} [filter] @param {{ select?: string[], orderBy?: string | Array<{ column: string, dir?: 'asc'|'desc' }>, limit?: number, offset?: number }} [opts] @returns {Promise<any[]>} */
138
+ static async findMany(filter, opts) {
139
+ return crud.findMany(this, filter ?? {}, opts)
140
+ }
141
+
142
+ /** 조건 매칭 행 수. @param {Record<string, any>} [filter] @returns {Promise<number>} */
143
+ static async count(filter) {
144
+ return crud.count(this, filter)
145
+ }
146
+
147
+ /** 조건 매칭 행 존재 여부. @param {Record<string, any>} filter @returns {Promise<boolean>} */
148
+ static async exists(filter) {
149
+ return crud.exists(this, filter)
150
+ }
151
+
152
+ /** 페이지 조회. total 은 `{ withTotal: true }` 일 때만 추가 count(ADR-212 #4). @param {Record<string, any>} filter @param {{ select?: string[], orderBy?: any, limit: number, offset?: number, withTotal?: boolean }} opts @returns {Promise<{ rows: any[], limit: number, offset: number, total?: number }>} */
153
+ static async paginate(filter, opts) {
154
+ return crud.paginate(this, filter ?? {}, opts)
155
+ }
156
+
157
+ /** 단건 삽입. 기본 새 id 반환, `{ returning: true }` 면 레코드(ADR-212 #1). @param {Record<string, any>} data @param {{ returning?: boolean }} [opts] @returns {Promise<any>} */
158
+ static async insertOne(data, opts) {
159
+ return crud.insertOne(this, data, opts)
160
+ }
161
+
162
+ /** 다건 삽입. 기본 `{ count }`, `{ returning: true }` 면 레코드 배열(maria 미지원→throw). @param {Record<string, any>[]} rows @param {{ returning?: boolean }} [opts] @returns {Promise<{ count: number } | any[]>} */
163
+ static async insertMany(rows, opts) {
164
+ return crud.insertMany(this, rows, opts)
165
+ }
166
+
167
+ /** 정확히 한 행 갱신(>1 매칭→롤백 후 model.multiple_matches, ADR-212 #3). 변경 행 수 반환. @param {Record<string, any>} filter @param {Record<string, any>} patch @returns {Promise<number>} */
168
+ static async updateOne(filter, patch) {
169
+ return crud.updateOne(this, filter, patch)
170
+ }
171
+
172
+ /** 다건 갱신. 빈 filter 는 차단 — 전체 갱신은 `{ all: true }`. @param {Record<string, any>} filter @param {Record<string, any>} patch @param {{ all?: boolean }} [opts] @returns {Promise<number>} */
173
+ static async updateMany(filter, patch, opts) {
174
+ return crud.updateMany(this, filter, patch, opts)
175
+ }
176
+
177
+ /** 정확히 한 행 삭제(>1 매칭→롤백 후 model.multiple_matches). 삭제 행 수 반환. @param {Record<string, any>} filter @returns {Promise<number>} */
178
+ static async deleteOne(filter) {
179
+ return crud.deleteOne(this, filter)
180
+ }
181
+
182
+ /** 다건 삭제. 빈 filter 는 차단 — 전체 삭제는 `{ all: true }`. @param {Record<string, any>} filter @param {{ all?: boolean }} [opts] @returns {Promise<number>} */
183
+ static async deleteMany(filter, opts) {
184
+ return crud.deleteMany(this, filter, opts)
185
+ }
186
+
187
+ /** upsert(INSERT … ON CONFLICT/DUPLICATE KEY UPDATE). `opts.conflict` 는 PK/unique 컬럼 필수. @param {Record<string, any>} data @param {{ conflict: string[], update?: string[], returning?: boolean }} opts @returns {Promise<any>} */
188
+ static async upsert(data, opts) {
189
+ return crud.upsert(this, data, opts)
190
+ }
191
+
112
192
  /**
113
193
  * `adapter`/`table` 정합성을 검증하고 글로벌 매니저에서 어댑터(MegaDbAdapter)를 잡아 반환한다.
114
194
  *