mega-framework 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +9 -0
  2. package/bin/mega-ws-hub.js +2 -2
  3. package/package.json +33 -9
  4. package/sample/crud/.env +10 -1
  5. package/sample/crud/.env.example +10 -1
  6. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  7. package/sample/crud/.mega/journal/snapshot.json +261 -0
  8. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  9. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  10. package/sample/crud/apps/main/locales/server/en.json +12 -1
  11. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  12. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  13. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  14. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  15. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  16. package/sample/crud/apps/main/models/note-model.js +79 -0
  17. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  18. package/sample/crud/apps/main/models/user-model.js +146 -0
  19. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  20. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  21. package/sample/crud/apps/main/routes/users.js +55 -10
  22. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  23. package/sample/crud/apps/main/services/auth-service.js +39 -24
  24. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  25. package/sample/crud/apps/main/services/note-service.js +6 -6
  26. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  27. package/sample/crud/apps/main/services/user-service.js +62 -21
  28. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  29. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  30. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  31. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  32. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  33. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  34. package/sample/crud/mega.config.js +10 -2
  35. package/sample/crud/package.json +3 -3
  36. package/sample/crud/scripts/start-ws-hub.sh +20 -6
  37. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/adapter-manager.js +2 -1
  40. package/src/adapters/adapter-options.js +44 -3
  41. package/src/adapters/file-adapter.js +9 -5
  42. package/src/adapters/file-session-adapter.js +4 -3
  43. package/src/adapters/maria-adapter.js +33 -7
  44. package/src/adapters/mega-cache-adapter.js +83 -6
  45. package/src/adapters/mega-db-adapter.js +10 -1
  46. package/src/adapters/mongo-adapter.js +40 -8
  47. package/src/adapters/postgres-adapter.js +33 -6
  48. package/src/adapters/redis-adapter.js +7 -3
  49. package/src/adapters/sqlite-adapter.js +26 -3
  50. package/src/cli/commands/console-cmd.js +3 -1
  51. package/src/cli/commands/new.js +13 -3
  52. package/src/cli/commands/scaffold.js +173 -33
  53. package/src/cli/generators/index.js +140 -3
  54. package/src/cli/index.js +437 -155
  55. package/src/cli/watch.js +188 -0
  56. package/src/core/ajv-mapper.js +30 -3
  57. package/src/core/boot.js +464 -245
  58. package/src/core/cluster-metrics.js +13 -4
  59. package/src/core/ctx-builder.js +65 -3
  60. package/src/core/envelope.js +119 -12
  61. package/src/core/hub-link.js +89 -18
  62. package/src/core/i18n.js +11 -1
  63. package/src/core/index.js +7 -3
  64. package/src/core/mega-app.js +253 -505
  65. package/src/core/mega-cluster.js +4 -1
  66. package/src/core/mega-server.js +40 -9
  67. package/src/core/migration/dialect-registry.js +107 -0
  68. package/src/core/migration/dialects/README.md +62 -0
  69. package/src/core/migration/dialects/maria.js +496 -0
  70. package/src/core/migration/dialects/mongo.js +824 -0
  71. package/src/core/migration/dialects/postgres.js +563 -0
  72. package/src/core/migration/dialects/sqlite.js +476 -0
  73. package/src/core/migration/differ.js +456 -0
  74. package/src/core/migration/generate.js +508 -0
  75. package/src/core/migration/journal.js +167 -0
  76. package/src/core/migration/model-scan.js +84 -0
  77. package/src/core/migration/mongo-migration-db.js +97 -0
  78. package/src/core/migration/schema-builder.js +400 -0
  79. package/src/core/migration/schema-validator.js +315 -0
  80. package/src/core/migration-lock.js +205 -0
  81. package/src/core/migration-runner.js +166 -38
  82. package/src/core/multipart.js +28 -5
  83. package/src/core/pipeline.js +131 -0
  84. package/src/core/router.js +70 -65
  85. package/src/core/scope-registry.js +1 -0
  86. package/src/core/security.js +70 -12
  87. package/src/core/session-store.js +14 -1
  88. package/src/core/workers-manager.js +12 -1
  89. package/src/core/ws-cluster.js +10 -3
  90. package/src/core/ws-message.js +48 -4
  91. package/src/core/ws-presence.js +636 -0
  92. package/src/core/ws-roster.js +50 -8
  93. package/src/core/ws-upgrade.js +223 -12
  94. package/src/index.js +1 -1
  95. package/src/lib/hub-protocol.js +29 -0
  96. package/src/lib/mega-circuit-breaker.js +5 -3
  97. package/src/lib/mega-health.js +35 -4
  98. package/src/lib/mega-job-queue.js +151 -34
  99. package/src/lib/mega-job.js +37 -1
  100. package/src/lib/mega-metrics.js +31 -13
  101. package/src/lib/mega-plugin.js +34 -3
  102. package/src/lib/mega-schedule.js +40 -22
  103. package/src/lib/mega-shutdown.js +114 -39
  104. package/src/lib/mega-tracing.js +66 -19
  105. package/src/lib/mega-worker.js +33 -6
  106. package/src/lib/otel-resource.js +36 -0
  107. package/src/{cli → lib}/ws-hub.js +139 -15
  108. package/src/models/crud-sql-builder.js +133 -0
  109. package/src/models/mega-model.js +82 -2
  110. package/src/models/model-crud.js +483 -0
  111. package/src/models/mongo-crud.js +285 -0
  112. package/templates/adr/code.tpl +23 -0
  113. package/templates/model/code-mongo.tpl +35 -0
  114. package/templates/model/code.tpl +15 -1
  115. package/templates/model/test-mongo.tpl +38 -0
  116. package/templates/model/test.tpl +4 -0
  117. package/types/adapters/adapter-manager.d.ts +95 -0
  118. package/types/adapters/adapter-options.d.ts +93 -0
  119. package/types/adapters/file-adapter.d.ts +105 -0
  120. package/types/adapters/file-session-adapter.d.ts +103 -0
  121. package/types/adapters/index.d.ts +20 -0
  122. package/types/adapters/maria-adapter.d.ts +117 -0
  123. package/types/adapters/mega-adapter.d.ts +215 -0
  124. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  125. package/types/adapters/mega-cache-adapter.d.ts +73 -0
  126. package/types/adapters/mega-db-adapter.d.ts +50 -0
  127. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  128. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  129. package/types/adapters/mega-session-adapter.d.ts +32 -0
  130. package/types/adapters/mongo-adapter.d.ts +150 -0
  131. package/types/adapters/nats-adapter.d.ts +108 -0
  132. package/types/adapters/postgres-adapter.d.ts +141 -0
  133. package/types/adapters/redis-adapter.d.ts +78 -0
  134. package/types/adapters/redis-session-adapter.d.ts +82 -0
  135. package/types/adapters/redlock-adapter.d.ts +149 -0
  136. package/types/adapters/registry.d.ts +46 -0
  137. package/types/adapters/sqlite-adapter.d.ts +112 -0
  138. package/types/auth/index.d.ts +24 -0
  139. package/types/cli/commands/console-cmd.d.ts +37 -0
  140. package/types/cli/commands/new.d.ts +16 -0
  141. package/types/cli/commands/routes.d.ts +36 -0
  142. package/types/cli/commands/scaffold.d.ts +78 -0
  143. package/types/cli/commands/test-cmd.d.ts +14 -0
  144. package/types/cli/generators/index.d.ts +122 -0
  145. package/types/cli/index.d.ts +234 -0
  146. package/types/cli/template-engine.d.ts +40 -0
  147. package/types/cli/watch.d.ts +59 -0
  148. package/types/core/ajv-mapper.d.ts +27 -0
  149. package/types/core/boot.d.ts +233 -0
  150. package/types/core/cluster-metrics.d.ts +52 -0
  151. package/types/core/config-loader.d.ts +13 -0
  152. package/types/core/config-validator.d.ts +30 -0
  153. package/types/core/ctx-builder.d.ts +103 -0
  154. package/types/core/envelope.d.ts +79 -0
  155. package/types/core/error-mapper.d.ts +17 -0
  156. package/types/core/formbody.d.ts +41 -0
  157. package/types/core/hub-link.d.ts +266 -0
  158. package/types/core/i18n.d.ts +178 -0
  159. package/types/core/index.d.ts +28 -0
  160. package/types/core/mega-app.d.ts +529 -0
  161. package/types/core/mega-cluster.d.ts +104 -0
  162. package/types/core/mega-server.d.ts +91 -0
  163. package/types/core/mega-service.d.ts +31 -0
  164. package/types/core/migration/dialect-registry.d.ts +22 -0
  165. package/types/core/migration/dialects/maria.d.ts +99 -0
  166. package/types/core/migration/dialects/mongo.d.ts +89 -0
  167. package/types/core/migration/dialects/postgres.d.ts +117 -0
  168. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  169. package/types/core/migration/differ.d.ts +47 -0
  170. package/types/core/migration/generate.d.ts +56 -0
  171. package/types/core/migration/journal.d.ts +52 -0
  172. package/types/core/migration/model-scan.d.ts +19 -0
  173. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  174. package/types/core/migration/schema-builder.d.ts +197 -0
  175. package/types/core/migration/schema-validator.d.ts +20 -0
  176. package/types/core/migration-lock.d.ts +33 -0
  177. package/types/core/migration-runner.d.ts +101 -0
  178. package/types/core/multipart.d.ts +86 -0
  179. package/types/core/openapi.d.ts +62 -0
  180. package/types/core/pipeline.d.ts +93 -0
  181. package/types/core/router.d.ts +159 -0
  182. package/types/core/routes-loader.d.ts +21 -0
  183. package/types/core/scope-registry.d.ts +14 -0
  184. package/types/core/security.d.ts +77 -0
  185. package/types/core/services-loader.d.ts +27 -0
  186. package/types/core/session-cleanup-schedule.d.ts +19 -0
  187. package/types/core/session-store.d.ts +25 -0
  188. package/types/core/session.d.ts +77 -0
  189. package/types/core/static-assets.d.ts +73 -0
  190. package/types/core/template.d.ts +106 -0
  191. package/types/core/workers-manager.d.ts +79 -0
  192. package/types/core/ws-cluster.d.ts +208 -0
  193. package/types/core/ws-compression.d.ts +112 -0
  194. package/types/core/ws-controller.d.ts +65 -0
  195. package/types/core/ws-message.d.ts +106 -0
  196. package/types/core/ws-presence.d.ts +273 -0
  197. package/types/core/ws-roster.d.ts +108 -0
  198. package/types/core/ws-upgrade.d.ts +260 -0
  199. package/types/errors/config-error.d.ts +10 -0
  200. package/types/errors/http-errors.d.ts +120 -0
  201. package/types/errors/index.d.ts +3 -0
  202. package/types/errors/mega-error.d.ts +32 -0
  203. package/types/index.d.ts +39 -0
  204. package/types/lib/asp/config.d.ts +49 -0
  205. package/types/lib/asp/crypto.d.ts +43 -0
  206. package/types/lib/asp/errors.d.ts +30 -0
  207. package/types/lib/asp/nonce-cache.d.ts +52 -0
  208. package/types/lib/asp/plugin.d.ts +30 -0
  209. package/types/lib/asp/ws-terminator.d.ts +45 -0
  210. package/types/lib/env-mapper.d.ts +14 -0
  211. package/types/lib/hub-protocol.d.ts +106 -0
  212. package/types/lib/index.d.ts +22 -0
  213. package/types/lib/logger/telegram-core.d.ts +104 -0
  214. package/types/lib/logger/telegram-transport.d.ts +45 -0
  215. package/types/lib/mega-brute-force.d.ts +66 -0
  216. package/types/lib/mega-circuit-breaker.d.ts +243 -0
  217. package/types/lib/mega-cron.d.ts +66 -0
  218. package/types/lib/mega-hash.d.ts +32 -0
  219. package/types/lib/mega-health.d.ts +48 -0
  220. package/types/lib/mega-job-queue.d.ts +188 -0
  221. package/types/lib/mega-job-worker.d.ts +130 -0
  222. package/types/lib/mega-job.d.ts +145 -0
  223. package/types/lib/mega-logger.d.ts +45 -0
  224. package/types/lib/mega-metrics.d.ts +285 -0
  225. package/types/lib/mega-plugin.d.ts +245 -0
  226. package/types/lib/mega-retry.d.ts +85 -0
  227. package/types/lib/mega-schedule.d.ts +260 -0
  228. package/types/lib/mega-shutdown.d.ts +135 -0
  229. package/types/lib/mega-tracing.d.ts +224 -0
  230. package/types/lib/mega-worker.d.ts +129 -0
  231. package/types/lib/otel-resource.d.ts +16 -0
  232. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  233. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  234. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  235. package/types/lib/ws-hub.d.ts +259 -0
  236. package/types/models/crud-sql-builder.d.ts +48 -0
  237. package/types/models/index.d.ts +1 -0
  238. package/types/models/mega-model.d.ts +138 -0
  239. package/types/models/model-crud.d.ts +82 -0
  240. package/types/models/mongo-crud.d.ts +59 -0
  241. package/types/test/index.d.ts +84 -0
  242. package/.env +0 -127
  243. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  244. package/sample/crud/apps/main/models/note.js +0 -71
  245. package/sample/crud/apps/main/models/user.js +0 -86
  246. package/sample/crud/package-lock.json +0 -5665
  247. package/sample/crud/yarn.lock +0 -2142
  248. package/sample/simple/package-lock.json +0 -1851
