mega-framework 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/bin/mega-ws-hub.js +2 -2
  2. package/package.json +32 -8
  3. package/sample/crud/.env +156 -8
  4. package/sample/crud/.env.example +153 -28
  5. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  6. package/sample/crud/.mega/journal/snapshot.json +261 -0
  7. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  8. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  9. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  10. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  11. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  12. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  13. package/sample/crud/apps/main/models/note-model.js +79 -0
  14. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  15. package/sample/crud/apps/main/models/user-model.js +146 -0
  16. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  17. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  18. package/sample/crud/apps/main/routes/users.js +55 -10
  19. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  20. package/sample/crud/apps/main/services/auth-service.js +39 -24
  21. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  22. package/sample/crud/apps/main/services/note-service.js +6 -6
  23. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  24. package/sample/crud/apps/main/services/user-service.js +62 -21
  25. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  26. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  27. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  28. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  29. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  30. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  31. package/sample/crud/mega.config.js +63 -3
  32. package/sample/crud/package.json +3 -3
  33. package/sample/crud/scripts/start-ws-hub.sh +2 -2
  34. package/sample/simple/package.json +2 -2
  35. package/src/adapters/adapter-manager.js +2 -1
  36. package/src/adapters/adapter-options.js +30 -0
  37. package/src/adapters/maria-adapter.js +26 -3
  38. package/src/adapters/mega-db-adapter.js +7 -1
  39. package/src/adapters/mongo-adapter.js +19 -1
  40. package/src/adapters/postgres-adapter.js +25 -2
  41. package/src/adapters/sqlite-adapter.js +20 -1
  42. package/src/cli/commands/new.js +13 -3
  43. package/src/cli/commands/scaffold.js +137 -33
  44. package/src/cli/generators/index.js +82 -2
  45. package/src/cli/index.js +478 -104
  46. package/src/core/ajv-mapper.js +27 -2
  47. package/src/core/boot.js +485 -237
  48. package/src/core/cluster-metrics.js +13 -4
  49. package/src/core/config-validator.js +25 -0
  50. package/src/core/ctx-builder.js +6 -2
  51. package/src/core/envelope.js +112 -12
  52. package/src/core/hub-link.js +65 -4
  53. package/src/core/i18n.js +11 -1
  54. package/src/core/index.js +6 -2
  55. package/src/core/mega-app.js +223 -481
  56. package/src/core/mega-cluster.js +54 -13
  57. package/src/core/mega-server.js +40 -9
  58. package/src/core/migration/dialect-registry.js +107 -0
  59. package/src/core/migration/dialects/README.md +62 -0
  60. package/src/core/migration/dialects/maria.js +496 -0
  61. package/src/core/migration/dialects/mongo.js +824 -0
  62. package/src/core/migration/dialects/postgres.js +563 -0
  63. package/src/core/migration/dialects/sqlite.js +476 -0
  64. package/src/core/migration/differ.js +456 -0
  65. package/src/core/migration/generate.js +508 -0
  66. package/src/core/migration/journal.js +167 -0
  67. package/src/core/migration/model-scan.js +84 -0
  68. package/src/core/migration/mongo-migration-db.js +97 -0
  69. package/src/core/migration/schema-builder.js +400 -0
  70. package/src/core/migration/schema-validator.js +315 -0
  71. package/src/core/migration-lock.js +205 -0
  72. package/src/core/migration-runner.js +166 -38
  73. package/src/core/multipart.js +28 -5
  74. package/src/core/pipeline.js +129 -0
  75. package/src/core/router.js +70 -65
  76. package/src/core/scope-registry.js +0 -1
  77. package/src/core/security.js +67 -9
  78. package/src/core/workers-manager.js +12 -1
  79. package/src/core/ws-cluster.js +10 -3
  80. package/src/core/ws-message.js +48 -4
  81. package/src/core/ws-presence.js +624 -0
  82. package/src/core/ws-roster.js +4 -1
  83. package/src/core/ws-upgrade.js +118 -12
  84. package/src/index.js +1 -1
  85. package/src/lib/hub-protocol.js +29 -0
  86. package/src/lib/mega-health.js +25 -4
  87. package/src/lib/mega-job-queue.js +98 -21
  88. package/src/lib/mega-job.js +29 -0
  89. package/src/lib/mega-logger.js +1 -1
  90. package/src/lib/mega-metrics.js +3 -12
  91. package/src/lib/mega-plugin.js +34 -3
  92. package/src/lib/mega-schedule.js +40 -22
  93. package/src/lib/mega-shutdown.js +162 -49
  94. package/src/lib/mega-tracing.js +66 -19
  95. package/src/lib/mega-worker.js +5 -1
  96. package/src/lib/otel-resource.js +36 -0
  97. package/src/{cli → lib}/ws-hub.js +51 -8
  98. package/src/models/crud-sql-builder.js +133 -0
  99. package/src/models/mega-model.js +82 -2
  100. package/src/models/model-crud.js +483 -0
  101. package/src/models/mongo-crud.js +285 -0
  102. package/templates/model/code-mongo.tpl +35 -0
  103. package/templates/model/code.tpl +15 -1
  104. package/templates/model/test-mongo.tpl +38 -0
  105. package/templates/model/test.tpl +4 -0
  106. package/types/adapters/adapter-manager.d.ts +95 -0
  107. package/types/adapters/adapter-options.d.ts +91 -0
  108. package/types/adapters/file-adapter.d.ts +94 -0
  109. package/types/adapters/file-session-adapter.d.ts +101 -0
  110. package/types/adapters/index.d.ts +20 -0
  111. package/types/adapters/maria-adapter.d.ts +115 -0
  112. package/types/adapters/mega-adapter.d.ts +215 -0
  113. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  114. package/types/adapters/mega-cache-adapter.d.ts +47 -0
  115. package/types/adapters/mega-db-adapter.d.ts +47 -0
  116. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  117. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  118. package/types/adapters/mega-session-adapter.d.ts +32 -0
  119. package/types/adapters/mongo-adapter.d.ts +139 -0
  120. package/types/adapters/nats-adapter.d.ts +108 -0
  121. package/types/adapters/postgres-adapter.d.ts +139 -0
  122. package/types/adapters/redis-adapter.d.ts +70 -0
  123. package/types/adapters/redis-session-adapter.d.ts +82 -0
  124. package/types/adapters/redlock-adapter.d.ts +149 -0
  125. package/types/adapters/registry.d.ts +46 -0
  126. package/types/adapters/sqlite-adapter.d.ts +106 -0
  127. package/types/auth/index.d.ts +24 -0
  128. package/types/cli/commands/console-cmd.d.ts +37 -0
  129. package/types/cli/commands/new.d.ts +16 -0
  130. package/types/cli/commands/routes.d.ts +36 -0
  131. package/types/cli/commands/scaffold.d.ts +78 -0
  132. package/types/cli/commands/test-cmd.d.ts +14 -0
  133. package/types/cli/generators/index.d.ts +112 -0
  134. package/types/cli/index.d.ts +249 -0
  135. package/types/cli/template-engine.d.ts +40 -0
  136. package/types/core/ajv-mapper.d.ts +27 -0
  137. package/types/core/boot.d.ts +233 -0
  138. package/types/core/cluster-metrics.d.ts +52 -0
  139. package/types/core/config-loader.d.ts +13 -0
  140. package/types/core/config-validator.d.ts +30 -0
  141. package/types/core/ctx-builder.d.ts +80 -0
  142. package/types/core/envelope.d.ts +79 -0
  143. package/types/core/error-mapper.d.ts +17 -0
  144. package/types/core/formbody.d.ts +41 -0
  145. package/types/core/hub-link.d.ts +264 -0
  146. package/types/core/i18n.d.ts +178 -0
  147. package/types/core/index.d.ts +28 -0
  148. package/types/core/mega-app.d.ts +529 -0
  149. package/types/core/mega-cluster.d.ts +104 -0
  150. package/types/core/mega-server.d.ts +91 -0
  151. package/types/core/mega-service.d.ts +31 -0
  152. package/types/core/migration/dialect-registry.d.ts +22 -0
  153. package/types/core/migration/dialects/maria.d.ts +99 -0
  154. package/types/core/migration/dialects/mongo.d.ts +89 -0
  155. package/types/core/migration/dialects/postgres.d.ts +117 -0
  156. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  157. package/types/core/migration/differ.d.ts +47 -0
  158. package/types/core/migration/generate.d.ts +56 -0
  159. package/types/core/migration/journal.d.ts +52 -0
  160. package/types/core/migration/model-scan.d.ts +19 -0
  161. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  162. package/types/core/migration/schema-builder.d.ts +197 -0
  163. package/types/core/migration/schema-validator.d.ts +20 -0
  164. package/types/core/migration-lock.d.ts +33 -0
  165. package/types/core/migration-runner.d.ts +101 -0
  166. package/types/core/multipart.d.ts +86 -0
  167. package/types/core/openapi.d.ts +62 -0
  168. package/types/core/pipeline.d.ts +92 -0
  169. package/types/core/router.d.ts +159 -0
  170. package/types/core/routes-loader.d.ts +21 -0
  171. package/types/core/scope-registry.d.ts +14 -0
  172. package/types/core/security.d.ts +77 -0
  173. package/types/core/services-loader.d.ts +27 -0
  174. package/types/core/session-cleanup-schedule.d.ts +19 -0
  175. package/types/core/session-store.d.ts +18 -0
  176. package/types/core/session.d.ts +77 -0
  177. package/types/core/static-assets.d.ts +73 -0
  178. package/types/core/template.d.ts +106 -0
  179. package/types/core/workers-manager.d.ts +79 -0
  180. package/types/core/ws-cluster.d.ts +208 -0
  181. package/types/core/ws-compression.d.ts +112 -0
  182. package/types/core/ws-controller.d.ts +65 -0
  183. package/types/core/ws-message.d.ts +106 -0
  184. package/types/core/ws-presence.d.ts +273 -0
  185. package/types/core/ws-roster.d.ts +96 -0
  186. package/types/core/ws-upgrade.d.ts +231 -0
  187. package/types/errors/config-error.d.ts +10 -0
  188. package/types/errors/http-errors.d.ts +120 -0
  189. package/types/errors/index.d.ts +3 -0
  190. package/types/errors/mega-error.d.ts +32 -0
  191. package/types/index.d.ts +39 -0
  192. package/types/lib/asp/config.d.ts +49 -0
  193. package/types/lib/asp/crypto.d.ts +43 -0
  194. package/types/lib/asp/errors.d.ts +30 -0
  195. package/types/lib/asp/nonce-cache.d.ts +52 -0
  196. package/types/lib/asp/plugin.d.ts +30 -0
  197. package/types/lib/asp/ws-terminator.d.ts +45 -0
  198. package/types/lib/env-mapper.d.ts +14 -0
  199. package/types/lib/hub-protocol.d.ts +106 -0
  200. package/types/lib/index.d.ts +22 -0
  201. package/types/lib/logger/telegram-core.d.ts +104 -0
  202. package/types/lib/logger/telegram-transport.d.ts +45 -0
  203. package/types/lib/mega-brute-force.d.ts +66 -0
  204. package/types/lib/mega-circuit-breaker.d.ts +241 -0
  205. package/types/lib/mega-cron.d.ts +66 -0
  206. package/types/lib/mega-hash.d.ts +32 -0
  207. package/types/lib/mega-health.d.ts +41 -0
  208. package/types/lib/mega-job-queue.d.ts +176 -0
  209. package/types/lib/mega-job-worker.d.ts +130 -0
  210. package/types/lib/mega-job.d.ts +138 -0
  211. package/types/lib/mega-logger.d.ts +45 -0
  212. package/types/lib/mega-metrics.d.ts +285 -0
  213. package/types/lib/mega-plugin.d.ts +245 -0
  214. package/types/lib/mega-retry.d.ts +85 -0
  215. package/types/lib/mega-schedule.d.ts +260 -0
  216. package/types/lib/mega-shutdown.d.ts +135 -0
  217. package/types/lib/mega-tracing.d.ts +224 -0
  218. package/types/lib/mega-worker.d.ts +127 -0
  219. package/types/lib/otel-resource.d.ts +16 -0
  220. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  221. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  222. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  223. package/types/lib/ws-hub.d.ts +234 -0
  224. package/types/models/crud-sql-builder.d.ts +48 -0
  225. package/types/models/index.d.ts +1 -0
  226. package/types/models/mega-model.d.ts +138 -0
  227. package/types/models/model-crud.d.ts +82 -0
  228. package/types/models/mongo-crud.d.ts +59 -0
  229. package/types/test/index.d.ts +84 -0
  230. package/.env +0 -127
  231. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  232. package/sample/crud/apps/main/models/note.js +0 -71
  233. package/sample/crud/apps/main/models/user.js +0 -86
  234. package/sample/crud/package-lock.json +0 -5665
  235. package/sample/crud/yarn.lock +0 -2142
  236. package/sample/simple/package-lock.json +0 -1851
