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,483 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaModel 공통 CRUD 본문 (ADR-212) — schema 기반 bounded CRUD + driver 자동 dialect 디스패치.
|
|
4
|
+
*
|
|
5
|
+
* MegaModel 의 static CRUD 메서드가 여기로 위임한다(`this`=Model 클래스). 각 함수는:
|
|
6
|
+
* 1) `getModelMeta(Model)` 로 컬럼 화이트리스트·PK(메모이즈, schema-builder 재사용)
|
|
7
|
+
* 2) `getCrudDialect(Model)` 로 driver→dialect(DML facet) 해석
|
|
8
|
+
* 3) `crud-sql-builder` 순수 함수로 SQL+params 빌드
|
|
9
|
+
* 4) **`Model.query(sql, params)`** 실행 — 어댑터의 계측(ADR-138)·트랜잭션(ALS) 경로를 그대로 탐
|
|
10
|
+
* 5) `dialect.parseRead/WriteResult` 로 native 형태(pg/maria/sqlite)를 **정규화 계약**으로 흡수
|
|
11
|
+
*
|
|
12
|
+
* raw 트랙(`this.query`/`this.db`)·어댑터 native 형태는 무변경(ADR-009 보존). `static schema` 없는 모델은
|
|
13
|
+
* CRUD 호출 시 `model.crud_requires_schema` throw(호출 안 하면 영향 0).
|
|
14
|
+
*
|
|
15
|
+
* @module models/model-crud
|
|
16
|
+
*/
|
|
17
|
+
import { MegaInternalError } from '../errors/http-errors.js'
|
|
18
|
+
import { buildModelRecord } from '../core/migration/schema-builder.js'
|
|
19
|
+
import { getDialect, dmlFacetMissing } from '../core/migration/dialect-registry.js'
|
|
20
|
+
import {
|
|
21
|
+
buildWhere,
|
|
22
|
+
buildOrderBy,
|
|
23
|
+
buildSelectList,
|
|
24
|
+
assertColumn,
|
|
25
|
+
assertNonNegInt,
|
|
26
|
+
} from './crud-sql-builder.js'
|
|
27
|
+
// mongo(document) 경로 — driver=mongodb 이면 각 메서드가 여기로 디스패치(ADR-212 P3). 순환 import 는
|
|
28
|
+
// getModelMeta 가 hoisted 함수 export 라 런타임 호출 시점엔 양쪽 모듈이 완전 로드돼 안전하다.
|
|
29
|
+
import * as mongoCrud from './mongo-crud.js'
|
|
30
|
+
|
|
31
|
+
/** driver 가 mongo(document)인지 — 그렇다면 SQL 경로 대신 mongo-crud 로 디스패치. @param {any} Model @returns {boolean} */
|
|
32
|
+
function isMongo(Model) {
|
|
33
|
+
return Model._resolveAdapter().driver === 'mongodb'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @typedef {{ record: any, cols: Set<string>, pk: string | null, pkCols: string[], uniques: Set<string>, table: string }} ModelMeta */
|
|
37
|
+
|
|
38
|
+
/** Model 클래스 → 메타(메모이즈). WeakMap 이라 클래스 GC 시 함께 정리. @type {WeakMap<Function, ModelMeta>} */
|
|
39
|
+
const META = new WeakMap()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 모델의 schema record(컬럼·PK·unique)를 1회 평가·메모이즈한다(ADR-204 `buildModelRecord` 재사용).
|
|
43
|
+
* `static schema` 없으면 `model.crud_requires_schema`.
|
|
44
|
+
* @param {any} Model @returns {ModelMeta}
|
|
45
|
+
*/
|
|
46
|
+
export function getModelMeta(Model) {
|
|
47
|
+
const cached = META.get(Model)
|
|
48
|
+
if (cached) return cached
|
|
49
|
+
if (typeof Model.schema !== 'function') {
|
|
50
|
+
throw new MegaInternalError(
|
|
51
|
+
'model.crud_requires_schema',
|
|
52
|
+
`${Model.name}: 공통 CRUD 는 'static schema' 선언이 필요합니다(ADR-212). raw SQL 은 this.query 로 그대로 쓸 수 있습니다.`,
|
|
53
|
+
{ details: { model: Model.name } },
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
const record = buildModelRecord(Model) // throw 시 schema 검증 에러 그대로 전파(ADR-204)
|
|
57
|
+
const cols = new Set(Object.keys(record.columns ?? {}))
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const pkCols =
|
|
60
|
+
Array.isArray(record.primaryKey) && record.primaryKey.length > 0
|
|
61
|
+
? record.primaryKey
|
|
62
|
+
: Object.entries(record.columns ?? {})
|
|
63
|
+
.filter(([, d]) => /** @type {any} */ (d)?.primary)
|
|
64
|
+
.map(([n]) => n)
|
|
65
|
+
const uniques = new Set(
|
|
66
|
+
Object.entries(record.columns ?? {})
|
|
67
|
+
.filter(([, d]) => /** @type {any} */ (d)?.unique)
|
|
68
|
+
.map(([n]) => n),
|
|
69
|
+
)
|
|
70
|
+
/** @type {ModelMeta} */
|
|
71
|
+
const meta = {
|
|
72
|
+
record,
|
|
73
|
+
cols,
|
|
74
|
+
pk: pkCols.length === 1 ? pkCols[0] : null,
|
|
75
|
+
pkCols,
|
|
76
|
+
uniques,
|
|
77
|
+
table: record.table,
|
|
78
|
+
}
|
|
79
|
+
META.set(Model, meta)
|
|
80
|
+
return meta
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Model 의 어댑터 driver 로 SQL dialect(DML facet)를 해석한다. mongo·DML facet 미구현 driver 는 throw.
|
|
85
|
+
* @param {any} Model @returns {{ dialect: any, driver: string }}
|
|
86
|
+
*/
|
|
87
|
+
export function getCrudDialect(Model) {
|
|
88
|
+
const driver = Model._resolveAdapter().driver
|
|
89
|
+
if (driver === 'mongodb') {
|
|
90
|
+
throw new MegaInternalError(
|
|
91
|
+
'model.crud_unsupported_driver',
|
|
92
|
+
`${Model.name}: SQL 공통 CRUD 는 mongo(document) 미지원입니다(P3). raw document API(this.db) 를 쓰세요.`,
|
|
93
|
+
{ details: { model: Model.name, driver } },
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
const dialect = getDialect(driver)
|
|
97
|
+
const missing = dmlFacetMissing(dialect)
|
|
98
|
+
if (missing.length > 0) {
|
|
99
|
+
throw new MegaInternalError(
|
|
100
|
+
'model.dml_dialect_unsupported',
|
|
101
|
+
`driver '${driver}' 는 CRUD DML facet 미구현 — 누락: [${missing.join(', ')}].`,
|
|
102
|
+
{
|
|
103
|
+
details: { model: Model.name, driver, missing },
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return { dialect, driver }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 테이블 인용 단축. @param {any} dialect @param {ModelMeta} meta @returns {string} */
|
|
111
|
+
function tbl(dialect, meta) {
|
|
112
|
+
return dialect.quoteIdent(meta.table)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── 읽기 ───────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/** @param {any} Model @param {Record<string, any>} filter @param {{ select?: string[] }} [opts] @returns {Promise<any|null>} */
|
|
118
|
+
export async function findOne(Model, filter, opts = {}) {
|
|
119
|
+
if (isMongo(Model)) return mongoCrud.findOne(Model, filter, opts)
|
|
120
|
+
const { dialect } = getCrudDialect(Model)
|
|
121
|
+
const meta = getModelMeta(Model)
|
|
122
|
+
const sel = buildSelectList(opts.select, meta.cols, dialect, Model.name)
|
|
123
|
+
const { clause, params } = buildWhere(filter ?? {}, meta.cols, dialect, Model.name, 1)
|
|
124
|
+
const where = clause ? ` WHERE ${clause}` : ''
|
|
125
|
+
const rows = dialect.parseReadResult(
|
|
126
|
+
await Model.query(`SELECT ${sel} FROM ${tbl(dialect, meta)}${where} LIMIT 1`, params),
|
|
127
|
+
)
|
|
128
|
+
return rows[0] ?? null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {any} Model @param {Record<string, any>} filter @param {{ select?: string[], orderBy?: any, limit?: number, offset?: number }} [opts] @returns {Promise<any[]>} */
|
|
132
|
+
export async function findMany(Model, filter, opts = {}) {
|
|
133
|
+
if (isMongo(Model)) return mongoCrud.findMany(Model, filter, opts)
|
|
134
|
+
const { dialect } = getCrudDialect(Model)
|
|
135
|
+
const meta = getModelMeta(Model)
|
|
136
|
+
const sel = buildSelectList(opts.select, meta.cols, dialect, Model.name)
|
|
137
|
+
const { clause, params } = buildWhere(filter ?? {}, meta.cols, dialect, Model.name, 1)
|
|
138
|
+
const where = clause ? ` WHERE ${clause}` : ''
|
|
139
|
+
const order = buildOrderBy(opts.orderBy, meta.cols, dialect, Model.name)
|
|
140
|
+
// 기본 limit 없음(ADR-212 #2) — 명시할 때만 부착(검증된 정수 리터럴).
|
|
141
|
+
const limit =
|
|
142
|
+
opts.limit !== undefined ? ` LIMIT ${assertNonNegInt(opts.limit, 'limit', Model.name)}` : ''
|
|
143
|
+
const offset =
|
|
144
|
+
opts.offset !== undefined ? ` OFFSET ${assertNonNegInt(opts.offset, 'offset', Model.name)}` : ''
|
|
145
|
+
return dialect.parseReadResult(
|
|
146
|
+
await Model.query(
|
|
147
|
+
`SELECT ${sel} FROM ${tbl(dialect, meta)}${where}${order}${limit}${offset}`,
|
|
148
|
+
params,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {any} Model @param {any} id @param {{ select?: string[] }} [opts] @returns {Promise<any|null>} */
|
|
154
|
+
export async function findById(Model, id, opts = {}) {
|
|
155
|
+
if (isMongo(Model)) return mongoCrud.findById(Model, id, opts)
|
|
156
|
+
const meta = getModelMeta(Model)
|
|
157
|
+
if (!meta.pk) {
|
|
158
|
+
throw new MegaInternalError(
|
|
159
|
+
'model.no_primary_key',
|
|
160
|
+
`${Model.name}: findById 는 단일 PK 가 필요합니다(현재 PK: [${meta.pkCols.join(', ') || '없음'}]).`,
|
|
161
|
+
{
|
|
162
|
+
details: { model: Model.name, pk: meta.pkCols },
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
return findOne(Model, { [meta.pk]: id }, opts)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {any} Model @param {Record<string, any>} [filter] @returns {Promise<number>} */
|
|
170
|
+
export async function count(Model, filter = {}) {
|
|
171
|
+
if (isMongo(Model)) return mongoCrud.count(Model, filter)
|
|
172
|
+
const { dialect } = getCrudDialect(Model)
|
|
173
|
+
const meta = getModelMeta(Model)
|
|
174
|
+
const { clause, params } = buildWhere(filter, meta.cols, dialect, Model.name, 1)
|
|
175
|
+
const where = clause ? ` WHERE ${clause}` : ''
|
|
176
|
+
const rows = dialect.parseReadResult(
|
|
177
|
+
await Model.query(`SELECT count(*) AS n FROM ${tbl(dialect, meta)}${where}`, params),
|
|
178
|
+
)
|
|
179
|
+
return Number(rows[0]?.n ?? 0)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {any} Model @param {Record<string, any>} filter @returns {Promise<boolean>} */
|
|
183
|
+
export async function exists(Model, filter) {
|
|
184
|
+
if (isMongo(Model)) return mongoCrud.exists(Model, filter)
|
|
185
|
+
const { dialect } = getCrudDialect(Model)
|
|
186
|
+
const meta = getModelMeta(Model)
|
|
187
|
+
const { clause, params } = buildWhere(filter ?? {}, meta.cols, dialect, Model.name, 1)
|
|
188
|
+
const where = clause ? ` WHERE ${clause}` : ''
|
|
189
|
+
const rows = dialect.parseReadResult(
|
|
190
|
+
await Model.query(`SELECT 1 AS x FROM ${tbl(dialect, meta)}${where} LIMIT 1`, params),
|
|
191
|
+
)
|
|
192
|
+
return rows.length > 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @param {any} Model @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 }>} */
|
|
196
|
+
export async function paginate(Model, filter, opts) {
|
|
197
|
+
if (isMongo(Model)) return mongoCrud.paginate(Model, filter, opts)
|
|
198
|
+
const limit = assertNonNegInt(opts?.limit, 'limit', Model.name)
|
|
199
|
+
const offset = opts?.offset !== undefined ? assertNonNegInt(opts.offset, 'offset', Model.name) : 0
|
|
200
|
+
const rows = await findMany(Model, filter, {
|
|
201
|
+
select: opts?.select,
|
|
202
|
+
orderBy: opts?.orderBy,
|
|
203
|
+
limit,
|
|
204
|
+
offset,
|
|
205
|
+
})
|
|
206
|
+
/** @type {{ rows: any[], limit: number, offset: number, total?: number }} */
|
|
207
|
+
const out = { rows, limit, offset }
|
|
208
|
+
if (opts?.withTotal === true) out.total = await count(Model, filter ?? {}) // 추가 count 쿼리(opt-in #4)
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── 쓰기 ───────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** insert 컬럼 검증 + placeholder/params 빌드. @param {any} Model @param {Record<string, any>} data @param {any} dialect @param {ModelMeta} meta @returns {{ colSql: string, phs: string[], params: any[], cols: string[] }} */
|
|
215
|
+
function prepInsert(Model, data, dialect, meta) {
|
|
216
|
+
const cols = Object.keys(data ?? {})
|
|
217
|
+
if (cols.length === 0) {
|
|
218
|
+
throw new MegaInternalError('model.empty_insert', `${Model.name}: 삽입할 컬럼이 없습니다.`, {
|
|
219
|
+
details: { model: Model.name },
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
cols.forEach((c) => assertColumn(c, meta.cols, Model.name, 'data'))
|
|
223
|
+
let i = 1
|
|
224
|
+
const phs = cols.map(() => dialect.placeholder(i++))
|
|
225
|
+
return {
|
|
226
|
+
colSql: cols.map((c) => dialect.quoteIdent(c)).join(', '),
|
|
227
|
+
phs,
|
|
228
|
+
params: cols.map((c) => data[c]),
|
|
229
|
+
cols,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @param {any} Model @param {Record<string, any>} data @param {{ returning?: boolean }} [opts] @returns {Promise<any>} */
|
|
234
|
+
export async function insertOne(Model, data, opts = {}) {
|
|
235
|
+
if (isMongo(Model)) return mongoCrud.insertOne(Model, data, opts)
|
|
236
|
+
const { dialect } = getCrudDialect(Model)
|
|
237
|
+
const meta = getModelMeta(Model)
|
|
238
|
+
const { colSql, phs, params } = prepInsert(Model, data, dialect, meta)
|
|
239
|
+
const head = `INSERT INTO ${tbl(dialect, meta)} (${colSql}) VALUES (${phs.join(', ')})`
|
|
240
|
+
|
|
241
|
+
if (opts.returning === true) {
|
|
242
|
+
if (dialect.supportsReturning) {
|
|
243
|
+
const rows = dialect.parseReadResult(await Model.query(`${head} RETURNING *`, params))
|
|
244
|
+
return rows[0]
|
|
245
|
+
}
|
|
246
|
+
// maria: insert 후 PK 로 재조회
|
|
247
|
+
if (!meta.pk) {
|
|
248
|
+
throw new MegaInternalError(
|
|
249
|
+
'model.no_primary_key',
|
|
250
|
+
`${Model.name}: RETURNING 미지원 driver 에서 returning:true 레코드 반환에는 단일 PK 가 필요합니다(insert 후 PK 재조회).`,
|
|
251
|
+
{
|
|
252
|
+
details: { model: Model.name },
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
const w = dialect.parseWriteResult(await Model.query(head, params))
|
|
257
|
+
return findById(Model, data[meta.pk] ?? w.insertId)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 기본(returning:false) — 새 id 반환(성능 우선, ADR-212 #1).
|
|
261
|
+
if (!meta.pk) return dialect.parseWriteResult(await Model.query(head, params)).count // PK 없으면 affected count
|
|
262
|
+
if (dialect.supportsReturning) {
|
|
263
|
+
const rows = dialect.parseReadResult(
|
|
264
|
+
await Model.query(`${head} RETURNING ${dialect.quoteIdent(meta.pk)}`, params),
|
|
265
|
+
)
|
|
266
|
+
return rows[0]?.[meta.pk]
|
|
267
|
+
}
|
|
268
|
+
const w = dialect.parseWriteResult(await Model.query(head, params))
|
|
269
|
+
return data[meta.pk] ?? w.insertId
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** @param {any} Model @param {Record<string, any>[]} rows @param {{ returning?: boolean }} [opts] @returns {Promise<{ count: number } | any[]>} */
|
|
273
|
+
export async function insertMany(Model, rows, opts = {}) {
|
|
274
|
+
if (isMongo(Model)) return mongoCrud.insertMany(Model, rows, opts)
|
|
275
|
+
if (!Array.isArray(rows) || rows.length === 0) return opts.returning === true ? [] : { count: 0 }
|
|
276
|
+
const { dialect } = getCrudDialect(Model)
|
|
277
|
+
const meta = getModelMeta(Model)
|
|
278
|
+
// 모든 행이 같은 컬럼 집합이어야 한다(멀티-VALUES 정합).
|
|
279
|
+
const cols = Object.keys(rows[0] ?? {})
|
|
280
|
+
if (cols.length === 0)
|
|
281
|
+
throw new MegaInternalError('model.empty_insert', `${Model.name}: 삽입할 컬럼이 없습니다.`, {
|
|
282
|
+
details: { model: Model.name },
|
|
283
|
+
})
|
|
284
|
+
cols.forEach((c) => assertColumn(c, meta.cols, Model.name, 'data'))
|
|
285
|
+
const colKey = cols.slice().sort().join(',')
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
if (Object.keys(r).slice().sort().join(',') !== colKey) {
|
|
288
|
+
throw new MegaInternalError(
|
|
289
|
+
'model.insert_shape_mismatch',
|
|
290
|
+
`${Model.name}: insertMany 의 모든 행은 동일 컬럼 집합이어야 합니다.`,
|
|
291
|
+
{ details: { model: Model.name } },
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// VALUES 튜플: 행마다 컬럼 수만큼 placeholder, params 는 행→컬럼 순서로 평탄화.
|
|
296
|
+
let i = 1
|
|
297
|
+
const tuples = rows.map(() => `(${cols.map(() => dialect.placeholder(i++)).join(', ')})`)
|
|
298
|
+
/** @type {any[]} */
|
|
299
|
+
const params = []
|
|
300
|
+
for (const r of rows) for (const c of cols) params.push(r[c])
|
|
301
|
+
const colSql = cols.map((c) => dialect.quoteIdent(c)).join(', ')
|
|
302
|
+
const head = `INSERT INTO ${tbl(dialect, meta)} (${colSql}) VALUES ${tuples.join(', ')}`
|
|
303
|
+
|
|
304
|
+
if (opts.returning === true) {
|
|
305
|
+
if (!dialect.supportsBulkReturning) {
|
|
306
|
+
throw new MegaInternalError(
|
|
307
|
+
'model.bulk_returning_unsupported',
|
|
308
|
+
`${Model.name}: 이 driver 는 insertMany returning 을 지원하지 않습니다(maria). returning:false(count) 또는 per-row insertOne 을 쓰세요.`,
|
|
309
|
+
{ details: { model: Model.name } },
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
return dialect.parseReadResult(await Model.query(`${head} RETURNING *`, params))
|
|
313
|
+
}
|
|
314
|
+
return { count: dialect.parseWriteResult(await Model.query(head, params)).count }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** UPDATE SQL+params 빌드. allowEmpty=false 면 빈 filter throw(unbounded). @param {any} Model @param {Record<string, any>} filter @param {Record<string, any>} patch @param {any} dialect @param {ModelMeta} meta @param {boolean} allowEmpty @returns {{ sql: string, params: any[] }} */
|
|
318
|
+
function buildUpdate(Model, filter, patch, dialect, meta, allowEmpty) {
|
|
319
|
+
const setCols = Object.keys(patch ?? {})
|
|
320
|
+
if (setCols.length === 0)
|
|
321
|
+
throw new MegaInternalError(
|
|
322
|
+
'model.empty_patch',
|
|
323
|
+
`${Model.name}: 갱신할 컬럼(patch)이 없습니다.`,
|
|
324
|
+
{ details: { model: Model.name } },
|
|
325
|
+
)
|
|
326
|
+
setCols.forEach((c) => assertColumn(c, meta.cols, Model.name, 'patch'))
|
|
327
|
+
let i = 1
|
|
328
|
+
const setSql = setCols
|
|
329
|
+
.map((c) => `${dialect.quoteIdent(c)} = ${dialect.placeholder(i++)}`)
|
|
330
|
+
.join(', ')
|
|
331
|
+
const setParams = setCols.map((c) => patch[c])
|
|
332
|
+
const filterKeys = Object.keys(filter ?? {})
|
|
333
|
+
if (filterKeys.length === 0 && !allowEmpty) {
|
|
334
|
+
throw new MegaInternalError(
|
|
335
|
+
'model.unbounded_write',
|
|
336
|
+
`${Model.name}: 빈 filter 의 전체 UPDATE 는 막혀 있습니다. 의도면 { all: true } 또는 raw SQL 을 쓰세요.`,
|
|
337
|
+
{ details: { model: Model.name } },
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
const { clause, params: whereParams } = buildWhere(
|
|
341
|
+
filter ?? {},
|
|
342
|
+
meta.cols,
|
|
343
|
+
dialect,
|
|
344
|
+
Model.name,
|
|
345
|
+
i,
|
|
346
|
+
)
|
|
347
|
+
const where = clause ? ` WHERE ${clause}` : ''
|
|
348
|
+
return {
|
|
349
|
+
sql: `UPDATE ${tbl(dialect, meta)} SET ${setSql}${where}`,
|
|
350
|
+
params: [...setParams, ...whereParams],
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @param {any} Model @param {Record<string, any>} filter @param {Record<string, any>} patch @returns {Promise<number>} */
|
|
355
|
+
export async function updateOne(Model, filter, patch) {
|
|
356
|
+
if (isMongo(Model)) return mongoCrud.updateOne(Model, filter, patch)
|
|
357
|
+
const { dialect } = getCrudDialect(Model)
|
|
358
|
+
const meta = getModelMeta(Model)
|
|
359
|
+
if (Object.keys(filter ?? {}).length === 0) {
|
|
360
|
+
throw new MegaInternalError(
|
|
361
|
+
'model.invalid_filter',
|
|
362
|
+
`${Model.name}: updateOne 은 대상을 특정하는 filter 가 필요합니다(빈 filter 금지).`,
|
|
363
|
+
{ details: { model: Model.name } },
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
const { sql, params } = buildUpdate(Model, filter, patch, dialect, meta, false)
|
|
367
|
+
// 원자적 "정확히 하나"(ADR-212 #3) — implicit tx 로 감싸 >1 매칭 시 롤백.
|
|
368
|
+
return Model.withTransaction(async () => {
|
|
369
|
+
const n = dialect.parseWriteResult(await Model.query(sql, params)).count
|
|
370
|
+
if (n > 1) throw multipleMatches(Model, 'updateOne', n)
|
|
371
|
+
return n
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @param {any} Model @param {Record<string, any>} filter @param {Record<string, any>} patch @param {{ all?: boolean }} [opts] @returns {Promise<number>} */
|
|
376
|
+
export async function updateMany(Model, filter, patch, opts = {}) {
|
|
377
|
+
if (isMongo(Model)) return mongoCrud.updateMany(Model, filter, patch, opts)
|
|
378
|
+
const { dialect } = getCrudDialect(Model)
|
|
379
|
+
const meta = getModelMeta(Model)
|
|
380
|
+
const { sql, params } = buildUpdate(Model, filter, patch, dialect, meta, opts.all === true)
|
|
381
|
+
return dialect.parseWriteResult(await Model.query(sql, params)).count
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** DELETE SQL+params 빌드. @param {any} Model @param {Record<string, any>} filter @param {any} dialect @param {ModelMeta} meta @param {boolean} allowEmpty @returns {{ sql: string, params: any[] }} */
|
|
385
|
+
function buildDelete(Model, filter, dialect, meta, allowEmpty) {
|
|
386
|
+
const filterKeys = Object.keys(filter ?? {})
|
|
387
|
+
if (filterKeys.length === 0 && !allowEmpty) {
|
|
388
|
+
throw new MegaInternalError(
|
|
389
|
+
'model.unbounded_write',
|
|
390
|
+
`${Model.name}: 빈 filter 의 전체 DELETE 는 막혀 있습니다. 의도면 { all: true } 또는 raw SQL 을 쓰세요.`,
|
|
391
|
+
{ details: { model: Model.name } },
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
const { clause, params } = buildWhere(filter ?? {}, meta.cols, dialect, Model.name, 1)
|
|
395
|
+
const where = clause ? ` WHERE ${clause}` : ''
|
|
396
|
+
return { sql: `DELETE FROM ${tbl(dialect, meta)}${where}`, params }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** @param {any} Model @param {Record<string, any>} filter @returns {Promise<number>} */
|
|
400
|
+
export async function deleteOne(Model, filter) {
|
|
401
|
+
if (isMongo(Model)) return mongoCrud.deleteOne(Model, filter)
|
|
402
|
+
const { dialect } = getCrudDialect(Model)
|
|
403
|
+
const meta = getModelMeta(Model)
|
|
404
|
+
if (Object.keys(filter ?? {}).length === 0) {
|
|
405
|
+
throw new MegaInternalError(
|
|
406
|
+
'model.invalid_filter',
|
|
407
|
+
`${Model.name}: deleteOne 은 대상을 특정하는 filter 가 필요합니다(빈 filter 금지).`,
|
|
408
|
+
{ details: { model: Model.name } },
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
const { sql, params } = buildDelete(Model, filter, dialect, meta, false)
|
|
412
|
+
return Model.withTransaction(async () => {
|
|
413
|
+
const n = dialect.parseWriteResult(await Model.query(sql, params)).count
|
|
414
|
+
if (n > 1) throw multipleMatches(Model, 'deleteOne', n)
|
|
415
|
+
return n
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** @param {any} Model @param {Record<string, any>} filter @param {{ all?: boolean }} [opts] @returns {Promise<number>} */
|
|
420
|
+
export async function deleteMany(Model, filter, opts = {}) {
|
|
421
|
+
if (isMongo(Model)) return mongoCrud.deleteMany(Model, filter, opts)
|
|
422
|
+
const { dialect } = getCrudDialect(Model)
|
|
423
|
+
const meta = getModelMeta(Model)
|
|
424
|
+
const { sql, params } = buildDelete(Model, filter, dialect, meta, opts.all === true)
|
|
425
|
+
return dialect.parseWriteResult(await Model.query(sql, params)).count
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** @param {any} Model @param {Record<string, any>} data @param {{ conflict: string[], update?: string[], returning?: boolean }} opts @returns {Promise<any>} */
|
|
429
|
+
export async function upsert(Model, data, opts) {
|
|
430
|
+
if (isMongo(Model)) return mongoCrud.upsert(Model, data, opts)
|
|
431
|
+
const { dialect } = getCrudDialect(Model)
|
|
432
|
+
const meta = getModelMeta(Model)
|
|
433
|
+
const { colSql, phs, params, cols } = prepInsert(Model, data, dialect, meta)
|
|
434
|
+
const conflict = opts?.conflict
|
|
435
|
+
if (!Array.isArray(conflict) || conflict.length === 0) {
|
|
436
|
+
throw new MegaInternalError(
|
|
437
|
+
'model.invalid_conflict_target',
|
|
438
|
+
`${Model.name}: upsert 는 opts.conflict(충돌 컬럼 배열)가 필요합니다.`,
|
|
439
|
+
{ details: { model: Model.name } },
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
conflict.forEach((c) => {
|
|
443
|
+
assertColumn(c, meta.cols, Model.name, 'conflict')
|
|
444
|
+
const isPk = meta.pkCols.includes(c)
|
|
445
|
+
if (!isPk && !meta.uniques.has(c)) {
|
|
446
|
+
throw new MegaInternalError(
|
|
447
|
+
'model.invalid_conflict_target',
|
|
448
|
+
`${Model.name}: conflict '${c}' 은 PK/unique 컬럼이 아닙니다(schema 기준).`,
|
|
449
|
+
{ details: { model: Model.name, conflict: c } },
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
const updateCols = opts.update ?? cols.filter((c) => !conflict.includes(c))
|
|
454
|
+
updateCols.forEach((c) => assertColumn(c, meta.cols, Model.name, 'update'))
|
|
455
|
+
const clause = dialect.upsertClause({ conflictCols: conflict, updateCols })
|
|
456
|
+
const head = `INSERT INTO ${tbl(dialect, meta)} (${colSql}) VALUES (${phs.join(', ')}) ${clause}`
|
|
457
|
+
|
|
458
|
+
if (opts.returning === true) {
|
|
459
|
+
if (dialect.supportsReturning) {
|
|
460
|
+
const rows = dialect.parseReadResult(await Model.query(`${head} RETURNING *`, params))
|
|
461
|
+
return rows[0]
|
|
462
|
+
}
|
|
463
|
+
// maria: upsert 후 conflict 키로 재조회
|
|
464
|
+
await Model.query(head, params)
|
|
465
|
+
/** @type {Record<string, any>} */
|
|
466
|
+
const sel = {}
|
|
467
|
+
conflict.forEach((c) => (sel[c] = data[c]))
|
|
468
|
+
return findOne(Model, sel)
|
|
469
|
+
}
|
|
470
|
+
await Model.query(head, params)
|
|
471
|
+
return undefined // returning:false → void
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** @param {any} Model @param {string} method @param {number} n */
|
|
475
|
+
function multipleMatches(Model, method, n) {
|
|
476
|
+
return new MegaInternalError(
|
|
477
|
+
'model.multiple_matches',
|
|
478
|
+
`${Model.name}.${method}: filter 가 ${n} 행에 매칭됐습니다("정확히 하나" 위반) — 변경은 롤백됐습니다. PK/unique filter 를 쓰거나 ${method.replace('One', 'Many')} 를 쓰세요.`,
|
|
479
|
+
{
|
|
480
|
+
details: { model: Model.name, method, matched: n },
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
}
|