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
|
@@ -73,9 +73,9 @@
|
|
|
73
73
|
* @module adapters/maria-adapter
|
|
74
74
|
*/
|
|
75
75
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
76
|
-
import { MegaValidationError } from '../errors/http-errors.js'
|
|
76
|
+
import { MegaInternalError, MegaValidationError } from '../errors/http-errors.js'
|
|
77
77
|
import { MegaDbAdapter } from './mega-db-adapter.js'
|
|
78
|
-
import { resolveConnection, normalizePool, assertPlainObject, MARIA_POOL_SPEC } from './adapter-options.js'
|
|
78
|
+
import { resolveConnection, normalizePool, assertPlainObject, resolveTxIsolation, MARIA_POOL_SPEC } from './adapter-options.js'
|
|
79
79
|
import * as Registry from './registry.js'
|
|
80
80
|
|
|
81
81
|
/**
|
|
@@ -165,6 +165,11 @@ function resolveBigIntStrategy(options) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
export class MegaMariaAdapter extends MegaDbAdapter {
|
|
168
|
+
/** driver 식별자 — dialect 디스패치(getDialect)·CRUD 의 단일 출처(ADR-212). @returns {'mariadb'} */
|
|
169
|
+
get driver() {
|
|
170
|
+
return 'mariadb'
|
|
171
|
+
}
|
|
172
|
+
|
|
168
173
|
/** @type {import('mariadb').Pool | null} 연결된 Pool 인스턴스 (connect 후에만). */
|
|
169
174
|
#pool = null
|
|
170
175
|
/** @type {Record<string, unknown>} _connect 의 `createPool()` 인자 (url 도 discrete 로 파싱해 항상 객체, ADR-109). */
|
|
@@ -318,23 +323,41 @@ export class MegaMariaAdapter extends MegaDbAdapter {
|
|
|
318
323
|
* RELEASE SAVEPOINT) 후 반환값을 그대로 돌려주고, throw 시 rollback(또는 ROLLBACK TO SAVEPOINT)
|
|
319
324
|
* 후 원본 에러 re-throw.
|
|
320
325
|
*
|
|
326
|
+
* `opts.isolation`(ADR-190) — top-level 이면 `beginTransaction()` **직전에** 같은 연결에서
|
|
327
|
+
* `SET TRANSACTION ISOLATION LEVEL` 을 실행한다(MariaDB 의 `SET TRANSACTION` 은 같은 세션의
|
|
328
|
+
* **다음** 트랜잭션 1회에만 적용 — 공식 문서). 격리수준은 트랜잭션 시작 시 고정되므로
|
|
329
|
+
* nested(SAVEPOINT) 호출에 지정하면 `adapter.nested_isolation_unsupported` 로 거부한다.
|
|
330
|
+
*
|
|
321
331
|
* hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
|
|
322
332
|
*
|
|
323
333
|
* @template T
|
|
324
334
|
* @param {(conn: import('mariadb').PoolConnection) => Promise<T> | T} fn
|
|
335
|
+
* @param {{ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' }} [opts] - 트랜잭션 옵션(ADR-190).
|
|
325
336
|
* @returns {Promise<T>}
|
|
337
|
+
* @throws {MegaInternalError} `adapter.nested_isolation_unsupported` - nested 호출에 isolation 지정.
|
|
326
338
|
*/
|
|
327
|
-
async withTransaction(fn) {
|
|
339
|
+
async withTransaction(fn, opts) {
|
|
340
|
+
// 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
|
|
341
|
+
const isolationSql = resolveTxIsolation(opts?.isolation, 'mariadb')
|
|
328
342
|
return this._instrument('withTransaction', { table: undefined }, async () => {
|
|
329
343
|
const existing = this.#txContext.getStore()
|
|
330
344
|
// nested — 진행 중 트랜잭션이 있으면 같은 연결에 SAVEPOINT 를 건다(Sqlite 와 분기).
|
|
331
345
|
if (existing !== undefined) {
|
|
346
|
+
if (isolationSql !== undefined) {
|
|
347
|
+
throw new MegaInternalError(
|
|
348
|
+
'adapter.nested_isolation_unsupported',
|
|
349
|
+
`${this.constructor.name}.withTransaction() cannot set an isolation level on a nested (SAVEPOINT) transaction — isolation is fixed when the top-level transaction starts (ADR-190).`,
|
|
350
|
+
{ details: { adapter: this.constructor.name, isolation: opts?.isolation } },
|
|
351
|
+
)
|
|
352
|
+
}
|
|
332
353
|
return this.#runSavepoint(existing, fn)
|
|
333
354
|
}
|
|
334
355
|
// top-level — 풀에서 연결 1개 획득 후 beginTransaction/commit/rollback.
|
|
335
356
|
const pool = /** @type {import('mariadb').Pool} */ (this.#pool)
|
|
336
357
|
const conn = await pool.getConnection()
|
|
337
358
|
try {
|
|
359
|
+
// SET TRANSACTION 은 다음 트랜잭션 1회에만 적용 — 반드시 beginTransaction 직전, 같은 연결에서.
|
|
360
|
+
if (isolationSql !== undefined) await conn.query(`SET TRANSACTION ISOLATION LEVEL ${isolationSql}`)
|
|
338
361
|
await conn.beginTransaction()
|
|
339
362
|
let result
|
|
340
363
|
try {
|
|
@@ -30,11 +30,17 @@ export class MegaDbAdapter extends MegaAdapter {
|
|
|
30
30
|
* driver 별 구현 (postgres `BEGIN/COMMIT/ROLLBACK`, MongoDB `session.withTransaction`).
|
|
31
31
|
* nested 호출은 driver 별 (postgres SAVEPOINT, MongoDB throw `adapter.nested_transaction_unsupported`).
|
|
32
32
|
*
|
|
33
|
+
* `opts.isolation`(ADR-190) — SQL 격리수준 옵트인. 미지정이면 driver 디폴트. driver 별 지원:
|
|
34
|
+
* postgres/maria 는 top-level 트랜잭션에 `SET TRANSACTION ISOLATION LEVEL` 로 반영(nested 엔 불가 —
|
|
35
|
+
* `adapter.nested_isolation_unsupported`), sqlite 는 항상 SERIALIZABLE 동작이라 'serializable' 만 수용,
|
|
36
|
+
* mongodb 는 SQL 격리수준 개념이 없어 지정 시 `adapter.invalid_option`.
|
|
37
|
+
*
|
|
33
38
|
* @template T
|
|
34
39
|
* @param {(db: any) => Promise<T>} _fn - 트랜잭션 컨텍스트의 `db` 를 받는 콜백.
|
|
40
|
+
* @param {{ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' }} [_opts] - 트랜잭션 옵션(ADR-190).
|
|
35
41
|
* @returns {Promise<T>}
|
|
36
42
|
*/
|
|
37
|
-
async withTransaction(_fn) {
|
|
43
|
+
async withTransaction(_fn, _opts) {
|
|
38
44
|
return this._notImplemented('withTransaction')
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -140,6 +140,11 @@ function buildMongoUri({ host, port, user, password }) {
|
|
|
140
140
|
*/
|
|
141
141
|
|
|
142
142
|
export class MegaMongoAdapter extends MegaDbAdapter {
|
|
143
|
+
/** driver 식별자 — dialect 디스패치·CRUD 의 단일 출처(ADR-212). mongo 는 SQL CRUD 미지원(P3). @returns {'mongodb'} */
|
|
144
|
+
get driver() {
|
|
145
|
+
return 'mongodb'
|
|
146
|
+
}
|
|
147
|
+
|
|
143
148
|
/** @type {import('mongodb').MongoClient | null} 연결된 MongoClient (connect 후에만). */
|
|
144
149
|
#client = null
|
|
145
150
|
/** @type {import('mongodb').Db | null} 선택된 Db 인스턴스 (connect 후에만). */
|
|
@@ -320,14 +325,27 @@ export class MegaMongoAdapter extends MegaDbAdapter {
|
|
|
320
325
|
* throw 시 abort 후 원본 에러를 re-throw 한다. nested 호출은 ALS 로 감지해 거부(Sqlite 와 동일).
|
|
321
326
|
* `session.endSession()` 은 `finally` 에서 반드시 호출(leak 방지).
|
|
322
327
|
*
|
|
328
|
+
* `opts.isolation`(ADR-190) — MongoDB 트랜잭션에는 SQL 격리수준 개념이 없다(snapshot 의미는 driver/
|
|
329
|
+
* readConcern 관할). 지정 시 `adapter.invalid_option` 으로 명시 거부한다(조용히 무시 X — P4).
|
|
330
|
+
*
|
|
323
331
|
* hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
|
|
324
332
|
*
|
|
325
333
|
* @template T
|
|
326
334
|
* @param {(db: import('mongodb').Db, session: import('mongodb').ClientSession) => Promise<T> | T} fn
|
|
335
|
+
* @param {{ isolation?: never }} [opts] - 트랜잭션 옵션(ADR-190) — mongodb 는 isolation 미지원.
|
|
327
336
|
* @returns {Promise<T>}
|
|
328
337
|
* @throws {MegaInternalError} `adapter.nested_transaction_unsupported` - 이미 트랜잭션 진행 중.
|
|
338
|
+
* @throws {MegaValidationError} `adapter.invalid_option` - isolation 지정(미지원).
|
|
329
339
|
*/
|
|
330
|
-
async withTransaction(fn) {
|
|
340
|
+
async withTransaction(fn, opts) {
|
|
341
|
+
// 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
|
|
342
|
+
if (opts?.isolation !== undefined) {
|
|
343
|
+
throw new MegaValidationError(
|
|
344
|
+
'adapter.invalid_option',
|
|
345
|
+
'mongodb: SQL isolation levels do not apply to MongoDB transactions — remove the "isolation" option (snapshot semantics are managed by the driver/readConcern).',
|
|
346
|
+
{ details: { driver: 'mongodb', option: 'isolation', value: opts.isolation } },
|
|
347
|
+
)
|
|
348
|
+
}
|
|
331
349
|
return this._instrument('withTransaction', { table: undefined }, async () => {
|
|
332
350
|
// nested 거부 — 진행 중 트랜잭션이 있으면 즉시 throw (Sqlite 와 동일 정책·에러 코드, ADR-108).
|
|
333
351
|
// 검사를 driver 호출 전에 두어, standalone(트랜잭션 미지원)에서도 nested 거부는 동작한다.
|
|
@@ -67,8 +67,9 @@
|
|
|
67
67
|
* @module adapters/postgres-adapter
|
|
68
68
|
*/
|
|
69
69
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
70
|
+
import { MegaInternalError } from '../errors/http-errors.js'
|
|
70
71
|
import { MegaDbAdapter } from './mega-db-adapter.js'
|
|
71
|
-
import { resolveConnection, normalizePool, assertPlainObject, PG_POOL_SPEC } from './adapter-options.js'
|
|
72
|
+
import { resolveConnection, normalizePool, assertPlainObject, resolveTxIsolation, PG_POOL_SPEC } from './adapter-options.js'
|
|
72
73
|
import * as Registry from './registry.js'
|
|
73
74
|
|
|
74
75
|
/**
|
|
@@ -89,6 +90,11 @@ import * as Registry from './registry.js'
|
|
|
89
90
|
*/
|
|
90
91
|
|
|
91
92
|
export class MegaPostgresAdapter extends MegaDbAdapter {
|
|
93
|
+
/** driver 식별자 — dialect 디스패치(getDialect)·CRUD 의 단일 출처(ADR-212). @returns {'postgres'} */
|
|
94
|
+
get driver() {
|
|
95
|
+
return 'postgres'
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
/** @type {import('pg').Pool | null} 연결된 Pool 인스턴스 (connect 후에만). */
|
|
93
99
|
#pool = null
|
|
94
100
|
/** @type {import('pg').PoolConfig} _connect 에서 `new Pool()` 에 넘길 설정 (생성자에서 구성·고정). */
|
|
@@ -234,17 +240,32 @@ export class MegaPostgresAdapter extends MegaDbAdapter {
|
|
|
234
240
|
* RELEASE SAVEPOINT) 후 `fn` 반환값을 그대로 돌려주고, throw 시 ROLLBACK(또는 ROLLBACK TO
|
|
235
241
|
* SAVEPOINT) 후 원본 에러를 re-throw 한다.
|
|
236
242
|
*
|
|
243
|
+
* `opts.isolation`(ADR-190) — top-level 이면 `BEGIN` 직후 `SET TRANSACTION ISOLATION LEVEL` 로
|
|
244
|
+
* 반영한다(PostgreSQL 은 트랜잭션 첫 쿼리 전이라 유효). 격리수준은 트랜잭션 시작 시 고정되므로
|
|
245
|
+
* nested(SAVEPOINT) 호출에 지정하면 `adapter.nested_isolation_unsupported` 로 거부한다.
|
|
246
|
+
*
|
|
237
247
|
* hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
|
|
238
248
|
*
|
|
239
249
|
* @template T
|
|
240
250
|
* @param {(client: import('pg').PoolClient) => Promise<T> | T} fn
|
|
251
|
+
* @param {{ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' }} [opts] - 트랜잭션 옵션(ADR-190).
|
|
241
252
|
* @returns {Promise<T>}
|
|
253
|
+
* @throws {MegaInternalError} `adapter.nested_isolation_unsupported` - nested 호출에 isolation 지정.
|
|
242
254
|
*/
|
|
243
|
-
async withTransaction(fn) {
|
|
255
|
+
async withTransaction(fn, opts) {
|
|
256
|
+
// 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
|
|
257
|
+
const isolationSql = resolveTxIsolation(opts?.isolation, 'postgres')
|
|
244
258
|
return this._instrument('withTransaction', { table: undefined }, async () => {
|
|
245
259
|
const existing = this.#txContext.getStore()
|
|
246
260
|
// nested — 진행 중 트랜잭션이 있으면 같은 클라이언트에 SAVEPOINT 를 건다(Sqlite 와 분기).
|
|
247
261
|
if (existing !== undefined) {
|
|
262
|
+
if (isolationSql !== undefined) {
|
|
263
|
+
throw new MegaInternalError(
|
|
264
|
+
'adapter.nested_isolation_unsupported',
|
|
265
|
+
`${this.constructor.name}.withTransaction() cannot set an isolation level on a nested (SAVEPOINT) transaction — isolation is fixed when the top-level transaction starts (ADR-190).`,
|
|
266
|
+
{ details: { adapter: this.constructor.name, isolation: opts?.isolation } },
|
|
267
|
+
)
|
|
268
|
+
}
|
|
248
269
|
return this.#runSavepoint(existing, fn)
|
|
249
270
|
}
|
|
250
271
|
// top-level — 풀에서 클라이언트 1개 획득 후 BEGIN/COMMIT/ROLLBACK.
|
|
@@ -252,6 +273,8 @@ export class MegaPostgresAdapter extends MegaDbAdapter {
|
|
|
252
273
|
const client = await pool.connect()
|
|
253
274
|
try {
|
|
254
275
|
await client.query('BEGIN')
|
|
276
|
+
// 격리수준은 트랜잭션 첫 쿼리 전에만 설정 가능 — BEGIN 직후 반영(화이트리스트 SQL 조각이라 안전).
|
|
277
|
+
if (isolationSql !== undefined) await client.query(`SET TRANSACTION ISOLATION LEVEL ${isolationSql}`)
|
|
255
278
|
let result
|
|
256
279
|
try {
|
|
257
280
|
result = await this.#txContext.run({ client, depth: 0 }, () => fn(client))
|
|
@@ -65,6 +65,11 @@ import * as Registry from './registry.js'
|
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
67
|
export class MegaSqliteAdapter extends MegaDbAdapter {
|
|
68
|
+
/** driver 식별자 — dialect 디스패치(getDialect)·CRUD 의 단일 출처(ADR-212). @returns {'sqlite'} */
|
|
69
|
+
get driver() {
|
|
70
|
+
return 'sqlite'
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
/** @type {import('better-sqlite3').Database | null} 연결된 Database 인스턴스 (connect 후에만). */
|
|
69
74
|
#db = null
|
|
70
75
|
/** @type {string} DB 파일 경로 또는 ':memory:'. */
|
|
@@ -237,14 +242,28 @@ export class MegaSqliteAdapter extends MegaDbAdapter {
|
|
|
237
242
|
* 성공 시 COMMIT 후 `fn` 반환값을 그대로 돌려주고, `fn` 이 throw 하면 ROLLBACK 후 원본 에러를
|
|
238
243
|
* re-throw 한다. nested / 동시 호출은 `db.inTransaction` 으로 감지해 거부(ADR-105).
|
|
239
244
|
*
|
|
245
|
+
* `opts.isolation`(ADR-190) — SQLite 트랜잭션은 항상 SERIALIZABLE 동작(단일 writer, 공식 문서)이라
|
|
246
|
+
* `'serializable'` 만 수용(no-op)하고 다른 값은 `adapter.invalid_option` 으로 명시 거부한다
|
|
247
|
+
* (조용히 무시하면 사용자가 다른 격리수준이 적용된 줄 오인 — P4).
|
|
248
|
+
*
|
|
240
249
|
* hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
|
|
241
250
|
*
|
|
242
251
|
* @template T
|
|
243
252
|
* @param {(db: import('better-sqlite3').Database) => Promise<T> | T} fn
|
|
253
|
+
* @param {{ isolation?: 'serializable' }} [opts] - 트랜잭션 옵션(ADR-190).
|
|
244
254
|
* @returns {Promise<T>}
|
|
245
255
|
* @throws {MegaInternalError} `adapter.nested_transaction_unsupported` - 이미 트랜잭션 진행 중.
|
|
256
|
+
* @throws {MegaValidationError} `adapter.invalid_option` - 'serializable' 외 isolation 값.
|
|
246
257
|
*/
|
|
247
|
-
async withTransaction(fn) {
|
|
258
|
+
async withTransaction(fn, opts) {
|
|
259
|
+
// 옵션 검증은 연결 상태와 무관한 설정 오류라 _instrument(상태 검증) 전에 fail-fast 한다.
|
|
260
|
+
if (opts?.isolation !== undefined && opts.isolation !== 'serializable') {
|
|
261
|
+
throw new MegaValidationError(
|
|
262
|
+
'adapter.invalid_option',
|
|
263
|
+
`sqlite transactions are always SERIALIZABLE — withTransaction "isolation" accepts only 'serializable'. Got ${JSON.stringify(opts.isolation)}.`,
|
|
264
|
+
{ details: { driver: 'sqlite', option: 'isolation', value: opts.isolation } },
|
|
265
|
+
)
|
|
266
|
+
}
|
|
248
267
|
return this._instrument('withTransaction', { table: undefined }, async () => {
|
|
249
268
|
const db = /** @type {import('better-sqlite3').Database} */ (this.#db)
|
|
250
269
|
if (db.inTransaction) {
|
package/src/cli/commands/new.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* @module cli/commands/new
|
|
16
16
|
*/
|
|
17
17
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
18
19
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
19
20
|
import { fileURLToPath } from 'node:url'
|
|
20
21
|
import { nameVariants } from '../template-engine.js'
|
|
@@ -76,10 +77,11 @@ function walk(dir, base, acc) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* 토큰 치환 — `sample-crud` 를 프로젝트명으로, package.json 의 모노레포 dep(`file:../..`)을 실제
|
|
80
|
+
* 토큰 치환 — `sample-crud` 를 프로젝트명으로, package.json 의 모노레포 dep(`file:../..`)을 실제 버전으로,
|
|
81
|
+
* `.env` 의 SESSION_SECRET 을 프로젝트별 신규 시크릿으로.
|
|
80
82
|
* @param {string} rel - 파일 상대경로.
|
|
81
83
|
* @param {string} content - 원본 텍스트.
|
|
82
|
-
* @param {{ name: string, version: string }} vars
|
|
84
|
+
* @param {{ name: string, version: string, sessionSecret: string }} vars
|
|
83
85
|
* @returns {string}
|
|
84
86
|
*/
|
|
85
87
|
function transformText(rel, content, vars) {
|
|
@@ -88,6 +90,12 @@ function transformText(rel, content, vars) {
|
|
|
88
90
|
// 모노레포 로컬 링크 → 퍼블리시된 프레임워크 버전 핀. 스캐폴드된 프로젝트는 독립 패키지라 file: 가 안 풀린다.
|
|
89
91
|
out = out.replace('"file:../.."', `"^${vars.version}"`)
|
|
90
92
|
}
|
|
93
|
+
if (rel === '.env') {
|
|
94
|
+
// 세션 쿠키 HMAC 시크릿은 데모 값을 복제하면 모든 `mega new` 프로젝트가 같은 서명 키를 공유한다
|
|
95
|
+
// (세션 위조·cross-app 쿠키 수용 위험) — 프로젝트마다 신규 생성으로 치환한다. `.env.example` 은
|
|
96
|
+
// placeholder("change-me-...") 그대로 둔다(문서 역할).
|
|
97
|
+
out = out.replace(/^SESSION_SECRET=.*$/m, `SESSION_SECRET=${vars.sessionSecret}`)
|
|
98
|
+
}
|
|
91
99
|
return out
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -104,7 +112,9 @@ export function scaffoldProject(targetDir, { name, force = false } = {}) {
|
|
|
104
112
|
const projectName = nameVariants(name ?? root.split(/[/\\]/).pop() ?? 'mega-app').kebab
|
|
105
113
|
/** @type {string} */
|
|
106
114
|
const version = JSON.parse(readFileSync(FRAMEWORK_PKG, 'utf8')).version
|
|
107
|
-
|
|
115
|
+
// 프로젝트별 세션 시크릿 — base64url(영숫자·-·_ 만)이라 .env 값·쉘 복붙에 안전(=/+// 회피).
|
|
116
|
+
const sessionSecret = randomBytes(32).toString('base64url')
|
|
117
|
+
const vars = { name: projectName, version, sessionSecret }
|
|
108
118
|
|
|
109
119
|
/** @type {string[]} */ const written = []
|
|
110
120
|
/** @type {string[]} */ const skipped = []
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/**
|
|
3
|
-
* scaffold/dev 명령군 (`new` / `generate`(g) / `routes` / `test` / `console`)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* scaffold/dev 명령군 (`new` / `generate`(g) / `routes` / `test` / `console`)의 commander 등록.
|
|
4
|
+
*
|
|
5
|
+
* ADR-142 가 commander 를 채택한 명령군이며, ADR-195(commander 전면 일원화) 이후 런타임 명령
|
|
6
|
+
* (start/worker/scheduler/migrate)과 **같은 program 트리**에 등록된다 — `registerScaffoldCommands` 를
|
|
7
|
+
* `cli/index.js` 의 `buildProgram` 이 호출한다. `runScaffoldCommand` 는 scaffold 명령군만 담은 독립
|
|
8
|
+
* program 을 돌리는 기존 진입점으로 유지한다(하위호환·단위 테스트 경계).
|
|
6
9
|
*
|
|
7
10
|
* @module cli/commands/scaffold
|
|
8
11
|
*/
|
|
9
12
|
import { Command } from 'commander'
|
|
10
|
-
import {
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import { generate, generateFromScaffoldDef, GENERATOR_KINDS } from '../generators/index.js'
|
|
11
17
|
import { scaffoldProject } from './new.js'
|
|
12
18
|
import { runRoutesCommand } from './routes.js'
|
|
13
19
|
import { runTestCommand } from './test-cmd.js'
|
|
14
20
|
import { startConsole } from './console-cmd.js'
|
|
15
21
|
|
|
16
|
-
/** scaffold/dev 명령 이름(별칭 포함).
|
|
22
|
+
/** scaffold/dev 명령 이름(별칭 포함). */
|
|
17
23
|
export const SCAFFOLD_COMMANDS = new Set(['new', 'generate', 'g', 'routes', 'test', 'console'])
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -26,27 +32,52 @@ function reportFiles(out, r, root) {
|
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
|
-
*
|
|
30
|
-
* (
|
|
31
|
-
*
|
|
35
|
+
* `g model --adapter <key>` 의 adapter 키/driver 해석 — mega.config.js 의 services.databases 를
|
|
36
|
+
* best-effort 로 읽는다(스캐폴드는 config 가 아직 없는 초기 프로젝트에서도 돌아야 하므로 로드
|
|
37
|
+
* 실패는 fail 이 아니라 기본값 + 경고 1줄). 키 미지정 시: 선언 db 가 1개면 그 키, 아니면 'primary'.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} projectRoot @param {string | undefined} explicitKey
|
|
40
|
+
* @param {(msg: string) => void} out
|
|
41
|
+
* @returns {Promise<{ key: string, driver: string | undefined }>}
|
|
42
|
+
*/
|
|
43
|
+
async function resolveModelAdapter(projectRoot, explicitKey, out) {
|
|
44
|
+
/** @type {Record<string, any>} */
|
|
45
|
+
let databases = {}
|
|
46
|
+
const configPath = join(projectRoot, 'mega.config.js')
|
|
47
|
+
if (existsSync(configPath)) {
|
|
48
|
+
try {
|
|
49
|
+
const mod = await import(pathToFileURL(configPath).href)
|
|
50
|
+
databases = mod.default?.services?.databases ?? {}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// config 문법 오류 등 — 스캐폴드는 계속 가능해야 하므로 기본 템플릿으로 폴백하되 명시 안내(P4).
|
|
53
|
+
out(`mega: mega.config.js 를 읽지 못해 adapter driver 를 해석하지 못했습니다(${/** @type {any} */ (err).message}) — SQL 템플릿으로 생성합니다.`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const keys = Object.keys(databases)
|
|
57
|
+
const key = explicitKey ?? (keys.length === 1 ? keys[0] : 'primary')
|
|
58
|
+
if (explicitKey !== undefined && keys.length > 0 && !keys.includes(explicitKey)) {
|
|
59
|
+
out(`mega: --adapter '${explicitKey}' 가 services.databases 에 없습니다(선언: [${keys.join(', ')}]) — 키를 그대로 쓰고 SQL 템플릿으로 생성합니다.`)
|
|
60
|
+
}
|
|
61
|
+
return { key, driver: databases[key]?.driver }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* scaffold/dev 명령 5종을 commander program 에 등록한다 — 명령 정의의 단일 정본(ADR-195).
|
|
66
|
+
* action 은 `process.exit` 를 부르지 않고 `setExit(code)` 로 종료 코드를 보고한다(runCli 계약).
|
|
67
|
+
*
|
|
68
|
+
* @param {import('commander').Command} program - 등록 대상 program.
|
|
32
69
|
* @param {object} deps
|
|
33
70
|
* @param {(msg: string) => void} deps.out
|
|
34
|
-
* @param {(msg: string) => void} deps.err
|
|
35
71
|
* @param {string} deps.projectRoot - 이미 `--root` 해석된 기준 디렉토리.
|
|
36
72
|
* @param {{ debug?: Function, info?: Function, warn?: Function }} [deps.logger]
|
|
37
|
-
* @
|
|
73
|
+
* @param {(kind: string) => Promise<{ dir: string, files: Array<{ path: string, template: string }> } | undefined>} [deps.resolvePluginGenerator] -
|
|
74
|
+
* 빌트인이 아닌 kind 의 플러그인 scaffold manifest 조회(`mega.scaffold.register`, 03-api-spec §11).
|
|
75
|
+
* 호출측(runCli)이 config 로드 + 플러그인 install 을 감싼 함수를 주입한다 — 본 모듈이 cli/index.js 를
|
|
76
|
+
* import 하면 순환이라 주입식으로 푼다. 미주입/미발견이면 unknown kind 에러.
|
|
77
|
+
* @param {(code: number) => void} deps.setExit - 명령 종료 코드 보고 콜백.
|
|
78
|
+
* @returns {void}
|
|
38
79
|
*/
|
|
39
|
-
export
|
|
40
|
-
let exitCode = 0
|
|
41
|
-
const program = new Command()
|
|
42
|
-
program.name('mega').exitOverride()
|
|
43
|
-
program.configureOutput({
|
|
44
|
-
writeOut: (s) => out(s.replace(/\n+$/, '')),
|
|
45
|
-
writeErr: (s) => err(s.replace(/\n+$/, '')),
|
|
46
|
-
})
|
|
47
|
-
// --root 는 runCli 가 이미 해석함 — commander 가 "unknown option" 으로 막지 않도록 받아만 둔다.
|
|
48
|
-
program.option('--root <dir>', '프로젝트 루트(runCli 가 해석)')
|
|
49
|
-
|
|
80
|
+
export function registerScaffoldCommands(program, { out, projectRoot, logger, resolvePluginGenerator, setExit }) {
|
|
50
81
|
program
|
|
51
82
|
.command('new <project>')
|
|
52
83
|
.description('sample/crud 데모앱(14기능) 전체를 빈 폴더에 스캐폴드')
|
|
@@ -64,24 +95,54 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
|
|
|
64
95
|
program
|
|
65
96
|
.command('generate <kind> <name>')
|
|
66
97
|
.alias('g')
|
|
67
|
-
.description(`코드+테스트 생성 (kind: ${GENERATOR_KINDS.join('|')})`)
|
|
98
|
+
.description(`코드+테스트 생성 (kind: ${GENERATOR_KINDS.join('|')} 또는 플러그인 등록 generator)`)
|
|
68
99
|
.option('--app <app>', '대상 앱', 'main')
|
|
69
100
|
.option('--version <v>', '컨트롤러 API 버전(예 v2, ADR-069)')
|
|
70
101
|
.option('--kind <adapterKind>', 'adapter 종류(db|cache|bus|session|log)')
|
|
102
|
+
.option('--adapter <key>', 'model 전용 — services.databases 키(driver 가 mongodb 면 mongo 템플릿, 기본: 유일 선언 db 또는 primary)')
|
|
71
103
|
.option('--lng <lng>', 'locale 언어(기본 en)')
|
|
72
104
|
.option('--force', '기존 파일 덮어쓰기')
|
|
73
|
-
.action((/** @type {string} */ kind, /** @type {string} */ name, /** @type {any} */ opts) => {
|
|
74
|
-
|
|
105
|
+
.action(async (/** @type {string} */ kind, /** @type {string} */ name, /** @type {any} */ opts) => {
|
|
106
|
+
/** @type {{ kind: string, name: string, written: string[], skipped: string[] }} */
|
|
107
|
+
let r
|
|
108
|
+
if (GENERATOR_KINDS.includes(/** @type {any} */ (kind))) {
|
|
109
|
+
/** @type {{ key: string, driver: string | undefined }} */
|
|
110
|
+
let modelAdapter = { key: 'primary', driver: undefined }
|
|
111
|
+
if (kind === 'model') modelAdapter = await resolveModelAdapter(projectRoot, opts.adapter, out)
|
|
112
|
+
r = generate(
|
|
113
|
+
kind,
|
|
114
|
+
name,
|
|
115
|
+
{ app: opts.app, version: opts.version, kind: opts.kind, lng: opts.lng, force: opts.force === true, adapter: modelAdapter.key, adapterDriver: modelAdapter.driver },
|
|
116
|
+
projectRoot,
|
|
117
|
+
)
|
|
118
|
+
} else {
|
|
119
|
+
// 빌트인이 아니면 플러그인 등록 generator(manifest) 조회(03-api-spec §11 `mega g <name>` 계약).
|
|
120
|
+
// config 로드 실패(프로젝트 밖 등)는 unknown kind 메시지에 원인을 병기해 오도 없이 보고한다.
|
|
121
|
+
/** @type {{ dir: string, files: Array<{ path: string, template: string }> } | undefined} */
|
|
122
|
+
let def
|
|
123
|
+
try {
|
|
124
|
+
def = await resolvePluginGenerator?.(kind)
|
|
125
|
+
} catch (e) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Unknown generator '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')}. ` +
|
|
128
|
+
`(플러그인 generator 확인 실패: ${/** @type {any} */ (e).message ?? e})`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (def === undefined) {
|
|
132
|
+
throw new Error(`Unknown generator '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')} (또는 플러그인 등록 generator).`)
|
|
133
|
+
}
|
|
134
|
+
r = generateFromScaffoldDef(kind, def, name, { app: opts.app, force: opts.force === true }, projectRoot)
|
|
135
|
+
}
|
|
75
136
|
out(`mega: generated ${r.kind} '${r.name}'`)
|
|
76
137
|
reportFiles(out, r, projectRoot)
|
|
77
|
-
if (r.written.length === 0)
|
|
138
|
+
if (r.written.length === 0) setExit(1)
|
|
78
139
|
})
|
|
79
140
|
|
|
80
141
|
program
|
|
81
142
|
.command('routes')
|
|
82
143
|
.description('등록된 라우트 트리 출력')
|
|
83
144
|
.action(async () => {
|
|
84
|
-
|
|
145
|
+
setExit(await runRoutesCommand(projectRoot, { out }))
|
|
85
146
|
})
|
|
86
147
|
|
|
87
148
|
program
|
|
@@ -90,7 +151,7 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
|
|
|
90
151
|
.allowUnknownOption()
|
|
91
152
|
.argument('[args...]', 'vitest 인자')
|
|
92
153
|
.action(async (/** @type {string[]} */ args) => {
|
|
93
|
-
|
|
154
|
+
setExit(await runTestCommand(projectRoot, args ?? [], { out }))
|
|
94
155
|
})
|
|
95
156
|
|
|
96
157
|
program
|
|
@@ -99,16 +160,59 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
|
|
|
99
160
|
.action(async () => {
|
|
100
161
|
await startConsole(projectRoot, { logger, out })
|
|
101
162
|
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* commander 의 exitOverride 예외를 exit code 로 환산한다 — help/version 출력은 정상 종료(0),
|
|
167
|
+
* CommanderError 는 자체 exitCode, 그 외 에러는 메시지 출력 후 1. commander 가 아닌 에러를 그대로
|
|
168
|
+
* 삼키지 않도록 `rethrowUnknown` 옵션을 둔다(runCli 가 부팅·config 에러를 bin 으로 전파할 때 사용).
|
|
169
|
+
*
|
|
170
|
+
* @param {unknown} e - parseAsync 가 던진 예외.
|
|
171
|
+
* @param {(msg: string) => void} err
|
|
172
|
+
* @param {{ rethrowUnknown?: boolean }} [opts] - true 면 CommanderError 가 아닌 예외를 재throw.
|
|
173
|
+
* @returns {number} exit code.
|
|
174
|
+
*/
|
|
175
|
+
export function commanderErrorToExitCode(e, err, { rethrowUnknown = false } = {}) {
|
|
176
|
+
const anyErr = /** @type {any} */ (e)
|
|
177
|
+
if (typeof anyErr?.code === 'string' && anyErr.code.startsWith('commander.')) {
|
|
178
|
+
// help/version 출력은 정상 종료(exitOverride 가 던지는 특수 코드).
|
|
179
|
+
if (anyErr.code === 'commander.helpDisplayed' || anyErr.code === 'commander.help' || anyErr.code === 'commander.version') return 0
|
|
180
|
+
return typeof anyErr.exitCode === 'number' ? anyErr.exitCode : 1
|
|
181
|
+
}
|
|
182
|
+
if (rethrowUnknown) throw e
|
|
183
|
+
err(`mega: ${anyErr?.message ?? e}`)
|
|
184
|
+
return 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* scaffold/dev 명령만 담은 독립 program 으로 실행한다(하위호환 진입점 — 단위 테스트 경계 유지).
|
|
189
|
+
* commander 로 파싱하되 `process.exit` 를 부르지 않고 exit code 를 반환한다(runCli 계약 정합).
|
|
190
|
+
*
|
|
191
|
+
* @param {string[]} argv - `process.argv.slice(2)` (첫 토큰 = 명령).
|
|
192
|
+
* @param {object} deps
|
|
193
|
+
* @param {(msg: string) => void} deps.out
|
|
194
|
+
* @param {(msg: string) => void} deps.err
|
|
195
|
+
* @param {string} deps.projectRoot - 이미 `--root` 해석된 기준 디렉토리.
|
|
196
|
+
* @param {{ debug?: Function, info?: Function, warn?: Function }} [deps.logger]
|
|
197
|
+
* @param {(kind: string) => Promise<{ dir: string, files: Array<{ path: string, template: string }> } | undefined>} [deps.resolvePluginGenerator]
|
|
198
|
+
* @returns {Promise<number>} exit code.
|
|
199
|
+
*/
|
|
200
|
+
export async function runScaffoldCommand(argv, { out, err, projectRoot, logger, resolvePluginGenerator }) {
|
|
201
|
+
let exitCode = 0
|
|
202
|
+
const program = new Command()
|
|
203
|
+
program.name('mega').exitOverride()
|
|
204
|
+
program.configureOutput({
|
|
205
|
+
writeOut: (s) => out(s.replace(/\n+$/, '')),
|
|
206
|
+
writeErr: (s) => err(s.replace(/\n+$/, '')),
|
|
207
|
+
})
|
|
208
|
+
// --root 는 호출측(runCli)이 이미 해석함 — commander 가 "unknown option" 으로 막지 않도록 받아만 둔다.
|
|
209
|
+
program.option('--root <dir>', '프로젝트 루트(호출측이 해석)')
|
|
210
|
+
registerScaffoldCommands(program, { out, projectRoot, logger, resolvePluginGenerator, setExit: (c) => (exitCode = c) })
|
|
102
211
|
|
|
103
212
|
try {
|
|
104
213
|
await program.parseAsync(argv, { from: 'user' })
|
|
105
214
|
return exitCode
|
|
106
215
|
} catch (e) {
|
|
107
|
-
|
|
108
|
-
const code = /** @type {any} */ (e).exitCode
|
|
109
|
-
if (/** @type {any} */ (e).code === 'commander.helpDisplayed' || /** @type {any} */ (e).code === 'commander.help') return 0
|
|
110
|
-
if (typeof code === 'number') return code
|
|
111
|
-
err(`mega: ${/** @type {any} */ (e).message ?? e}`)
|
|
112
|
-
return 1
|
|
216
|
+
return commanderErrorToExitCode(e, err)
|
|
113
217
|
}
|
|
114
218
|
}
|
|
@@ -38,6 +38,20 @@ export const GENERATOR_KINDS = /** @type {const} */ ([
|
|
|
38
38
|
'migration',
|
|
39
39
|
])
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 플러그인 scaffold manifest 의 토큰 계약(ADR-199) — `files[].path`/`files[].template` 의 `{{token}}`
|
|
43
|
+
* 에 쓸 수 있는 이름과 의미. 빌트인 generator 의 base 토큰과 동일 집합이라 템플릿 작성 관례가 하나다.
|
|
44
|
+
* 미정의 토큰은 renderTemplate 가 throw 한다(P4 — silent 치환 누락 방지).
|
|
45
|
+
* @type {Readonly<Record<string, string>>}
|
|
46
|
+
*/
|
|
47
|
+
export const SCAFFOLD_TOKENS = Object.freeze({
|
|
48
|
+
Name: 'PascalCase 이름 (예: userCard → UserCard)',
|
|
49
|
+
name: 'kebab-case 이름 (user-card)',
|
|
50
|
+
camelName: 'camelCase 이름 (userCard)',
|
|
51
|
+
snake: 'snake_case 이름 (user_card)',
|
|
52
|
+
app: '대상 앱 이름 (--app, 기본 main)',
|
|
53
|
+
})
|
|
54
|
+
|
|
41
55
|
/** adapter `--kind` → 베이스 클래스(mega-framework export 명). */
|
|
42
56
|
const ADAPTER_BASES = /** @type {Record<string, string>} */ ({
|
|
43
57
|
db: 'MegaDbAdapter',
|
|
@@ -121,8 +135,19 @@ export function planArtifacts(kind, rawName, opts, projectRoot) {
|
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
switch (kind) {
|
|
124
|
-
case 'model':
|
|
125
|
-
|
|
138
|
+
case 'model': {
|
|
139
|
+
// --adapter <key>: services.databases 키(static adapter 값). driver 가 mongodb 로 해석되면
|
|
140
|
+
// mongo 변형 템플릿(_id 자동·도큐먼트 API — ADR-209)을 쓴다. 해석은 scaffold 명령이
|
|
141
|
+
// best-effort 로 수행해 opts.adapterDriver 로 전달한다(config 부재 시 SQL 템플릿 기본).
|
|
142
|
+
const adapter = typeof opts.adapter === 'string' && opts.adapter.length > 0 ? opts.adapter : 'primary'
|
|
143
|
+
const isMongo = opts.adapterDriver === 'mongodb'
|
|
144
|
+
return pair({
|
|
145
|
+
codeRel: `apps/${app}/models/${v.kebab}.js`,
|
|
146
|
+
vars: { table: v.snake, adapter },
|
|
147
|
+
codeTpl: isMongo ? 'code-mongo.tpl' : 'code.tpl',
|
|
148
|
+
testTpl: isMongo ? 'test-mongo.tpl' : 'test.tpl',
|
|
149
|
+
})
|
|
150
|
+
}
|
|
126
151
|
|
|
127
152
|
case 'service':
|
|
128
153
|
return pair({ codeRel: `apps/${app}/services/${v.kebab}-service.js`, vars: {} })
|
|
@@ -347,6 +372,61 @@ export function writeArtifacts(artifacts, { force = false } = {}) {
|
|
|
347
372
|
return { written, skipped }
|
|
348
373
|
}
|
|
349
374
|
|
|
375
|
+
/**
|
|
376
|
+
* 플러그인 scaffold manifest(`mega.scaffold.register(name, { dir, files, description? })`,
|
|
377
|
+
* 03-api-spec §11 / ADR-199)를 artifact 목록으로 계획한다 — 빌트인 13종과 같은 plan→write 2단 분리.
|
|
378
|
+
* 토큰 계약은 {@link SCAFFOLD_TOKENS} 가 정본이며 미정의 토큰은 renderTemplate 가 throw(P4).
|
|
379
|
+
* `files[].path` 에도 토큰을 쓸 수 있다(예 `services/{{name}}-service.js`).
|
|
380
|
+
*
|
|
381
|
+
* @param {{ dir: string, files: Array<{ path: string, template: string }> }} def - 플러그인 등록 정의.
|
|
382
|
+
* @param {string} rawName - `mega g <kind> <name>` 의 name.
|
|
383
|
+
* @param {Record<string, any>} opts - { app }.
|
|
384
|
+
* @param {string} projectRoot - 출력 기준 루트. `def.dir` 는 이 루트 상대.
|
|
385
|
+
* @returns {Artifact[]}
|
|
386
|
+
* @throws {Error} def 파일 항목이 `{ path, template }` 문자열 쌍이 아니거나, 출력 경로가 projectRoot 를
|
|
387
|
+
* 벗어나면(경로 탐색 차단 — template.js resolveViewPath 정합) fail-fast.
|
|
388
|
+
*/
|
|
389
|
+
export function planScaffoldDef(def, rawName, opts, projectRoot) {
|
|
390
|
+
const app = typeof opts.app === 'string' && opts.app.length > 0 ? opts.app : 'main'
|
|
391
|
+
const v = nameVariants(rawName)
|
|
392
|
+
const vars = { Name: v.pascal, name: v.kebab, camelName: v.camel, snake: v.snake, app }
|
|
393
|
+
const rootAbs = resolve(projectRoot)
|
|
394
|
+
const baseDir = resolve(rootAbs, def.dir)
|
|
395
|
+
/** @type {Artifact[]} */
|
|
396
|
+
const out = []
|
|
397
|
+
for (const file of def.files) {
|
|
398
|
+
if (!file || typeof file.path !== 'string' || file.path.length === 0 || typeof file.template !== 'string') {
|
|
399
|
+
throw new Error(`scaffold def: files entries must be { path: string, template: string }. Got ${JSON.stringify(file)}.`)
|
|
400
|
+
}
|
|
401
|
+
const outAbs = resolve(baseDir, renderTemplate(file.path, vars))
|
|
402
|
+
// 출력은 projectRoot 내부로 제한 — def.dir/path 의 `..` 가 프로젝트 밖에 쓰는 걸 차단.
|
|
403
|
+
if (outAbs !== rootAbs && !outAbs.startsWith(rootAbs + sep)) {
|
|
404
|
+
throw new Error(`scaffold def: output '${file.path}' escapes the project root (path traversal blocked).`)
|
|
405
|
+
}
|
|
406
|
+
out.push({ outAbs, role: 'code', content: renderTemplate(file.template, vars) })
|
|
407
|
+
}
|
|
408
|
+
return out
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 플러그인 등록 scaffold generator 실행 — `mega g <plugin-generator> <name>` 의 본체. 빌트인 `generate`
|
|
413
|
+
* 와 같은 계획 → 쓰기 → 결과 계약(존재 파일 skip, `--force` 덮어쓰기).
|
|
414
|
+
* @param {string} kindName - 플러그인이 등록한 generator 이름(결과 보고용).
|
|
415
|
+
* @param {{ dir: string, files: Array<{ path: string, template: string }> }} def
|
|
416
|
+
* @param {string} rawName
|
|
417
|
+
* @param {object} [opts] - { app, force }
|
|
418
|
+
* @param {string} [projectRoot]
|
|
419
|
+
* @returns {{ kind: string, name: string, written: string[], skipped: string[] }}
|
|
420
|
+
*/
|
|
421
|
+
export function generateFromScaffoldDef(kindName, def, rawName, opts = {}, projectRoot = process.cwd()) {
|
|
422
|
+
if (typeof rawName !== 'string' || rawName.trim().length === 0) {
|
|
423
|
+
throw new Error(`mega g ${kindName}: a name is required (e.g. 'mega g ${kindName} users').`)
|
|
424
|
+
}
|
|
425
|
+
const artifacts = planScaffoldDef(def, rawName, /** @type {any} */ (opts), projectRoot)
|
|
426
|
+
const { written, skipped } = writeArtifacts(artifacts, { force: /** @type {any} */ (opts).force === true })
|
|
427
|
+
return { kind: kindName, name: rawName, written, skipped }
|
|
428
|
+
}
|
|
429
|
+
|
|
350
430
|
/**
|
|
351
431
|
* `mega g <kind> <name>` 실행 — 계획 → 쓰기 → 결과 반환.
|
|
352
432
|
* @param {string} kind
|