@@ -0,0 +1,36 @@
1
+ // @ts-check
2
+ /**
3
+ * OTel Resource 공유 빌더 — 트레이싱·메트릭 SDK 의 단일 출처 (ADR-196, F5 audit O-SDK 단일화).
4
+ *
5
+ * `MegaTracing.init`/`MegaMetrics.init` 이 각자 동일한 resource 조립 블록을 들고 있어 service.name 류
6
+ * 시맨틱 키 매핑이 두 곳에서 드리프트할 수 있었다. 본 모듈이 그 조립을 한 곳으로 모은다 — 두 SDK 가
7
+ * 같은 입력(`serviceName`/`version`/`environment`/`attributes`)에 항상 같은 resource 속성을 낸다.
8
+ * (전면 NodeSDK 채택은 자동 instrumentation 의존이 끌려와 zero-dep 방침과 충돌 — 자체 일원화로 결정.)
9
+ *
10
+ * @module lib/otel-resource
11
+ */
12
+ import { resourceFromAttributes } from '@opentelemetry/resources'
13
+ import {
14
+ ATTR_SERVICE_NAME,
15
+ ATTR_SERVICE_VERSION,
16
+ ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
17
+ } from '@opentelemetry/semantic-conventions'
18
+
19
+ /**
20
+ * 공통 입력 → OTel Resource. 시크릿은 attributes 에 싣지 말 것(exporter 로 평문 전송됨).
21
+ *
22
+ * @param {object} opts
23
+ * @param {string} opts.serviceName - **필수**(호출부가 선검증). `service.name` 속성.
24
+ * @param {string} [opts.version] - `service.version`.
25
+ * @param {string} [opts.environment] - `deployment.environment.name`.
26
+ * @param {Record<string, any>} [opts.attributes] - 추가 resource 속성(머지).
27
+ * @returns {import('@opentelemetry/resources').Resource}
28
+ */
29
+ export function buildOtelResource({ serviceName, version, environment, attributes }) {
30
+ return resourceFromAttributes({
31
+ [ATTR_SERVICE_NAME]: serviceName,
32
+ ...(typeof version === 'string' ? { [ATTR_SERVICE_VERSION]: version } : {}),
33
+ ...(typeof environment === 'string' ? { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: environment } : {}),
34
+ ...(attributes && typeof attributes === 'object' ? attributes : {}),
35
+ })
36
+ }
@@ -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 }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number }>} */
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()
@@ -209,16 +221,30 @@ export class MegaWsHub {
209
221
  * @private
210
222
  */
211
223
  _handleRegister(connId, socket, msg) {
212
- const payload = /** @type {{ instanceId: string, token: string, capabilities: string[] }} */ (/** @type {any} */ (msg).payload)
224
+ const payload = /** @type {{ instanceId: string, token: string, capabilities: string[], protocolVersion?: number }} */ (/** @type {any} */ (msg).payload)
213
225
  if (!isTokenAccepted(payload.token, this._acceptedTokens)) {
214
226
  this._log?.warn?.({ connId, instanceId: payload.instanceId }, 'ws-hub register denied — bad token (ADR-059)')
215
227
  this._safeSend(socket, createHubMessage({ type: T.ERROR, error: { code: 'hub.unauthorized', message: 'invalid bridge token' }, ref: /** @type {any} */ (msg).id }))
216
228
  socket.close(1008, 'unauthorized') // RFC 6455 1008 = policy violation
217
229
  return false
218
230
  }
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 } }))
231
+ // 프로토콜 버전 협상 — bridge 가 최대 지원 버전을 실으면 상호 최고 버전(min) 채택해 회신한다.
232
+ // 부재 = 레거시 bridge v1 고정 + register_ok 에 필드 미포함(strict 스키마 bridge 가 안 깨지게).
233
+ const hasVersionRequest = payload.protocolVersion !== undefined
234
+ const protocolVersion = hasVersionRequest
235
+ ? negotiateHubProtocolVersion(payload.protocolVersion)
236
+ : HUB_PROTOCOL_VERSION
237
+ this._bridges.set(connId, { socket, lastSeen: Date.now(), protocolVersion })
238
+ this._log?.info?.({ connId, instanceId: payload.instanceId, hubId: this._hubId, protocolVersion }, 'ws-hub bridge registered')
239
+ this._safeSend(socket, createHubMessage({
240
+ type: T.REGISTER_OK,
241
+ payload: {
242
+ hubId: this._hubId,
243
+ acceptedAt: Date.now(),
244
+ heartbeatMs: this._heartbeatMs,
245
+ ...(hasVersionRequest ? { protocolVersion } : {}),
246
+ },
247
+ }))
222
248
  return true