@@ -0,0 +1,165 @@
1
+ // @ts-check
2
+ /**
3
+ * 마이그레이션 create-boards (20260606000002). up = 게시판 관련 테이블 생성, down = 제거(ADR-149).
4
+ * `mega migrate`(up)·`mega migrate:down`·`mega migrate:status` 로 실행. 적용 이력은 대상 DB 의
5
+ * `mega_migrations` 테이블이 추적하며, 각 마이그레이션은 트랜잭션으로 감싸 적용된다.
6
+ *
7
+ * @param {{ query: (sql: string, params?: any[]) => Promise<any> }} db - 대상 DB 어댑터(트랜잭션 내 query).
8
+ * @returns {Promise<void>}
9
+ */
10
+ export async function up(db) {
11
+
12
+ // ltree 확장 활성화 (계층형 게시물 sort_path 용)
13
+ await db.query(`CREATE EXTENSION IF NOT EXISTS ltree`)
14
+
15
+ // 게시판 설정 테이블
16
+ await db.query(`
17
+ CREATE TABLE IF NOT EXISTS board_configs (
18
+ id SERIAL PRIMARY KEY,
19
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
20
+ code TEXT NOT NULL UNIQUE,
21
+ type TEXT NOT NULL,
22
+ use_comment BOOLEAN NOT NULL DEFAULT true,
23
+ use_file BOOLEAN NOT NULL DEFAULT true,
24
+ name TEXT NOT NULL,
25
+ sort_order INTEGER NOT NULL DEFAULT 0,
26
+ is_enabled BOOLEAN NOT NULL DEFAULT true,
27
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
28
+ );
29
+ COMMENT ON TABLE board_configs IS '게시판 설정 테이블';
30
+ COMMENT ON COLUMN board_configs.uuid IS '고유값';
31
+ COMMENT ON COLUMN board_configs.code IS '게시판 코드';
32
+ COMMENT ON COLUMN board_configs.name IS '게시판 명칭';
33
+ COMMENT ON COLUMN board_configs.sort_order IS '정렬 순서';
34
+ COMMENT ON COLUMN board_configs.is_enabled IS '사용 여부';
35
+ COMMENT ON COLUMN board_configs.created_at IS '생성 시간';
36
+ CREATE UNIQUE INDEX idx_board_configs_uuid ON board_configs (uuid);
37
+ `)
38
+
39
+ // 게시물 테이블
40
+ await db.query(`
41
+ CREATE TABLE IF NOT EXISTS boards (
42
+ id BIGSERIAL PRIMARY KEY,
43
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
44
+ config_code TEXT NOT NULL REFERENCES board_configs(code) ON DELETE CASCADE,
45
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
46
+ parent_id BIGINT REFERENCES boards(id) ON DELETE CASCADE,
47
+ depth INTEGER NOT NULL DEFAULT 0,
48
+ sort_path ltree NOT NULL DEFAULT '',
49
+ title TEXT NOT NULL,
50
+ content TEXT,
51
+ view_count INTEGER NOT NULL DEFAULT 0,
52
+ reply_count INTEGER NOT NULL DEFAULT 0,
53
+ like_count INTEGER NOT NULL DEFAULT 0,
54
+ dislike_count INTEGER NOT NULL DEFAULT 0,
55
+ is_pinned BOOLEAN NOT NULL DEFAULT false,
56
+ is_recommended BOOLEAN NOT NULL DEFAULT false,
57
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
58
+ deleted_at TIMESTAMPTZ,
59
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
60
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
61
+ );
62
+ COMMENT ON TABLE boards IS '게시물 테이블';
63
+ COMMENT ON COLUMN boards.uuid IS '고유값';
64
+ COMMENT ON COLUMN boards.user_id IS '작성자 ID';
65
+ COMMENT ON COLUMN boards.config_code IS '게시판 코드';
66
+ COMMENT ON COLUMN boards.parent_id IS '부모 게시물 ID (답글 원글)';
67
+ COMMENT ON COLUMN boards.depth IS '계층 깊이 (0=원글, 1=답글, 2=답답글...)';
68
+ COMMENT ON COLUMN boards.sort_path IS '계층 정렬 경로 (ltree)';
69
+ COMMENT ON COLUMN boards.title IS '제목';
70
+ COMMENT ON COLUMN boards.content IS '내용';
71
+ COMMENT ON COLUMN boards.view_count IS '조회수';
72
+ COMMENT ON COLUMN boards.reply_count IS '답변 수';
73
+ COMMENT ON COLUMN boards.like_count IS '좋아요 수';
74
+ COMMENT ON COLUMN boards.dislike_count IS '싫어요 수';
75
+ COMMENT ON COLUMN boards.is_pinned IS '고정 여부';
76
+ COMMENT ON COLUMN boards.is_recommended IS '추천 여부';
77
+ COMMENT ON COLUMN boards.is_deleted IS '삭제 여부';
78
+ COMMENT ON COLUMN boards.deleted_at IS '삭제 시간';
79
+ COMMENT ON COLUMN boards.created_at IS '생성 시간';
80
+ COMMENT ON COLUMN boards.updated_at IS '수정 시간';
81
+ CREATE UNIQUE INDEX idx_boards_uuid ON boards (uuid);
82
+ CREATE INDEX idx_boards_config_code ON boards (config_code);
83
+ CREATE INDEX idx_boards_user_id ON boards (user_id);
84
+ CREATE INDEX idx_boards_parent_id ON boards (parent_id);
85
+ CREATE INDEX idx_boards_sort_path ON boards USING GIST (sort_path);
86
+ CREATE INDEX idx_boards_created_at ON boards (created_at DESC) WHERE is_deleted = false;
87
+ `)
88
+
89
+ // 게시물 댓글 테이블
90
+ await db.query(`
91
+ CREATE TABLE IF NOT EXISTS board_comments (
92
+ id BIGSERIAL PRIMARY KEY,
93
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
94
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
95
+ board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
96
+ parent_id BIGINT REFERENCES board_comments(id) ON DELETE CASCADE,
97
+ content TEXT,
98
+ like_count INTEGER NOT NULL DEFAULT 0,
99
+ dislike_count INTEGER NOT NULL DEFAULT 0,
100
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
101
+ deleted_at TIMESTAMPTZ,
102
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
103
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
104
+ );
105
+ COMMENT ON TABLE board_comments IS '게시물 댓글 테이블';
106
+ COMMENT ON COLUMN board_comments.uuid IS '고유값';
107
+ COMMENT ON COLUMN board_comments.user_id IS '작성자 ID';
108
+ COMMENT ON COLUMN board_comments.board_id IS '게시판 ID';
109
+ COMMENT ON COLUMN board_comments.parent_id IS '부모 댓글 ID (대댓글용)';
110
+ COMMENT ON COLUMN board_comments.content IS '내용';
111
+ COMMENT ON COLUMN board_comments.like_count IS '좋아요 수';
112
+ COMMENT ON COLUMN board_comments.dislike_count IS '싫어요 수';
113
+ COMMENT ON COLUMN board_comments.is_deleted IS '삭제 여부';
114
+ COMMENT ON COLUMN board_comments.deleted_at IS '삭제 시간';
115
+ COMMENT ON COLUMN board_comments.created_at IS '생성 시간';
116
+ COMMENT ON COLUMN board_comments.updated_at IS '수정 시간';
117
+ CREATE UNIQUE INDEX idx_board_comments_uuid ON board_comments (uuid);
118
+ CREATE INDEX idx_board_comments_user_id ON board_comments (user_id);
119
+ CREATE INDEX idx_board_comments_board_id ON board_comments (board_id);
120
+ CREATE INDEX idx_board_comments_parent_id ON board_comments (parent_id);
121
+ `)
122
+
123
+ // 게시글 첨부 파일 테이블
124
+ await db.query(`
125
+ CREATE TABLE IF NOT EXISTS board_files (
126
+ id SERIAL PRIMARY KEY,
127
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
128
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
129
+ board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
130
+ original_name TEXT NOT NULL,
131
+ stored_name TEXT NOT NULL,
132
+ file_type TEXT NOT NULL DEFAULT 'unknown',
133
+ file_size INTEGER NOT NULL,
134
+ download_count INTEGER NOT NULL DEFAULT 0,
135
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
136
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
137
+ );
138
+ COMMENT ON TABLE board_files IS '게시글 첨부 파일 테이블';
139
+ COMMENT ON COLUMN board_files.uuid IS '고유값';
140
+ COMMENT ON COLUMN board_files.user_id IS '작성자 ID';
141
+ COMMENT ON COLUMN board_files.board_id IS '게시판 ID';
142
+ COMMENT ON COLUMN board_files.original_name IS '원본파일명';
143
+ COMMENT ON COLUMN board_files.stored_name IS '저장명';
144
+ COMMENT ON COLUMN board_files.file_type IS '파일 유형';
145
+ COMMENT ON COLUMN board_files.file_size IS '파일 크기';
146
+ COMMENT ON COLUMN board_files.download_count IS '다운로드 수';
147
+ COMMENT ON COLUMN board_files.created_at IS '생성 시간';
148
+ COMMENT ON COLUMN board_files.updated_at IS '수정 시간';
149
+ CREATE UNIQUE INDEX idx_board_files_uuid ON board_files (uuid);
150
+ CREATE INDEX idx_board_files_user_id ON board_files (user_id);
151
+ CREATE INDEX idx_board_files_board_id ON board_files (board_id);
152
+ `)
153
+ }
154
+
155
+ /**
156
+ * @param {{ query: (sql: string, params?: any[]) => Promise<any> }} db
157
+ * @returns {Promise<void>}
158
+ */
159
+ export async function down(db) {
160
+ await db.query('DROP TABLE IF EXISTS board_files')
161
+ await db.query('DROP TABLE IF EXISTS board_comments')
162
+ await db.query('DROP TABLE IF EXISTS boards')
163
+ await db.query('DROP TABLE IF EXISTS board_configs')
164
+ await db.query('DROP EXTENSION IF EXISTS ltree')
165
+ }
@@ -0,0 +1,107 @@
1
+ // @ts-check
2
+ /**
3
+ * 마이그레이션 create-logs (20260606000003). up = 로그 관련 파티션 테이블 및 초기 파티션 생성, down = 제거(ADR-149).
4
+ * `mega migrate`(up)·`mega migrate:down`·`mega migrate:status` 로 실행. 적용 이력은 대상 DB 의
5
+ * `mega_migrations` 테이블이 추적하며, 각 마이그레이션은 트랜잭션으로 감싸 적용된다.
6
+ *
7
+ * @param {{ query: (sql: string, params?: any[]) => Promise<any> }} db - 대상 DB 어댑터(트랜잭션 내 query).
8
+ * @returns {Promise<void>}
9
+ */
10
+ export async function up(db) {
11
+
12
+ // 활동 로그 파티션 테이블 생성 (created_at 컬럼을 기준으로 범위 파티셔닝 적용)
13
+ await db.query(`
14
+ CREATE TABLE IF NOT EXISTS action_logs (
15
+ id BIGSERIAL,
16
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
17
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
18
+ action_type TEXT NOT NULL,
19
+ action_target TEXT NOT NULL,
20
+ action_target_id TEXT,
21
+ message TEXT,
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23
+ PRIMARY KEY (id, created_at) -- 파티션 제약 조건에 따라 파티션 키가 PK에 포함되어야 함
24
+ ) PARTITION BY RANGE (created_at);
25
+ COMMENT ON TABLE action_logs IS '활동 로그 파티션 테이블';
26
+ COMMENT ON COLUMN action_logs.uuid IS '고유값';
27
+ COMMENT ON COLUMN action_logs.action_type IS '활동 유형';
28
+ COMMENT ON COLUMN action_logs.action_target IS '활동 대상';
29
+ COMMENT ON COLUMN action_logs.action_target_id IS '활동 대상 ID';
30
+ COMMENT ON COLUMN action_logs.message IS '메시지';
31
+ COMMENT ON COLUMN action_logs.created_at IS '생성 시간';
32
+ CREATE UNIQUE INDEX idx_action_logs_uuid ON action_logs (uuid, created_at); -- 고유 인덱스에도 파티션 키 필수 포함
33
+ CREATE INDEX idx_action_logs_user_id ON action_logs (user_id);
34
+ CREATE INDEX idx_action_logs_action_type ON action_logs (action_type);
35
+ `)
36
+
37
+ // 지갑 로그 파티션 테이블 생성 (created_at 컬럼을 기준으로 범위 파티셔닝 적용)
38
+ await db.query(`
39
+ CREATE TABLE IF NOT EXISTS wallet_logs (
40
+ id BIGSERIAL,
41
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
42
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
43
+ wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE CASCADE,
44
+ action_type TEXT NOT NULL,
45
+ action_target TEXT NOT NULL,
46
+ action_target_id TEXT,
47
+ message TEXT,
48
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
49
+ PRIMARY KEY (id, created_at) -- 파티션 제약 조건에 따라 파티션 키가 PK에 포함되어야 함
50
+ ) PARTITION BY RANGE (created_at);
51
+ COMMENT ON TABLE wallet_logs IS '지갑 로그 파티션 테이블';
52
+ COMMENT ON COLUMN wallet_logs.uuid IS '고유값';
53
+ COMMENT ON COLUMN wallet_logs.action_type IS '활동 유형';
54
+ COMMENT ON COLUMN wallet_logs.action_target IS '활동 대상';
55
+ COMMENT ON COLUMN wallet_logs.action_target_id IS '활동 대상 ID';
56
+ COMMENT ON COLUMN wallet_logs.message IS '메시지';
57
+ COMMENT ON COLUMN wallet_logs.created_at IS '생성 시간';
58
+ CREATE UNIQUE INDEX idx_wallet_logs_uuid ON wallet_logs (uuid, created_at); -- 고유 인덱스에도 파티션 키 필수 포함
59
+ CREATE INDEX idx_wallet_logs_user_id ON wallet_logs (user_id);
60
+ CREATE INDEX idx_wallet_logs_action_type ON wallet_logs (action_type);
61
+ `)
62
+
63
+ // 상세 로그 파티션 테이블 생성 (created_at 컬럼을 기준으로 범위 파티셔닝 적용)
64
+ await db.query(`
65
+ CREATE TABLE IF NOT EXISTS detail_logs (
66
+ id BIGSERIAL,
67
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
68
+ log_id BIGINT NOT NULL,
69
+ log_json JSONB NOT NULL,
70
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
71
+ PRIMARY KEY (id, created_at) -- 파티션 제약 조건에 따라 파티션 키가 PK에 포함되어야 함
72
+ ) PARTITION BY RANGE (created_at);
73
+ COMMENT ON TABLE detail_logs IS '상세 로그 파티션 테이블';
74
+ COMMENT ON COLUMN detail_logs.uuid IS '고유값';
75
+ COMMENT ON COLUMN detail_logs.log_id IS '로그 ID';
76
+ COMMENT ON COLUMN detail_logs.log_json IS '상세 로그 JSON';
77
+ COMMENT ON COLUMN detail_logs.created_at IS '생성 시간';
78
+ CREATE UNIQUE INDEX idx_detail_logs_uuid ON detail_logs (uuid, created_at); -- 고유 인덱스에도 파티션 키 필수 포함
79
+ CREATE UNIQUE INDEX idx_detail_logs_log_id ON detail_logs (log_id, created_at); -- 고유 인덱스에도 파티션 키 필수 포함
80
+ `)
81
+
82
+ // 초기 파티션 생성 (2026년 6월 및 7월)
83
+ // 데이터 유실 및 쿼리 에러를 미연에 방지하기 위해 마이그레이션 시점에 현재 월과 다음 월의 파티션을 생성해 둡니다.
84
+ await db.query(`
85
+ CREATE TABLE IF NOT EXISTS action_logs_y2026m06 PARTITION OF action_logs FOR VALUES FROM ('2026-06-01 00:00:00+09') TO ('2026-07-01 00:00:00+09');
86
+ CREATE TABLE IF NOT EXISTS action_logs_y2026m07 PARTITION OF action_logs FOR VALUES FROM ('2026-07-01 00:00:00+09') TO ('2026-08-01 00:00:00+09');
87
+
88
+ CREATE TABLE IF NOT EXISTS wallet_logs_y2026m06 PARTITION OF wallet_logs FOR VALUES FROM ('2026-06-01 00:00:00+09') TO ('2026-07-01 00:00:00+09');
89
+ CREATE TABLE IF NOT EXISTS wallet_logs_y2026m07 PARTITION OF wallet_logs FOR VALUES FROM ('2026-07-01 00:00:00+09') TO ('2026-08-01 00:00:00+09');
90
+
91
+ CREATE TABLE IF NOT EXISTS detail_logs_y2026m06 PARTITION OF detail_logs FOR VALUES FROM ('2026-06-01 00:00:00+09') TO ('2026-07-01 00:00:00+09');
92
+ CREATE TABLE IF NOT EXISTS detail_logs_y2026m07 PARTITION OF detail_logs FOR VALUES FROM ('2026-07-01 00:00:00+09') TO ('2026-08-01 00:00:00+09');
93
+ `)
94
+ }
95
+
96
+ /**
97
+ * @param {{ query: (sql: string, params?: any[]) => Promise<any> }} db
98
+ * @returns {Promise<void>}
99
+ */
100
+ export async function down(db) {
101
+ // 부모 테이블 삭제 시 하위 파티션 테이블(y2026m06 등)은 자동으로 함께 드롭됩니다.
102
+ await db.query(`
103
+ DROP TABLE IF EXISTS detail_logs;
104
+ DROP TABLE IF EXISTS action_logs;
105
+ DROP TABLE IF EXISTS wallet_logs;
106
+ `)
107
+ }
@@ -0,0 +1,105 @@
1
+ // @ts-check
2
+ import { MegaModel } from 'mega-framework'
3
+
4
+ /**
5
+ * 로그 파티션 테이블에 대한 직접적인 DDL 쿼리를 실행하는 모델 클래스 (ADR-009/138).
6
+ * @extends {MegaModel}
7
+ */
8
+ export class LogPartitionModel extends MegaModel {
9
+ static adapter = 'primary'
10
+ // MegaModel 요구사항: 기본 관리 대상이 되는 대표 테이블 지정
11
+ static table = 'action_logs'
12
+
13
+ /**
14
+ * 대상 부모 테이블의 하위 파티션 목록을 데이터베이스에서 조회합니다.
15
+ * @param {string} parentTable - 부모 테이블 이름 ('action_logs', 'wallet_logs', 'detail_logs')
16
+ * @returns {Promise<string[]>} 하위 파티션 테이블 명칭 배열
17
+ */
18
+ static async getPartitions(parentTable) {
19
+ // 부모 테이블 이름 화이트리스트 검사 (SQL 인젝션 방지)
20
+ const allowed = ['action_logs', 'wallet_logs', 'detail_logs']
21
+ if (!allowed.includes(parentTable)) {
22
+ throw new Error(`허용되지 않은 부모 테이블명입니다: ${parentTable}`)
23
+ }
24
+
25
+ // pg_inherits 시스템 테이블을 조회하여 파티션 목록 조회
26
+ const sql = `
27
+ SELECT c.relname AS partition_name
28
+ FROM pg_inherits i
29
+ JOIN pg_class c ON c.oid = i.inhrelid
30
+ JOIN pg_class p ON p.oid = i.inhparent
31
+ WHERE p.relname = $1
32
+ ORDER BY c.relname ASC
33
+ `
34
+ const { rows } = await super.query(sql, [parentTable])
35
+
36
+ // 파티션 테이블명만 추출하여 리턴
37
+ return rows.map((/** @type {any} */ r) => r.partition_name)
38
+ }
39
+
40
+ /**
41
+ * 지정한 연월 범위의 파티션 테이블을 생성합니다.
42
+ * @param {string} parentTable - 부모 테이블 이름
43
+ * @param {number} year - 연도 (예: 2026)
44
+ * @param {number} month - 월 (예: 6)
45
+ * @returns {Promise<void>}
46
+ */
47
+ static async createPartition(parentTable, year, month) {
48
+ const allowed = ['action_logs', 'wallet_logs', 'detail_logs']
49
+ if (!allowed.includes(parentTable)) {
50
+ throw new Error(`허용되지 않은 부모 테이블명입니다: ${parentTable}`)
51
+ }
52
+
53
+ // 월 자릿수를 두 자리 문자열로 포맷팅 (예: 6 -> '06')
54
+ const monthStr = month.toString().padStart(2, '0')
55
+ const partitionName = `${parentTable}_y${year}m${monthStr}`
56
+
57
+ // 시작일(해당 월 1일) 및 종료일(다음 달 1일) 계산
58
+ const startDate = `${year}-${monthStr}-01 00:00:00+09`
59
+
60
+ // 다음 달 계산 로직 (12월인 경우 다음 해 1월로 이월)
61
+ const nextYear = month === 12 ? year + 1 : year
62
+ const nextMonth = month === 12 ? 1 : month + 1
63
+ const nextMonthStr = nextMonth.toString().padStart(2, '0')
64
+ const endDate = `${nextYear}-${nextMonthStr}-01 00:00:00+09`
65
+
66
+ // 파티션 테이블 생성 쿼리 (안전한 명칭 포맷을 사전에 정규식 검증)
67
+ if (!/^[a-z0-9_]+$/.test(partitionName)) {
68
+ throw new Error(`부적절한 파티션 테이블 이름입니다: ${partitionName}`)
69
+ }
70
+
71
+ // 파티션 생성 쿼리 실행
72
+ await super.query(`
73
+ CREATE TABLE IF NOT EXISTS ${partitionName}
74
+ PARTITION OF ${parentTable}
75
+ FOR VALUES FROM ('${startDate}') TO ('${endDate}')
76
+ `)
77
+ }
78
+
79
+ /**
80
+ * 지정한 파티션 테이블을 부모 테이블에서 분리(Detach)하고 물리적으로 제거(Drop)합니다.
81
+ * @param {string} parentTable - 부모 테이블 이름
82
+ * @param {string} partitionName - 드롭할 파티션 테이블 이름
83
+ * @returns {Promise<void>}
84
+ */
85
+ static async dropPartition(parentTable, partitionName) {
86
+ const allowed = ['action_logs', 'wallet_logs', 'detail_logs']
87
+ if (!allowed.includes(parentTable)) {
88
+ throw new Error(`허용되지 않은 부모 테이블명입니다: ${parentTable}`)
89
+ }
90
+
91
+ // 파티션 명칭 검증 (형식: 테이블명_yYYYYmMM)
92
+ if (!partitionName.startsWith(`${parentTable}_y`)) {
93
+ throw new Error(`부모 테이블에 일치하지 않는 파티션입니다: ${partitionName}`)
94
+ }
95
+ if (!/^[a-z0-9_]+$/.test(partitionName)) {
96
+ throw new Error(`부적절한 파티션 테이블 이름입니다: ${partitionName}`)
97
+ }
98
+
99
+ // 1단계: 부모 테이블로부터 파티션 분리 (락 오버헤드를 최소화하기 위해 선(先)분리 후(後)삭제 수행)
100
+ await super.query(`ALTER TABLE ${parentTable} DETACH PARTITION ${partitionName}`)
101
+
102
+ // 2단계: 분리된 독립 테이블 제거
103
+ await super.query(`DROP TABLE IF EXISTS ${partitionName}`)
104
+ }
105
+ }
@@ -0,0 +1,79 @@
1
+ // @ts-check
2
+ import { randomUUID } from 'node:crypto'
3
+ import { MegaModel } from 'mega-framework'
4
+
5
+ /**
6
+ * NoteModel — `notes` 컬렉션(globalKey 'mongo' 의 MongoDB, ADR-108). `static schema`(ADR-209 $jsonSchema)
7
+ * 선언으로 공통 CRUD(ADR-212 P3 — mongo 경로)를 켜고, 도메인 메서드 본문을 그 CRUD 로 작성한다. mongo CRUD 는
8
+ * SQL 과 동일 표면이되 내부적으로 `this.db.collection().find/insertOne/...` 도큐먼트 연산으로 디스패치된다.
9
+ *
10
+ * 식별자는 라우트/폼에서 다루기 쉬운 자체 `id`(UUID v4)를 쓰고, 조회 응답에서는 mongo 내부 `_id` 를
11
+ * projection(select)으로 제외해 도메인 형태만 노출한다. (공통 CRUD 의 PK 는 `_id` 지만, 도메인 lookup 은
12
+ * `findOne({ id })` 로 UUID 를 쓴다 — NoteModel 의 기존 패턴 보존.)
13
+ *
14
+ * @typedef {{ id: string, title: string, body: string, created_at: string }} NoteDoc
15
+ */
16
+ export class NoteModel extends MegaModel {
17
+ static adapter = 'mongo'
18
+ static table = 'notes'
19
+
20
+ /**
21
+ * 컬렉션 스키마(ADR-209) — 공통 CRUD 식별자 화이트리스트 + (마이그레이션 생성 시) $jsonSchema validator.
22
+ * `_id` 는 mongo 네이티브 PK(ObjectId), `id` 는 도메인 식별자(UUID, unique). created_at 은 ISO 문자열로 저장.
23
+ */
24
+ static schema = /** @param {any} t */ (t) => ({
25
+ _id: t.objectId().primary(),
26
+ id: t.uuid().notNull().unique(),
27
+ title: t.varchar(120).notNull(),
28
+ body: t.text(),
29
+ created_at: t.timestamptz().notNull(),
30
+ })
31
+
32
+ /** 조회 공개 필드 — 내부 `_id` 를 빼고 도메인 필드만(공통 CRUD select projection). @type {string[]} */
33
+ static PUBLIC_FIELDS = ['id', 'title', 'body', 'created_at']
34
+
35
+ /** 최신순 전체 목록(ADR-212 findMany). @returns {Promise<NoteDoc[]>} */
36
+ static async list() {
37
+ return this.findMany({}, { select: this.PUBLIC_FIELDS, orderBy: [{ column: 'created_at', dir: 'desc' }] })
38
+ }
39
+
40
+ /** 단건 조회(도메인 id=UUID, 없으면 null). @param {string} id @returns {Promise<NoteDoc | null>} */
41
+ static async findById(id) {
42
+ return this.findOne({ id }, { select: this.PUBLIC_FIELDS })
43
+ }
44
+
45
+ /** 생성 — UUID·생성시각을 부여해 삽입(ADR-212 insertOne). 반환은 삽입한 도메인 도큐먼트. @param {{ title: string, body: string }} input @returns {Promise<NoteDoc>} */
46
+ static async create({ title, body }) {
47
+ /** @type {NoteDoc} */
48
+ const doc = { id: randomUUID(), title, body, created_at: new Date().toISOString() }
49
+ await this.insertOne({ ...doc }) // 새 _id 는 mongo 가 부여(반환값은 도메인 doc 사용)
50
+ return doc
51
+ }
52
+
53
+ /**
54
+ * 수정 — 주어진 필드만 $set(undefined 제외). 대상 없으면 null. id 는 unique 라 0|1 매칭(updateOne).
55
+ * @param {string} id @param {{ title?: string, body?: string }} patch @returns {Promise<NoteDoc | null>}
56
+ */
57
+ static async update(id, { title, body }) {
58
+ /** @type {Record<string, string>} */
59
+ const set = {}
60
+ if (title !== undefined) set.title = title
61
+ if (body !== undefined) set.body = body
62
+ if (Object.keys(set).length === 0) return this.findById(id) // 변경 없음
63
+ const n = await this.updateOne({ id }, set)
64
+ return n > 0 ? this.findById(id) : null
65
+ }
66
+
67
+ /** 삭제 — id(unique) 기준(deleteOne). @param {string} id @returns {Promise<boolean>} */
68
+ static async remove(id) {
69
+ return (await this.deleteOne({ id })) > 0
70
+ }
71
+
72
+ /**
73
+ * 전체 도큐먼트 수(캐시 데모용). 도메인 메서드명 `count` 가 CRUD `count` 와 충돌하므로 `super.count()`
74
+ * (부모=ADR-212 CRUD)로 호출한다(자기 재귀 회피). @returns {Promise<number>}
75
+ */
76
+ static async count() {
77
+ return super.count()
78
+ }
79
+ }
@@ -0,0 +1,24 @@
1
+ // @ts-check
2
+ import { MegaModel } from 'mega-framework'
3
+
4
+ /**
5
+ * UserLevelModel — `user_levels`(회원 등급 lookup). type_code 는 user_types(code) 를 FK 참조(비-PK unique
6
+ * 컬럼 참조 — ADR-204 validator 가 허용). idx_user_levels_type_code 인덱스 동반.
7
+ *
8
+ * @typedef {{ id: number, code: string, type_code: string, sort_order: number, name: string, created_at: string }} UserLevelRow
9
+ */
10
+ export class UserLevelModel extends MegaModel {
11
+ static adapter = 'primary'
12
+ static table = 'user_levels'
13
+
14
+ static schema = /** @param {any} t */ (t) => ({
15
+ id: t.serial().primary(),
16
+ code: t.text().notNull().unique().comment('회원 등급 코드'),
17
+ type_code: t.text().notNull().references('UserTypeModel', 'code', { onDelete: 'cascade' }).comment('회원 유형 코드'),
18
+ sort_order: t.integer().notNull().default(0).comment('정렬 순서'),
19
+ name: t.text().notNull().comment('회원 등급명'),
20
+ created_at: t.timestamptz().notNull().default({ raw: 'now()' }).comment('생성 시간'),
21
+ })
22
+
23
+ static indexes = /** @param {any} t */ (t) => [t.index(['type_code'], { name: 'idx_user_levels_type_code' })]
24
+ }
@@ -0,0 +1,146 @@
1
+ // @ts-check
2
+ import { MegaModel } from 'mega-framework'
3
+
4
+ /**
5
+ * UserModel — `users` 테이블(globalKey 'primary' 의 postgres). `static schema`(ADR-204) 선언으로
6
+ * 공통 CRUD(ADR-212)를 켜고 도메인 메서드 본문을 그 CRUD 로 작성한다. 복잡 쿼리(now() 등)는 계측
7
+ * raw `this.query`(ADR-138)와 공존. 모델은 서비스만 import 한다(라우트·컨트롤러 직접 import 는
8
+ * `mega/no-direct-model-import` 가 차단, ADR-022).
9
+ *
10
+ * @typedef {{ id: number|string, username: string, name: string, nickname: string, email: string|null, phone_number: string|null, type_code: string, level_code: string, last_login_at: string|null, created_at: string }} UserRow
11
+ * @typedef {{ id: number|string, username: string, name: string, password_hash: string | null }} UserAuthRow
12
+ */
13
+ export class UserModel extends MegaModel {
14
+ static adapter = 'primary'
15
+ static table = 'users'
16
+
17
+ /**
18
+ * 회원 테이블 스키마(ADR-204) — 손 DDL(20260606000001-create-users)의 `users` 와 동일 컬럼.
19
+ * 선언 시 마이그레이션 자동 생성 + 공통 CRUD(ADR-212)가 함께 켜진다. type_code/level_code 는
20
+ * 원본대로 FK 없는 NOT NULL TEXT(기본값만).
21
+ */
22
+ static schema = /** @param {any} t */ (t) => ({
23
+ id: t.bigSerial().primary(),
24
+ uuid: t.uuid().notNull().default({ raw: 'gen_random_uuid()' }).comment('고유값'),
25
+ username: t.text().notNull().unique().comment('로그인 아이디'),
26
+ password_hash: t.text().notNull().comment('비밀번호 해시'),
27
+ type_code: t.text().notNull().default('USER').comment('회원 유형'),
28
+ level_code: t.text().notNull().default('lv1').comment('회원 등급'),
29
+ name: t.text().notNull().comment('실명'),
30
+ nickname: t.text().notNull().unique().comment('별명'),
31
+ email: t.text().comment('이메일'),
32
+ phone_number: t.text().comment('전화번호'),
33
+ last_login_at: t.timestamptz().comment('마지막 로그인 시간'),
34
+ created_at: t.timestamptz().notNull().default({ raw: 'now()' }).comment('생성 시간'),
35
+ })
36
+
37
+ static indexes = /** @param {any} t */ (t) => [
38
+ t.index(['uuid'], { unique: true, name: 'idx_users_uuid' }),
39
+ t.index(['email'], { name: 'idx_users_email' }),
40
+ t.index(['type_code'], { name: 'idx_users_type_code' }),
41
+ t.index(['level_code'], { name: 'idx_users_level_code' }),
42
+ ]
43
+
44
+ /**
45
+ * 공개 컬럼 화이트리스트 — 조회 응답에 노출하는 컬럼(`password_hash`·`uuid` 제외). CRUD select 에 쓴다.
46
+ * @type {string[]}
47
+ */
48
+ static PUBLIC_COLUMNS = ['id', 'username', 'name', 'nickname', 'email', 'phone_number', 'type_code', 'level_code', 'last_login_at', 'created_at']
49
+
50
+ /** 목록 — 공개 컬럼만, id 오름차순(ADR-212 findMany). @returns {Promise<UserRow[]>} */
51
+ static async list() {
52
+ return this.findMany({}, { select: this.PUBLIC_COLUMNS, orderBy: 'id' })
53
+ }
54
+
55
+ /**
56
+ * 전체 사용자 수 — /demo/redis 캐시 데모가 캐싱한다. 도메인 메서드명 `count` 가 CRUD `count` 와
57
+ * 충돌하므로 `super.count()`(부모=ADR-212 CRUD)로 호출한다(자기 재귀 회피). @returns {Promise<number>}
58
+ */
59
+ static async count() {
60
+ return super.count()
61
+ }
62
+
63
+ /**
64
+ * id 단건(공개 컬럼). 이름 충돌 회피로 `super.findById`(CRUD)에 위임. @param {number|string} id
65
+ * @returns {Promise<UserRow | null>}
66
+ */
67
+ static async findById(id) {
68
+ return super.findById(Number(id), { select: this.PUBLIC_COLUMNS })
69
+ }
70
+
71
+ /**
72
+ * username 으로 단건(공개 컬럼). 로그인 폼·중복 확인용. @param {string} username @returns {Promise<UserRow | null>}
73
+ */
74
+ static async findByUsername(username) {
75
+ return this.findOne({ username }, { select: this.PUBLIC_COLUMNS })
76
+ }
77
+
78
+ /**
79
+ * 새 사용자 생성(관리자/회원가입 공용). 필수: username·passwordHash·name·nickname. 선택: email·
80
+ * phone_number·type_code·level_code(미지정 시 DB 기본값 USER/lv1). 반환은 공개 컬럼만(해시 미노출).
81
+ * @param {{ username: string, passwordHash: string, name: string, nickname: string, email?: string|null, phone_number?: string|null, type_code?: string, level_code?: string }} input
82
+ * @returns {Promise<UserRow>}
83
+ */
84
+ static async create(input) {
85
+ /** @type {Record<string, any>} */
86
+ const data = {
87
+ username: input.username,
88
+ password_hash: input.passwordHash,
89
+ name: input.name,
90
+ nickname: input.nickname,
91
+ email: input.email ?? null,
92
+ phone_number: input.phone_number ?? null,
93
+ }
94
+ if (input.type_code) data.type_code = input.type_code
95
+ if (input.level_code) data.level_code = input.level_code
96
+ const id = await this.insertOne(data) // 기본 returning=false → 새 id
97
+ return /** @type {Promise<UserRow>} */ (this.findById(id))
98
+ }
99
+
100
+ /**
101
+ * 회원가입(/register) — create 와 동일 본문(자동 로그인 흐름은 컨트롤러 책임). 메서드명 유지.
102
+ * @param {{ username: string, passwordHash: string, name: string, nickname: string, email?: string|null, phone_number?: string|null }} input
103
+ * @returns {Promise<UserRow>}
104
+ */
105
+ static async register(input) {
106
+ return this.create(input)
107
+ }
108
+
109
+ /**
110
+ * 부분 수정 — 허용 컬럼(name·nickname·email·phone_number·type_code·level_code)만. username·password 는
111
+ * 별도 흐름(여기서 안 바꿈). 변경 없으면 현재 행 반환. PK filter 라 0|1 매칭(updateOne, ADR-212 #3).
112
+ * @param {number|string} id @param {Record<string, any>} patch @returns {Promise<UserRow | null>}
113
+ */
114
+ static async update(id, patch) {
115
+ /** @type {Record<string, any>} */
116
+ const fields = {}
117
+ for (const k of ['name', 'nickname', 'email', 'phone_number', 'type_code', 'level_code']) {
118
+ if (patch?.[k] !== undefined) fields[k] = patch[k]
119
+ }
120
+ if (Object.keys(fields).length === 0) return this.findById(id) // 변경 없음 — 현재 행
121
+ const n = await this.updateOne({ id: Number(id) }, fields)
122
+ return n > 0 ? this.findById(id) : null
123
+ }
124
+
125
+ /** 삭제 — PK filter(deleteOne, 0|1). @param {number|string} id @returns {Promise<boolean>} 삭제 여부. */
126
+ static async remove(id) {
127
+ return (await this.deleteOne({ id: Number(id) })) > 0
128
+ }
129
+
130
+ /**
131
+ * 로그인 검증용 — username 으로 사용자를 찾아 password_hash 까지 돌려준다. 해시가 필요한 인증 경로에서만
132
+ * 쓴다(일반 조회 list/findById 는 해시 미선택 → 노출 없음). @param {string} username
133
+ * @returns {Promise<UserAuthRow | null>}
134
+ */
135
+ static async findByUsernameWithHash(username) {
136
+ return this.findOne({ username }, { select: ['id', 'username', 'name', 'password_hash'] })
137
+ }
138
+
139
+ /**
140
+ * 로그인 성공 시각 갱신. `now()` 는 SQL 함수라 raw 쿼리 유지(ADR-212 CRUD 는 값 바인딩만 — 오너 합의).
141
+ * @param {number|string} id @returns {Promise<void>}
142
+ */
143
+ static async touchLastLogin(id) {
144
+ await this.query('UPDATE users SET last_login_at = now() WHERE id = $1', [Number(id)])
145
+ }
146
+ }
@@ -0,0 +1,21 @@
1
+ // @ts-check
2
+ import { MegaModel } from 'mega-framework'
3
+
4
+ /**
5
+ * UserTypeModel — `user_types`(회원 유형 lookup). `static schema`(ADR-204) 로 마이그레이션 자동 생성 +
6
+ * 공통 CRUD(ADR-212) 를 함께 켠다. 기존 손 DDL(20260606000001-create-users)의 user_types 와 동일 형태.
7
+ *
8
+ * @typedef {{ id: number, code: string, sort_order: number, name: string, created_at: string }} UserTypeRow
9
+ */
10
+ export class UserTypeModel extends MegaModel {
11
+ static adapter = 'primary'
12
+ static table = 'user_types'
13
+
14
+ static schema = /** @param {any} t */ (t) => ({
15
+ id: t.serial().primary(),
16
+ code: t.text().notNull().unique().comment('회원 유형 코드'),
17
+ sort_order: t.integer().notNull().default(0).comment('정렬 순서'),
18
+ name: t.text().notNull().comment('회원 유형명'),
19
+ created_at: t.timestamptz().notNull().default({ raw: 'now()' }).comment('생성 시간'),
20
+ })
21
+ }