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.
- package/README.md +9 -0
- package/bin/mega-ws-hub.js +2 -2
- package/package.json +33 -9
- package/sample/crud/.env +10 -1
- package/sample/crud/.env.example +10 -1
- package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
- package/sample/crud/.mega/journal/snapshot.json +261 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
- package/sample/crud/apps/main/controllers/web-controller.js +7 -5
- package/sample/crud/apps/main/locales/server/en.json +12 -1
- package/sample/crud/apps/main/locales/server/ko.json +12 -1
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
- package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
- package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
- package/sample/crud/apps/main/models/log-partition-model.js +105 -0
- package/sample/crud/apps/main/models/note-model.js +79 -0
- package/sample/crud/apps/main/models/user-level-model.js +24 -0
- package/sample/crud/apps/main/models/user-model.js +146 -0
- package/sample/crud/apps/main/models/user-type-model.js +21 -0
- package/sample/crud/apps/main/models/wallet-model.js +24 -0
- package/sample/crud/apps/main/routes/users.js +55 -10
- package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
- package/sample/crud/apps/main/services/auth-service.js +39 -24
- package/sample/crud/apps/main/services/log-partition-service.js +101 -0
- package/sample/crud/apps/main/services/note-service.js +6 -6
- package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
- package/sample/crud/apps/main/services/user-service.js +62 -21
- package/sample/crud/apps/main/views/auth/login.ejs +6 -6
- package/sample/crud/apps/main/views/auth/register.ejs +46 -5
- package/sample/crud/apps/main/views/users/edit.ejs +42 -5
- package/sample/crud/apps/main/views/users/list.ejs +6 -2
- package/sample/crud/apps/main/views/users/new.ejs +56 -4
- package/sample/crud/docs/log_partition_design.mm.md +23 -0
- package/sample/crud/mega.config.js +10 -2
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +20 -6
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +44 -3
- package/src/adapters/file-adapter.js +9 -5
- package/src/adapters/file-session-adapter.js +4 -3
- package/src/adapters/maria-adapter.js +33 -7
- package/src/adapters/mega-cache-adapter.js +83 -6
- package/src/adapters/mega-db-adapter.js +10 -1
- package/src/adapters/mongo-adapter.js +40 -8
- package/src/adapters/postgres-adapter.js +33 -6
- package/src/adapters/redis-adapter.js +7 -3
- package/src/adapters/sqlite-adapter.js +26 -3
- package/src/cli/commands/console-cmd.js +3 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +173 -33
- package/src/cli/generators/index.js +140 -3
- package/src/cli/index.js +437 -155
- package/src/cli/watch.js +188 -0
- package/src/core/ajv-mapper.js +30 -3
- package/src/core/boot.js +464 -245
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/ctx-builder.js +65 -3
- package/src/core/envelope.js +119 -12
- package/src/core/hub-link.js +89 -18
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +7 -3
- package/src/core/mega-app.js +253 -505
- package/src/core/mega-cluster.js +4 -1
- package/src/core/mega-server.js +40 -9
- package/src/core/migration/dialect-registry.js +107 -0
- package/src/core/migration/dialects/README.md +62 -0
- package/src/core/migration/dialects/maria.js +496 -0
- package/src/core/migration/dialects/mongo.js +824 -0
- package/src/core/migration/dialects/postgres.js +563 -0
- package/src/core/migration/dialects/sqlite.js +476 -0
- package/src/core/migration/differ.js +456 -0
- package/src/core/migration/generate.js +508 -0
- package/src/core/migration/journal.js +167 -0
- package/src/core/migration/model-scan.js +84 -0
- package/src/core/migration/mongo-migration-db.js +97 -0
- package/src/core/migration/schema-builder.js +400 -0
- package/src/core/migration/schema-validator.js +315 -0
- package/src/core/migration-lock.js +205 -0
- package/src/core/migration-runner.js +166 -38
- package/src/core/multipart.js +28 -5
- package/src/core/pipeline.js +131 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +1 -0
- package/src/core/security.js +70 -12
- package/src/core/session-store.js +14 -1
- package/src/core/workers-manager.js +12 -1
- package/src/core/ws-cluster.js +10 -3
- package/src/core/ws-message.js +48 -4
- package/src/core/ws-presence.js +636 -0
- package/src/core/ws-roster.js +50 -8
- package/src/core/ws-upgrade.js +223 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-circuit-breaker.js +5 -3
- package/src/lib/mega-health.js +35 -4
- package/src/lib/mega-job-queue.js +151 -34
- package/src/lib/mega-job.js +37 -1
- package/src/lib/mega-metrics.js +31 -13
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +114 -39
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +33 -6
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +139 -15
- package/src/models/crud-sql-builder.js +133 -0
- package/src/models/mega-model.js +82 -2
- package/src/models/model-crud.js +483 -0
- package/src/models/mongo-crud.js +285 -0
- package/templates/adr/code.tpl +23 -0
- package/templates/model/code-mongo.tpl +35 -0
- package/templates/model/code.tpl +15 -1
- package/templates/model/test-mongo.tpl +38 -0
- package/templates/model/test.tpl +4 -0
- package/types/adapters/adapter-manager.d.ts +95 -0
- package/types/adapters/adapter-options.d.ts +93 -0
- package/types/adapters/file-adapter.d.ts +105 -0
- package/types/adapters/file-session-adapter.d.ts +103 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +117 -0
- package/types/adapters/mega-adapter.d.ts +215 -0
- package/types/adapters/mega-bus-adapter.d.ts +45 -0
- package/types/adapters/mega-cache-adapter.d.ts +73 -0
- package/types/adapters/mega-db-adapter.d.ts +50 -0
- package/types/adapters/mega-lock-adapter.d.ts +62 -0
- package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
- package/types/adapters/mega-session-adapter.d.ts +32 -0
- package/types/adapters/mongo-adapter.d.ts +150 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +141 -0
- package/types/adapters/redis-adapter.d.ts +78 -0
- package/types/adapters/redis-session-adapter.d.ts +82 -0
- package/types/adapters/redlock-adapter.d.ts +149 -0
- package/types/adapters/registry.d.ts +46 -0
- package/types/adapters/sqlite-adapter.d.ts +112 -0
- package/types/auth/index.d.ts +24 -0
- package/types/cli/commands/console-cmd.d.ts +37 -0
- package/types/cli/commands/new.d.ts +16 -0
- package/types/cli/commands/routes.d.ts +36 -0
- package/types/cli/commands/scaffold.d.ts +78 -0
- package/types/cli/commands/test-cmd.d.ts +14 -0
- package/types/cli/generators/index.d.ts +122 -0
- package/types/cli/index.d.ts +234 -0
- package/types/cli/template-engine.d.ts +40 -0
- package/types/cli/watch.d.ts +59 -0
- package/types/core/ajv-mapper.d.ts +27 -0
- package/types/core/boot.d.ts +233 -0
- package/types/core/cluster-metrics.d.ts +52 -0
- package/types/core/config-loader.d.ts +13 -0
- package/types/core/config-validator.d.ts +30 -0
- package/types/core/ctx-builder.d.ts +103 -0
- package/types/core/envelope.d.ts +79 -0
- package/types/core/error-mapper.d.ts +17 -0
- package/types/core/formbody.d.ts +41 -0
- package/types/core/hub-link.d.ts +266 -0
- package/types/core/i18n.d.ts +178 -0
- package/types/core/index.d.ts +28 -0
- package/types/core/mega-app.d.ts +529 -0
- package/types/core/mega-cluster.d.ts +104 -0
- package/types/core/mega-server.d.ts +91 -0
- package/types/core/mega-service.d.ts +31 -0
- package/types/core/migration/dialect-registry.d.ts +22 -0
- package/types/core/migration/dialects/maria.d.ts +99 -0
- package/types/core/migration/dialects/mongo.d.ts +89 -0
- package/types/core/migration/dialects/postgres.d.ts +117 -0
- package/types/core/migration/dialects/sqlite.d.ts +111 -0
- package/types/core/migration/differ.d.ts +47 -0
- package/types/core/migration/generate.d.ts +56 -0
- package/types/core/migration/journal.d.ts +52 -0
- package/types/core/migration/model-scan.d.ts +19 -0
- package/types/core/migration/mongo-migration-db.d.ts +7 -0
- package/types/core/migration/schema-builder.d.ts +197 -0
- package/types/core/migration/schema-validator.d.ts +20 -0
- package/types/core/migration-lock.d.ts +33 -0
- package/types/core/migration-runner.d.ts +101 -0
- package/types/core/multipart.d.ts +86 -0
- package/types/core/openapi.d.ts +62 -0
- package/types/core/pipeline.d.ts +93 -0
- package/types/core/router.d.ts +159 -0
- package/types/core/routes-loader.d.ts +21 -0
- package/types/core/scope-registry.d.ts +14 -0
- package/types/core/security.d.ts +77 -0
- package/types/core/services-loader.d.ts +27 -0
- package/types/core/session-cleanup-schedule.d.ts +19 -0
- package/types/core/session-store.d.ts +25 -0
- package/types/core/session.d.ts +77 -0
- package/types/core/static-assets.d.ts +73 -0
- package/types/core/template.d.ts +106 -0
- package/types/core/workers-manager.d.ts +79 -0
- package/types/core/ws-cluster.d.ts +208 -0
- package/types/core/ws-compression.d.ts +112 -0
- package/types/core/ws-controller.d.ts +65 -0
- package/types/core/ws-message.d.ts +106 -0
- package/types/core/ws-presence.d.ts +273 -0
- package/types/core/ws-roster.d.ts +108 -0
- package/types/core/ws-upgrade.d.ts +260 -0
- package/types/errors/config-error.d.ts +10 -0
- package/types/errors/http-errors.d.ts +120 -0
- package/types/errors/index.d.ts +3 -0
- package/types/errors/mega-error.d.ts +32 -0
- package/types/index.d.ts +39 -0
- package/types/lib/asp/config.d.ts +49 -0
- package/types/lib/asp/crypto.d.ts +43 -0
- package/types/lib/asp/errors.d.ts +30 -0
- package/types/lib/asp/nonce-cache.d.ts +52 -0
- package/types/lib/asp/plugin.d.ts +30 -0
- package/types/lib/asp/ws-terminator.d.ts +45 -0
- package/types/lib/env-mapper.d.ts +14 -0
- package/types/lib/hub-protocol.d.ts +106 -0
- package/types/lib/index.d.ts +22 -0
- package/types/lib/logger/telegram-core.d.ts +104 -0
- package/types/lib/logger/telegram-transport.d.ts +45 -0
- package/types/lib/mega-brute-force.d.ts +66 -0
- package/types/lib/mega-circuit-breaker.d.ts +243 -0
- package/types/lib/mega-cron.d.ts +66 -0
- package/types/lib/mega-hash.d.ts +32 -0
- package/types/lib/mega-health.d.ts +48 -0
- package/types/lib/mega-job-queue.d.ts +188 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +145 -0
- package/types/lib/mega-logger.d.ts +45 -0
- package/types/lib/mega-metrics.d.ts +285 -0
- package/types/lib/mega-plugin.d.ts +245 -0
- package/types/lib/mega-retry.d.ts +85 -0
- package/types/lib/mega-schedule.d.ts +260 -0
- package/types/lib/mega-shutdown.d.ts +135 -0
- package/types/lib/mega-tracing.d.ts +224 -0
- package/types/lib/mega-worker.d.ts +129 -0
- package/types/lib/otel-resource.d.ts +16 -0
- package/types/lib/worker-runner/process-entry.d.ts +1 -0
- package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
- package/types/lib/worker-runner/thread-entry.d.ts +1 -0
- package/types/lib/ws-hub.d.ts +259 -0
- package/types/models/crud-sql-builder.d.ts +48 -0
- package/types/models/index.d.ts +1 -0
- package/types/models/mega-model.d.ts +138 -0
- package/types/models/model-crud.d.ts +82 -0
- package/types/models/mongo-crud.d.ts +59 -0
- package/types/test/index.d.ts +84 -0
- package/.env +0 -127
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
- package/sample/crud/apps/main/models/note.js +0 -71
- package/sample/crud/apps/main/models/user.js +0 -86
- package/sample/crud/package-lock.json +0 -5665
- package/sample/crud/yarn.lock +0 -2142
- 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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 공유 —
|
|
240
|
-
this.
|
|
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.
|
|
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.
|
|
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 로
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/models/mega-model.js
CHANGED
|
@@ -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
|
*
|