223
249
  }
224
250
 
@@ -478,6 +504,22 @@ export class MegaWsHub {
478
504
  * @private
479
505
  */
480
506
  _sendSerialized(socket, serialized) {
507
+ // 느린 bridge 백프레셔 가드: 송신 버퍼가 상한을 넘으면 더 쌓지 않고 연결을 끊는다 — fan-out 허브는
508
+ // bridge 1개만 느려도 모든 채널 메시지가 그 소켓 버퍼(힙)에 적재돼 OOM 으로 가는 최악 지점이다.
509
+ // terminate 후 'close' 이벤트가 `_handleBridgeGone` 을 불러 presence 정리 + BULK_LEAVE fan-out 이
510
+ // 따라온다(재연결은 bridge 의 retry 책임, ADR-098).
511
+ if (socket.bufferedAmount > this._maxBufferedBytes) {
512
+ this._log?.warn?.(
513
+ { bufferedAmount: socket.bufferedAmount, max: this._maxBufferedBytes },
514
+ 'ws-hub slow bridge — terminating (backpressure)',
515
+ )
516
+ try {
517
+ socket.terminate()
518
+ } catch (err) {
519
+ this._log?.debug?.({ err }, 'ws-hub backpressure terminate failed (already closing)')
520
+ }
521
+ return
522
+ }
481
523
  try {
482
524
  socket.send(serialized)
483
525
  } catch (err) {
@@ -575,7 +617,8 @@ export async function runWsHubCli() {
575
617
  const hub = new MegaWsHub({ acceptedTokens, heartbeatMs, maxPayloadBytes, compression, logger: console })
576
618
  const addr = await hub.start({ port, host })
577
619
  // 독립 hub 프로세스 graceful shutdown(L2 + drain) — SIGTERM/SIGINT → hub.stop({ drain: true }).
578
- MegaShutdown.register('mega-ws-hub', async () => hub.stop({ drain: true }))
620
+ // 'server' stage hub 도 수용 종단이라 가장 먼저 drain 종료한다.
621
+ MegaShutdown.register('mega-ws-hub', async () => hub.stop({ drain: true }), { stage: 'server' })
579
622
  MegaShutdown.setupSignals()
580
623
  console.log(`[mega:ws-hub] listening on ${addr.host}:${addr.port} (hubId=${hub.hubId})`)
581
624
  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
  *