mega-framework 0.1.6 → 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.
- package/bin/mega-ws-hub.js +2 -2
- package/package.json +32 -8
- package/sample/crud/.env +1 -1
- package/sample/crud/.env.example +1 -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/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 +3 -2
- package/sample/crud/package.json +1 -1
- package/sample/crud/scripts/start-ws-hub.sh +2 -2
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +30 -0
- package/src/adapters/maria-adapter.js +26 -3
- package/src/adapters/mega-db-adapter.js +7 -1
- package/src/adapters/mongo-adapter.js +19 -1
- package/src/adapters/postgres-adapter.js +25 -2
- package/src/adapters/sqlite-adapter.js +20 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +137 -33
- package/src/cli/generators/index.js +82 -2
- package/src/cli/index.js +353 -100
- package/src/core/ajv-mapper.js +27 -2
- package/src/core/boot.js +464 -245
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/ctx-builder.js +6 -2
- package/src/core/envelope.js +112 -12
- package/src/core/hub-link.js +65 -4
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +6 -2
- package/src/core/mega-app.js +201 -463
- 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 +129 -0
- package/src/core/router.js +70 -65
- package/src/core/security.js +67 -9
- 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 +624 -0
- package/src/core/ws-roster.js +4 -1
- package/src/core/ws-upgrade.js +118 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-health.js +25 -4
- package/src/lib/mega-job-queue.js +98 -21
- package/src/lib/mega-job.js +29 -0
- package/src/lib/mega-metrics.js +3 -12
- 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 +5 -1
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +51 -8
- 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/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 +91 -0
- package/types/adapters/file-adapter.d.ts +94 -0
- package/types/adapters/file-session-adapter.d.ts +101 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +115 -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 +47 -0
- package/types/adapters/mega-db-adapter.d.ts +47 -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 +139 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +139 -0
- package/types/adapters/redis-adapter.d.ts +70 -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 +106 -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 +112 -0
- package/types/cli/index.d.ts +249 -0
- package/types/cli/template-engine.d.ts +40 -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 +80 -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 +264 -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 +92 -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 +18 -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 +96 -0
- package/types/core/ws-upgrade.d.ts +231 -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 +241 -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 +41 -0
- package/types/lib/mega-job-queue.d.ts +176 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +138 -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 +127 -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 +234 -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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 스키마 record 검증기 (ADR-204) — 모델 스캔·빌더 실행 결과(record 집합)를 SQL 생성 전에 일괄
|
|
4
|
+
* 검증한다. 실패는 전부 `migration.schema_invalid` 로 fail-fast(P7 — 잘못된 스키마로 SQL 을 만들어
|
|
5
|
+
* 적용 단계에서 cryptic 하게 죽는 것을 차단).
|
|
6
|
+
*
|
|
7
|
+
* 검증과 동시에 FK `references` 의 대상 모델명을 실제 테이블명으로 해석해 record 에
|
|
8
|
+
* `references.table` 을 채운다(스냅샷 자기완결 — 과거 스냅샷만으로 diff 가능).
|
|
9
|
+
*
|
|
10
|
+
* @module core/migration/schema-validator
|
|
11
|
+
*/
|
|
12
|
+
import { MegaConfigError } from '../../errors/config-error.js'
|
|
13
|
+
import { FK_ACTIONS } from './schema-builder.js'
|
|
14
|
+
|
|
15
|
+
/** SQL 식별자(컬럼·테이블 이름) 형식 — 소문자 시작 snake/camel 허용, 공백·따옴표 불가. */
|
|
16
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
17
|
+
|
|
18
|
+
/** 빌더가 만들 수 있는 타입 집합 (postgres PoC 기준 — dialect 가 별도 매핑). */
|
|
19
|
+
const KNOWN_TYPES = new Set([
|
|
20
|
+
'serial', 'bigSerial', 'integer', 'bigInteger', 'smallInteger',
|
|
21
|
+
'real', 'doublePrecision', 'decimal',
|
|
22
|
+
'varchar', 'text', 'char',
|
|
23
|
+
'boolean', 'timestamp', 'timestamptz', 'date', 'time',
|
|
24
|
+
'uuid', 'json', 'jsonb', 'enum', 'bytea',
|
|
25
|
+
// mongo 전용(ADR-209) — SQL dialect 는 렌더 시점(typeSql)에 명시 거부한다.
|
|
26
|
+
'objectId', 'object', 'array',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
/** 중첩 def(object shape / array items)에서 허용하지 않는 최상위 전용 제약. */
|
|
30
|
+
const NESTED_FORBIDDEN = ['primary', 'unique', 'references', 'check', 'default']
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 중첩 def 재귀 검증 — 타입·길이·enum 규칙은 최상위와 동일, 최상위 전용 제약은 거부.
|
|
34
|
+
* @param {string} model @param {string} path @param {Record<string, any>} def
|
|
35
|
+
*/
|
|
36
|
+
function validateNestedDef(model, path, def) {
|
|
37
|
+
const where = { model, column: path }
|
|
38
|
+
if (!KNOWN_TYPES.has(def.type)) {
|
|
39
|
+
throw invalid(`model '${model}.${path}': 알 수 없는 타입 '${def.type}'.`, where)
|
|
40
|
+
}
|
|
41
|
+
for (const key of NESTED_FORBIDDEN) {
|
|
42
|
+
if (def[key] !== undefined) {
|
|
43
|
+
throw invalid(
|
|
44
|
+
`model '${model}.${path}': 중첩 필드에는 .${key}() 를 쓸 수 없습니다 — 중첩 단계의 제약은 타입·길이·enum·notNull 만 지원됩니다(ADR-209).`,
|
|
45
|
+
{ ...where, constraint: key },
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if ((def.type === 'varchar' || def.type === 'char') && (!Number.isInteger(def.length) || def.length <= 0)) {
|
|
50
|
+
throw invalid(`model '${model}.${path}': ${def.type} 길이는 양의 정수여야 합니다.`, where)
|
|
51
|
+
}
|
|
52
|
+
if (def.type === 'enum' && (!Array.isArray(def.values) || def.values.length === 0)) {
|
|
53
|
+
throw invalid(`model '${model}.${path}': enum values 는 비어 있지 않은 문자열 배열이어야 합니다.`, where)
|
|
54
|
+
}
|
|
55
|
+
validateNestedStructure(model, path, def)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* object shape / array items 의 재귀 진입점 (최상위·중첩 공용).
|
|
60
|
+
* @param {string} model @param {string} path @param {Record<string, any>} def
|
|
61
|
+
*/
|
|
62
|
+
function validateNestedStructure(model, path, def) {
|
|
63
|
+
if (def.type === 'object' && def.shape !== undefined) {
|
|
64
|
+
for (const [key, sub] of Object.entries(def.shape)) {
|
|
65
|
+
if (!IDENT_RE.test(key)) {
|
|
66
|
+
throw invalid(`model '${model}.${path}': 중첩 필드명 '${key}' 이 유효한 식별자가 아닙니다.`, { model, column: `${path}.${key}` })
|
|
67
|
+
}
|
|
68
|
+
validateNestedDef(model, `${path}.${key}`, sub)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (def.type === 'array' && def.items !== undefined) {
|
|
72
|
+
validateNestedDef(model, `${path}[]`, def.items)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} message @param {Record<string, unknown>} details
|
|
78
|
+
* @returns {MegaConfigError}
|
|
79
|
+
*/
|
|
80
|
+
function invalid(message, details) {
|
|
81
|
+
return new MegaConfigError('migration.schema_invalid', message, { details })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* record 의 t.object shape 들이 제공하는 중첩 dot-path 전부 수집(에러 안내용).
|
|
86
|
+
* @param {Record<string, any>} columns @param {string} [prefix]
|
|
87
|
+
* @returns {string[]}
|
|
88
|
+
*/
|
|
89
|
+
function collectNestedPaths(columns, prefix = '') {
|
|
90
|
+
/** @type {string[]} */
|
|
91
|
+
const out = []
|
|
92
|
+
for (const [colName, def] of Object.entries(columns)) {
|
|
93
|
+
if (def.type === 'object' && def.shape !== undefined) {
|
|
94
|
+
for (const [key, sub] of Object.entries(def.shape)) {
|
|
95
|
+
const path = `${prefix}${colName}.${key}`
|
|
96
|
+
out.push(path)
|
|
97
|
+
if (sub.type === 'object' && sub.shape !== undefined) {
|
|
98
|
+
out.push(...collectNestedPaths({ [key]: sub }, `${prefix}${colName}.`).map((nestedPath) => nestedPath))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 인덱스 필드 존재 검증 — 평면 필드는 columns 직접, dot-path(중첩 필드 인덱스 — mongo 핵심 패턴,
|
|
108
|
+
* ADR-210 M-5)는 `t.object` shape 를 따라 해석한다. 실패 메시지는 사용 가능 path 를 동반한다.
|
|
109
|
+
* @param {string} model @param {Record<string, any>} record @param {string} field
|
|
110
|
+
* @param {Record<string, unknown>} where
|
|
111
|
+
*/
|
|
112
|
+
function assertIndexFieldExists(model, record, field, where) {
|
|
113
|
+
if (!field.includes('.')) {
|
|
114
|
+
if (record.columns[field] === undefined) {
|
|
115
|
+
throw invalid(`model '${model}': 인덱스 컬럼 '${field}' 가 schema 에 없습니다.`, { ...where, column: field })
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const segments = field.split('.')
|
|
120
|
+
/** @type {any} */
|
|
121
|
+
let def = record.columns[segments[0]]
|
|
122
|
+
let ok = def !== undefined
|
|
123
|
+
for (let i = 1; ok && i < segments.length; i++) {
|
|
124
|
+
if (def.type !== 'object' || def.shape === undefined || def.shape[segments[i]] === undefined) {
|
|
125
|
+
ok = false
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
def = def.shape[segments[i]]
|
|
129
|
+
}
|
|
130
|
+
if (!ok) {
|
|
131
|
+
const available = collectNestedPaths(record.columns)
|
|
132
|
+
throw invalid(
|
|
133
|
+
`model '${model}': 중첩 필드 '${field}' 가 t.object shape 에 선언되어 있지 않습니다 ` +
|
|
134
|
+
`(사용 가능 path: ${available.length > 0 ? available.join(', ') : '(선언된 중첩 path 없음)'}).`,
|
|
135
|
+
{ ...where, column: field, available },
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 스캔된 모델 record 집합을 검증하고 FK 대상 테이블을 해석(in-place)한다.
|
|
142
|
+
*
|
|
143
|
+
* @param {Array<{ name: string, table: string, adapter: string, record: any, file?: string }>} models - 스캔 결과
|
|
144
|
+
* (name = 모델 이름, file = 출처 파일 경로 — 충돌 메시지에 동반, record =
|
|
145
|
+
* {@link module:core/migration/schema-builder.buildModelRecord} 산출).
|
|
146
|
+
* @param {{ identifierMaxBytesOf?: (adapter: string) => number }} [opts] - adapter 별 식별자 byte 한도
|
|
147
|
+
* 공급자(dialect.identifierMaxBytes — 합성 이름은 dialect 의 quoteIdent 깔때기가 추가 검증).
|
|
148
|
+
* @returns {void}
|
|
149
|
+
* @throws {MegaConfigError} `migration.schema_invalid` | `migration.identifier_too_long`
|
|
150
|
+
*/
|
|
151
|
+
export function validateModels(models, { identifierMaxBytesOf = () => Infinity } = {}) {
|
|
152
|
+
/** @type {Map<string, { table: string, record: any, file?: string }>} 모델명 → 대상 (FK 해석용). */
|
|
153
|
+
const byName = new Map()
|
|
154
|
+
for (const m of models) {
|
|
155
|
+
const dup = byName.get(m.name)
|
|
156
|
+
if (dup !== undefined) {
|
|
157
|
+
throw invalid(
|
|
158
|
+
`모델 이름 '${m.name}' 이 중복됩니다 — file-scan 범위 안에서 모델 이름은 유일해야 합니다(FK 대상 식별자). ` +
|
|
159
|
+
`파일: ${dup.file ?? '(unknown)'} ↔ ${m.file ?? '(unknown)'}`,
|
|
160
|
+
{ model: m.name, files: [dup.file ?? null, m.file ?? null] },
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
byName.set(m.name, { table: m.table, record: m.record, file: m.file })
|
|
164
|
+
}
|
|
165
|
+
/** @type {Map<string, { model: string, file?: string }>} `${adapter}:${table}` → 출처 (테이블 중복 검출). */
|
|
166
|
+
const tables = new Map()
|
|
167
|
+
|
|
168
|
+
for (const m of models) {
|
|
169
|
+
const { name, record } = m
|
|
170
|
+
const where = { model: name, table: record.table }
|
|
171
|
+
const maxBytes = identifierMaxBytesOf(record.adapter)
|
|
172
|
+
|
|
173
|
+
if (!IDENT_RE.test(record.table)) {
|
|
174
|
+
throw invalid(`model '${name}': table '${record.table}' 이 유효한 식별자가 아닙니다.`, where)
|
|
175
|
+
}
|
|
176
|
+
// dialect 식별자 한도 — 절단(서버측 truncate)은 이름 충돌·표기 불일치를 만들므로 거부.
|
|
177
|
+
// 합성 이름(idx_/fk_ 등)은 dialect quoteIdent 깔때기가 렌더 시점에 추가 검증한다.
|
|
178
|
+
if (Buffer.byteLength(record.table, 'utf8') > maxBytes) {
|
|
179
|
+
throw new MegaConfigError(
|
|
180
|
+
'migration.identifier_too_long',
|
|
181
|
+
`model '${name}': table '${record.table}' 이 dialect 식별자 한도(${maxBytes}byte)를 초과합니다 — 이름을 줄이세요.`,
|
|
182
|
+
{ details: { ...where, bytes: Buffer.byteLength(record.table, 'utf8'), max: maxBytes } },
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
const tableKey = `${record.adapter}:${record.table}`
|
|
186
|
+
const dupTable = tables.get(tableKey)
|
|
187
|
+
if (dupTable !== undefined) {
|
|
188
|
+
throw invalid(
|
|
189
|
+
`model '${name}': table '${record.table}' (adapter '${record.adapter}') 이 model '${dupTable.model}' 과 중복됩니다. ` +
|
|
190
|
+
`파일: ${dupTable.file ?? '(unknown)'} ↔ ${m.file ?? '(unknown)'}`,
|
|
191
|
+
{ ...where, dupModel: dupTable.model, files: [dupTable.file ?? null, m.file ?? null] },
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
tables.set(tableKey, { model: name, file: m.file })
|
|
195
|
+
|
|
196
|
+
const colNames = Object.keys(record.columns)
|
|
197
|
+
if (colNames.length === 0) {
|
|
198
|
+
throw invalid(`model '${name}': 컬럼이 없습니다 — schema 는 최소 1개 컬럼을 정의해야 합니다.`, where)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// PK 규칙 — 컬럼 레벨 .primary() 는 최대 1개, 복합 PK(t.primary)와 동시 사용 불가.
|
|
202
|
+
const primaryCols = colNames.filter((c) => record.columns[c].primary === true)
|
|
203
|
+
if (primaryCols.length > 1) {
|
|
204
|
+
throw invalid(
|
|
205
|
+
`model '${name}': .primary() 컬럼이 ${primaryCols.length}개(${primaryCols.join(', ')}) — 복합 PK 는 t.primary(['a','b']) 로 선언하세요.`,
|
|
206
|
+
{ ...where, columns: primaryCols },
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
if (record.primaryKey !== undefined) {
|
|
210
|
+
if (primaryCols.length > 0) {
|
|
211
|
+
throw invalid(`model '${name}': 컬럼 .primary() 와 t.primary([...]) 를 동시에 쓸 수 없습니다.`, { ...where, columns: primaryCols })
|
|
212
|
+
}
|
|
213
|
+
if (!Array.isArray(record.primaryKey) || record.primaryKey.length === 0) {
|
|
214
|
+
throw invalid(`model '${name}': t.primary([...]) 는 비어 있지 않은 컬럼 배열이어야 합니다.`, where)
|
|
215
|
+
}
|
|
216
|
+
for (const c of record.primaryKey) {
|
|
217
|
+
if (record.columns[c] === undefined) {
|
|
218
|
+
throw invalid(`model '${name}': t.primary 의 '${c}' 가 schema 컬럼에 없습니다.`, { ...where, column: c })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const [colName, def] of Object.entries(record.columns)) {
|
|
224
|
+
const colWhere = { ...where, column: colName }
|
|
225
|
+
if (!IDENT_RE.test(colName)) {
|
|
226
|
+
throw invalid(`model '${name}': 컬럼명 '${colName}' 이 유효한 식별자가 아닙니다.`, colWhere)
|
|
227
|
+
}
|
|
228
|
+
if (Buffer.byteLength(colName, 'utf8') > maxBytes) {
|
|
229
|
+
throw new MegaConfigError(
|
|
230
|
+
'migration.identifier_too_long',
|
|
231
|
+
`model '${name}': 컬럼명 '${colName}' 이 dialect 식별자 한도(${maxBytes}byte)를 초과합니다 — 이름을 줄이세요.`,
|
|
232
|
+
{ details: { ...colWhere, bytes: Buffer.byteLength(colName, 'utf8'), max: maxBytes } },
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
if (!KNOWN_TYPES.has(def.type)) {
|
|
236
|
+
throw invalid(`model '${name}.${colName}': 알 수 없는 타입 '${def.type}'.`, colWhere)
|
|
237
|
+
}
|
|
238
|
+
if (def.notNull === true && def.nullable === true) {
|
|
239
|
+
throw invalid(`model '${name}.${colName}': .notNull() 과 .nullable() 은 동시에 쓸 수 없습니다(모순).`, colWhere)
|
|
240
|
+
}
|
|
241
|
+
if ((def.type === 'varchar' || def.type === 'char') && (!Number.isInteger(def.length) || def.length <= 0)) {
|
|
242
|
+
throw invalid(`model '${name}.${colName}': ${def.type} 길이는 양의 정수여야 합니다. Got ${JSON.stringify(def.length)}.`, colWhere)
|
|
243
|
+
}
|
|
244
|
+
if (def.type === 'decimal' && (!Number.isInteger(def.precision) || def.precision <= 0 || !Number.isInteger(def.scale) || def.scale < 0)) {
|
|
245
|
+
throw invalid(`model '${name}.${colName}': decimal(precision, scale) 은 양의 정수 precision 과 0 이상 정수 scale 이 필요합니다.`, colWhere)
|
|
246
|
+
}
|
|
247
|
+
if (def.type === 'enum') {
|
|
248
|
+
if (!Array.isArray(def.values) || def.values.length === 0 || def.values.some((/** @type {any} */ v) => typeof v !== 'string' || v.length === 0)) {
|
|
249
|
+
throw invalid(`model '${name}.${colName}': enum values 는 비어 있지 않은 문자열 배열이어야 합니다.`, colWhere)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// mongo 중첩 구조(object shape / array items) — 재귀 검증.
|
|
253
|
+
validateNestedStructure(name, colName, def)
|
|
254
|
+
if (def.check !== undefined && (typeof def.check.expr !== 'string' || def.check.expr.length === 0)) {
|
|
255
|
+
throw invalid(`model '${name}.${colName}': check 식이 비어 있습니다.`, colWhere)
|
|
256
|
+
}
|
|
257
|
+
if (def.default !== undefined && def.default !== null && typeof def.default === 'object') {
|
|
258
|
+
if (typeof def.default.raw !== 'string' || def.default.raw.length === 0) {
|
|
259
|
+
throw invalid(`model '${name}.${colName}': default 객체는 { raw: '<SQL 식>' } 형태여야 합니다.`, colWhere)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ref = def.references
|
|
264
|
+
if (ref !== undefined) {
|
|
265
|
+
const target = byName.get(ref.model)
|
|
266
|
+
if (target === undefined) {
|
|
267
|
+
throw invalid(
|
|
268
|
+
`model '${name}.${colName}': references 대상 모델 '${ref.model}' 이 file-scan 결과에 없습니다 — schema 를 가진 모델만 FK 대상이 될 수 있습니다.`,
|
|
269
|
+
{ ...colWhere, refModel: ref.model },
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
if (target.record.adapter !== record.adapter) {
|
|
273
|
+
throw invalid(
|
|
274
|
+
`model '${name}.${colName}': FK 대상 '${ref.model}' 의 adapter('${target.record.adapter}')가 다릅니다 — cross-adapter FK 는 불가능합니다.`,
|
|
275
|
+
{ ...colWhere, refModel: ref.model },
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
if (target.record.columns[ref.column] === undefined) {
|
|
279
|
+
throw invalid(`model '${name}.${colName}': references 대상 컬럼 '${ref.model}.${ref.column}' 이 없습니다.`, {
|
|
280
|
+
...colWhere,
|
|
281
|
+
refModel: ref.model,
|
|
282
|
+
refColumn: ref.column,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
if (ref.onDelete !== undefined && !FK_ACTIONS.includes(ref.onDelete)) {
|
|
286
|
+
throw invalid(`model '${name}.${colName}': onDelete '${ref.onDelete}' — 허용: ${FK_ACTIONS.join(' | ')}.`, colWhere)
|
|
287
|
+
}
|
|
288
|
+
if (ref.onUpdate !== undefined && !FK_ACTIONS.includes(ref.onUpdate)) {
|
|
289
|
+
throw invalid(`model '${name}.${colName}': onUpdate '${ref.onUpdate}' — 허용: ${FK_ACTIONS.join(' | ')}.`, colWhere)
|
|
290
|
+
}
|
|
291
|
+
// FK 대상 테이블 해석 — 스냅샷 자기완결(과거 스냅샷 diff 에 모델 재스캔 불요).
|
|
292
|
+
ref.table = target.table
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 인덱스 — 컬럼 존재 + 표현식/컬럼 상호배타.
|
|
297
|
+
for (const ix of record.indexes ?? []) {
|
|
298
|
+
if (ix.expression !== undefined) {
|
|
299
|
+
if (typeof ix.expression !== 'string' || ix.expression.length === 0) {
|
|
300
|
+
throw invalid(`model '${name}': 표현식 인덱스의 expression 이 비어 있습니다.`, where)
|
|
301
|
+
}
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(ix.columns) || ix.columns.length === 0) {
|
|
305
|
+
throw invalid(`model '${name}': 인덱스는 columns 또는 { expression } 이 필요합니다.`, where)
|
|
306
|
+
}
|
|
307
|
+
for (const c of ix.columns) {
|
|
308
|
+
// mongo 정렬 방향 suffix(':desc'/':asc')는 필드명이 아니다 — 제거 후 존재 검증
|
|
309
|
+
// (SQL dialect 에선 suffix 포함 이름이 quoteIdent 깔때기에서 거부된다).
|
|
310
|
+
const field = c.endsWith(':desc') ? c.slice(0, -5) : c.endsWith(':asc') ? c.slice(0, -4) : c
|
|
311
|
+
assertIndexFieldExists(name, record, field, where)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 마이그레이션 락 (ADR-190) — 동시 `mega migrate` 실행(멀티 인스턴스 배포 훅 등)의 중복 적용을 막는다.
|
|
4
|
+
*
|
|
5
|
+
* 러너({@link module:core/migration-runner})는 어댑터 비의존을 유지하고, 락은 **호스트(`runMigrateHost`)
|
|
6
|
+
* 레벨**에서 driver 별 표준 메커니즘으로 건다:
|
|
7
|
+
* - postgres : `pg_advisory_lock(key)` — 세션 귀속 락이라 풀에서 연결 1개를 고정(pin)해 잡고,
|
|
8
|
+
* 같은 연결에서 unlock 한다(풀 경유 query 는 매번 다른 연결을 탈 수 있어 부적합).
|
|
9
|
+
* 선점돼 있으면 해제까지 대기한다(PostgreSQL 공식 문서 — advisory lock 은 세션 종료 시 자동 해제).
|
|
10
|
+
* - mariadb : `GET_LOCK(name, timeout)` — 동일하게 연결 고정, timeout 초과 시 fail-fast.
|
|
11
|
+
* 추가로 MariaDB 는 DDL 이 암묵 COMMIT 을 유발해(공식 문서 "Statements That Cause an
|
|
12
|
+
* Implicit Commit") 러너의 트랜잭션 래핑이 DDL 원자성을 보장하지 못함을 경고로 명시한다.
|
|
13
|
+
* - sqlite : 별도 락 없음 — connection-per-Database 단일 파일 DB 는 자체 파일 락 + 트랜잭션 DDL
|
|
14
|
+
* (busy timeout 직렬화)로 충분하다(ADR-105). 잔존 경합은 러너의 2중 이력 재확인이
|
|
15
|
+
* 흡수한다(ADR-208 M-2).
|
|
16
|
+
* - mongodb : `mega_migrations_lock` 컬렉션의 `_id:'lock'` 도큐먼트 선점(insertOne — _id 유일성이
|
|
17
|
+
* 원자적 선점). 선점돼 있으면 폴링 대기, 한도 초과 시 fail-fast(보유자 정보 동반).
|
|
18
|
+
* 해제는 deleteOne — crash 로 남은 stale lock 은 에러 안내의 수동 정리 절차를 따른다(ADR-209).
|
|
19
|
+
* - 그 외 : 락 미지원 driver 는 **명시 경고 후** 락 없이 진행한다(silent skip 금지, P4).
|
|
20
|
+
*
|
|
21
|
+
* @module core/migration-lock
|
|
22
|
+
*/
|
|
23
|
+
import { MegaConfigError } from '../errors/config-error.js'
|
|
24
|
+
|
|
25
|
+
/** postgres advisory lock 키 — 'mega' ASCII(0x6d656761). 프레임워크 고정값(임의 64-bit 앱 키). */
|
|
26
|
+
export const PG_ADVISORY_LOCK_KEY = 0x6d656761
|
|
27
|
+
|
|
28
|
+
/** mariadb GET_LOCK 이름. */
|
|
29
|
+
export const MARIA_LOCK_NAME = 'mega_migrations'
|
|
30
|
+
|
|
31
|
+
/** mariadb GET_LOCK 대기 한도(초) — 초과 시 `migrate.lock_timeout` fail-fast. */
|
|
32
|
+
export const MARIA_LOCK_TIMEOUT_SEC = 60
|
|
33
|
+
|
|
34
|
+
/** mongodb 락 컬렉션 이름. */
|
|
35
|
+
export const MONGO_LOCK_COLLECTION = 'mega_migrations_lock'
|
|
36
|
+
|
|
37
|
+
/** mongodb 락 대기 한도(ms)·폴링 간격(ms) — 한도 초과 시 `migrate.lock_timeout` fail-fast. */
|
|
38
|
+
export const MONGO_LOCK_TIMEOUT_MS = 60_000
|
|
39
|
+
export const MONGO_LOCK_POLL_MS = 500
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{ debug?: Function, info?: Function, warn?: Function }} LockLogger
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* driver 별 마이그레이션 락 안에서 `fn` 을 실행한다. 락 획득/해제는 같은 고정 연결에서 수행하고,
|
|
47
|
+
* 해제 실패는 원본 결과/에러를 가리지 않는다(세션 락은 연결 반납·종료 시 서버가 자동 해제).
|
|
48
|
+
*
|
|
49
|
+
* @template T
|
|
50
|
+
* @param {import('../adapters/mega-adapter.js').MegaAdapter} adapter - 연결된 대상 DB 어댑터.
|
|
51
|
+
* @param {() => Promise<T>} fn - 락 보유 중 실행할 작업(마이그레이션 up/down).
|
|
52
|
+
* @param {{ logger?: LockLogger }} [opts]
|
|
53
|
+
* @returns {Promise<T>}
|
|
54
|
+
* @throws {MegaConfigError} `migrate.lock_timeout` - mariadb GET_LOCK 대기 한도 초과.
|
|
55
|
+
*/
|
|
56
|
+
export async function withMigrationLock(adapter, fn, { logger } = {}) {
|
|
57
|
+
const driver = /** @type {any} */ (adapter)?.getStats?.().driver
|
|
58
|
+
if (driver === 'postgres') return withPgAdvisoryLock(adapter, fn, logger)
|
|
59
|
+
if (driver === 'mariadb') return withMariaLock(adapter, fn, logger)
|
|
60
|
+
if (driver === 'mongodb') return withMongoLock(adapter, fn, logger)
|
|
61
|
+
if (driver === 'sqlite') {
|
|
62
|
+
// 단일 파일 DB — 파일 락 + 트랜잭션 DDL 이 곧 직렬화 장치라 별도 락이 불필요(ADR-105/184).
|
|
63
|
+
logger?.debug?.({ driver }, 'migrate.lock skipped (sqlite file lock + transactional DDL)')
|
|
64
|
+
return fn()
|
|
65
|
+
}
|
|
66
|
+
// 3rd-party driver 등 락 전략 미정의 — 락 없이 진행하되 반드시 명시 경고(P4: silent skip 금지).
|
|
67
|
+
logger?.warn?.({ driver: driver ?? null }, 'migrate.lock unsupported for this driver — proceeding without a migration lock')
|
|
68
|
+
return fn()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* postgres — 풀에서 연결 1개를 고정해 `pg_advisory_lock` 으로 직렬화한다. 락은 세션(연결) 귀속이라
|
|
73
|
+
* 같은 클라이언트에서 잡고 풀어야 하며, 마이그레이션 본문은 평소처럼 어댑터(풀)로 실행돼도 무방하다.
|
|
74
|
+
*
|
|
75
|
+
* @template T
|
|
76
|
+
* @param {any} adapter @param {() => Promise<T>} fn @param {LockLogger} [logger]
|
|
77
|
+
* @returns {Promise<T>}
|
|
78
|
+
*/
|
|
79
|
+
async function withPgAdvisoryLock(adapter, fn, logger) {
|
|
80
|
+
const pool = adapter.native
|
|
81
|
+
const client = await pool.connect()
|
|
82
|
+
try {
|
|
83
|
+
logger?.debug?.({ key: PG_ADVISORY_LOCK_KEY }, 'migrate.lock acquiring (pg_advisory_lock)')
|
|
84
|
+
// 선점 시 해제까지 대기 — 동시 배포의 늦은 쪽은 앞선 쪽이 끝난 뒤 pending 0 으로 무해 통과한다.
|
|
85
|
+
await client.query('SELECT pg_advisory_lock($1)', [PG_ADVISORY_LOCK_KEY])
|
|
86
|
+
logger?.debug?.({ key: PG_ADVISORY_LOCK_KEY }, 'migrate.lock acquired')
|
|
87
|
+
try {
|
|
88
|
+
return await fn()
|
|
89
|
+
} finally {
|
|
90
|
+
try {
|
|
91
|
+
await client.query('SELECT pg_advisory_unlock($1)', [PG_ADVISORY_LOCK_KEY])
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// unlock 실패는 비치명적 — 아래 release/세션 종료 시 서버가 자동 해제한다. 원본 결과 우선.
|
|
94
|
+
logger?.warn?.({ err }, 'migrate.lock pg_advisory_unlock failed (lock auto-releases with the session)')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
client.release()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* mariadb — 연결 1개를 고정해 `GET_LOCK` 으로 직렬화한다. DDL 암묵 COMMIT 경고를 함께 남긴다.
|
|
104
|
+
*
|
|
105
|
+
* @template T
|
|
106
|
+
* @param {any} adapter @param {() => Promise<T>} fn @param {LockLogger} [logger]
|
|
107
|
+
* @returns {Promise<T>}
|
|
108
|
+
*/
|
|
109
|
+
async function withMariaLock(adapter, fn, logger) {
|
|
110
|
+
// MariaDB 는 CREATE/ALTER/DROP 등 DDL 이 암묵 COMMIT 을 유발 — 러너의 트랜잭션 래핑이 DDL 을
|
|
111
|
+
// 롤백하지 못한다. 락으로 동시 실행은 막지만, 중간 실패 시 부분 적용이 남을 수 있음을 명시(ADR-190).
|
|
112
|
+
logger?.warn?.('migrate: MariaDB DDL causes an implicit COMMIT — a failed migration may leave partially applied DDL (no rollback).')
|
|
113
|
+
const pool = adapter.native
|
|
114
|
+
const conn = await pool.getConnection()
|
|
115
|
+
try {
|
|
116
|
+
logger?.debug?.({ name: MARIA_LOCK_NAME }, 'migrate.lock acquiring (GET_LOCK)')
|
|
117
|
+
const rows = await conn.query(`SELECT GET_LOCK('${MARIA_LOCK_NAME}', ${MARIA_LOCK_TIMEOUT_SEC}) AS l`)
|
|
118
|
+
// GET_LOCK: 1=획득, 0=timeout, NULL=에러. bigIntStrategy 에 따라 BigInt 일 수 있어 Number 정규화.
|
|
119
|
+
const raw = rows?.[0]?.l
|
|
120
|
+
const got = raw == null ? null : Number(raw)
|
|
121
|
+
if (got !== 1) {
|
|
122
|
+
throw new MegaConfigError(
|
|
123
|
+
'migrate.lock_timeout',
|
|
124
|
+
`another migration appears to be running — GET_LOCK('${MARIA_LOCK_NAME}') not acquired within ${MARIA_LOCK_TIMEOUT_SEC}s.`,
|
|
125
|
+
{ details: { result: got } },
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
logger?.debug?.({ name: MARIA_LOCK_NAME }, 'migrate.lock acquired')
|
|
129
|
+
try {
|
|
130
|
+
return await fn()
|
|
131
|
+
} finally {
|
|
132
|
+
try {
|
|
133
|
+
await conn.query(`SELECT RELEASE_LOCK('${MARIA_LOCK_NAME}')`)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// 해제 실패는 비치명적 — 연결 반납·세션 종료 시 서버가 자동 해제한다. 원본 결과 우선.
|
|
136
|
+
logger?.warn?.({ err }, 'migrate.lock RELEASE_LOCK failed (lock auto-releases with the session)')
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
await conn.release()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* mongodb — `mega_migrations_lock` 의 `_id:'lock'` 도큐먼트를 insertOne 으로 선점한다(_id 유일성이
|
|
146
|
+
* 원자적 선점 장치 — 트랜잭션/replica-set 불요라 standalone 에서도 동작). 선점돼 있으면 폴링으로
|
|
147
|
+
* 대기하고, 한도 초과 시 보유자 정보(pid/host/at)와 수동 정리 절차를 담아 fail-fast 한다.
|
|
148
|
+
* mongo DDL 은 트랜잭션 밖이라(공식 문서 — collMod 등은 multi-document 트랜잭션 불가) 중간 실패는
|
|
149
|
+
* 부분 적용으로 남을 수 있음을 경고로 명시한다(maria 패턴 정합, ADR-190/209).
|
|
150
|
+
*
|
|
151
|
+
* @template T
|
|
152
|
+
* @param {any} adapter @param {() => Promise<T>} fn @param {LockLogger} [logger]
|
|
153
|
+
* @returns {Promise<T>}
|
|
154
|
+
*/
|
|
155
|
+
async function withMongoLock(adapter, fn, logger) {
|
|
156
|
+
logger?.warn?.('migrate: MongoDB DDL runs outside transactions — a failed migration may leave partially applied changes (no rollback).')
|
|
157
|
+
/** @type {import('mongodb').Db} */
|
|
158
|
+
const db = adapter.native
|
|
159
|
+
const coll = db.collection(MONGO_LOCK_COLLECTION)
|
|
160
|
+
const me = { _id: /** @type {any} */ ('lock'), pid: process.pid, host: process.env.HOSTNAME ?? 'unknown', at: new Date().toISOString() }
|
|
161
|
+
logger?.debug?.({ collection: MONGO_LOCK_COLLECTION }, 'migrate.lock acquiring (mongo lock document)')
|
|
162
|
+
const deadline = Date.now() + MONGO_LOCK_TIMEOUT_MS
|
|
163
|
+
for (;;) {
|
|
164
|
+
try {
|
|
165
|
+
await coll.insertOne(me)
|
|
166
|
+
break
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// E11000(중복 키) = 다른 러너 보유 — 폴링 대기. 그 외 에러는 즉시 전파(연결 단절 등).
|
|
169
|
+
if (/** @type {any} */ (err).code !== 11000) throw err
|
|
170
|
+
if (Date.now() >= deadline) {
|
|
171
|
+
/** @type {any} */
|
|
172
|
+
let holder = null
|
|
173
|
+
try {
|
|
174
|
+
holder = await coll.findOne({ _id: /** @type {any} */ ('lock') })
|
|
175
|
+
} catch (readErr) {
|
|
176
|
+
// 보유자 조회 실패는 안내 품질 문제일 뿐 — timeout 이 정본 에러다.
|
|
177
|
+
logger?.debug?.({ err: readErr }, 'migrate.lock holder lookup failed')
|
|
178
|
+
}
|
|
179
|
+
throw new MegaConfigError(
|
|
180
|
+
'migrate.lock_timeout',
|
|
181
|
+
`another migration appears to be running — mongo lock not acquired within ${MONGO_LOCK_TIMEOUT_MS / 1000}s ` +
|
|
182
|
+
`(holder: ${holder ? `pid ${holder.pid}@${holder.host} since ${holder.at}` : '(unknown)'}). ` +
|
|
183
|
+
`진행 중인 프로세스가 없는데도 반복되면 crash 로 남은 stale lock 입니다 — ` +
|
|
184
|
+
`db.${MONGO_LOCK_COLLECTION}.deleteOne({ _id: 'lock' }) 로 정리 후 재시도하세요.`,
|
|
185
|
+
{ details: { collection: MONGO_LOCK_COLLECTION, holder } },
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, MONGO_LOCK_POLL_MS))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
logger?.debug?.({ collection: MONGO_LOCK_COLLECTION }, 'migrate.lock acquired')
|
|
192
|
+
try {
|
|
193
|
+
return await fn()
|
|
194
|
+
} finally {
|
|
195
|
+
try {
|
|
196
|
+
await coll.deleteOne({ _id: /** @type {any} */ ('lock'), pid: process.pid })
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// 해제 실패는 비치명적이지 않다(mongo 락은 세션 자동 해제가 없다) — stale lock 안내를 경고로 남긴다.
|
|
199
|
+
logger?.warn?.(
|
|
200
|
+
{ err },
|
|
201
|
+
`migrate.lock release failed — stale lock 이 남으면 db.${MONGO_LOCK_COLLECTION}.deleteOne({ _id: 'lock' }) 로 정리하세요`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|