mega-framework 0.1.10 → 0.1.13

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 (91) hide show
  1. package/README.md +14 -4
  2. package/package.json +23 -21
  3. package/sample/crud/.env +10 -2
  4. package/sample/crud/.env.example +8 -0
  5. package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
  6. package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
  7. package/sample/crud/apps/main/locales/server/en.json +31 -1
  8. package/sample/crud/apps/main/locales/server/ko.json +31 -1
  9. package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
  10. package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
  11. package/sample/crud/apps/main/routes/bus.js +43 -0
  12. package/sample/crud/apps/main/routes/lock.js +35 -0
  13. package/sample/crud/apps/main/services/jobs-demo-service.js +21 -14
  14. package/sample/crud/apps/main/views/bus/index.ejs +80 -0
  15. package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
  16. package/sample/crud/apps/main/views/lock/index.ejs +99 -0
  17. package/sample/crud/docs/guide/03-service-model-db.md +110 -6
  18. package/sample/crud/docs/guide/05-scheduler-job-worker.md +3 -1
  19. package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
  20. package/sample/crud/docs/guide/10-multi-app.md +74 -0
  21. package/sample/crud/mega.config.js +32 -0
  22. package/sample/crud/package.json +3 -2
  23. package/sample/multi/.env +16 -0
  24. package/sample/multi/.env.example +17 -0
  25. package/sample/multi/README.md +54 -0
  26. package/sample/multi/apps/admin/app.config.js +24 -0
  27. package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
  28. package/sample/multi/apps/admin/public/js/admin.js +31 -0
  29. package/sample/multi/apps/admin/routes/pages.js +11 -0
  30. package/sample/multi/apps/admin/views/index.ejs +33 -0
  31. package/sample/multi/apps/web/app.config.js +30 -0
  32. package/sample/multi/apps/web/controllers/web-controller.js +45 -0
  33. package/sample/multi/apps/web/public/js/web.js +24 -0
  34. package/sample/multi/apps/web/routes/pages.js +13 -0
  35. package/sample/multi/apps/web/views/index.ejs +51 -0
  36. package/sample/multi/mega.config.js +42 -0
  37. package/sample/multi/package.json +20 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/nats-adapter.js +39 -44
  40. package/src/adapters/nats-codec.js +38 -0
  41. package/src/cli/commands/scaffold.js +1 -0
  42. package/src/cli/index.js +9 -1
  43. package/src/core/app-registry.js +69 -0
  44. package/src/core/boot.js +99 -0
  45. package/src/core/bus/cluster-bus.js +190 -0
  46. package/src/core/bus/contract.js +123 -0
  47. package/src/core/bus/index.js +285 -0
  48. package/src/core/bus/memory-bus.js +103 -0
  49. package/src/core/bus/nats-bus.js +203 -0
  50. package/src/core/config-validator.js +118 -1
  51. package/src/core/ctx-builder.js +14 -1
  52. package/src/core/index.js +2 -0
  53. package/src/core/lock/cluster-lock.js +174 -0
  54. package/src/core/lock/contract.js +123 -0
  55. package/src/core/lock/fifo-waitlist.js +93 -0
  56. package/src/core/lock/index.js +292 -0
  57. package/src/core/lock/memory-lock.js +162 -0
  58. package/src/core/lock/redis-lock.js +276 -0
  59. package/src/core/mega-app.js +29 -0
  60. package/src/core/migration/generate.js +1 -1
  61. package/src/core/migration/journal.js +1 -1
  62. package/src/core/scope-registry.js +9 -0
  63. package/src/eslint-plugin/no-direct-model-import.js +2 -2
  64. package/src/index.js +2 -0
  65. package/src/lib/mega-job-queue.js +71 -47
  66. package/templates/model/code-mongo.tpl +1 -9
  67. package/templates/model/code.tpl +1 -10
  68. package/templates/model/test-mongo.tpl +1 -23
  69. package/templates/model/test.tpl +0 -17
  70. package/types/adapters/mega-adapter.d.ts +1 -1
  71. package/types/adapters/nats-adapter.d.ts +4 -4
  72. package/types/adapters/nats-codec.d.ts +13 -0
  73. package/types/adapters/redlock-adapter.d.ts +1 -1
  74. package/types/core/app-registry.d.ts +22 -0
  75. package/types/core/bus/cluster-bus.d.ts +45 -0
  76. package/types/core/bus/contract.d.ts +164 -0
  77. package/types/core/bus/index.d.ts +100 -0
  78. package/types/core/bus/memory-bus.d.ts +45 -0
  79. package/types/core/bus/nats-bus.d.ts +41 -0
  80. package/types/core/index.d.ts +1 -0
  81. package/types/core/lock/cluster-lock.d.ts +44 -0
  82. package/types/core/lock/contract.d.ts +181 -0
  83. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  84. package/types/core/lock/index.d.ts +96 -0
  85. package/types/core/lock/memory-lock.d.ts +58 -0
  86. package/types/core/lock/redis-lock.d.ts +43 -0
  87. package/types/core/mega-app.d.ts +10 -0
  88. package/types/core/scope-registry.d.ts +6 -0
  89. package/types/index.d.ts +1 -1
  90. package/types/lib/mega-job-queue.d.ts +27 -4
  91. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -0,0 +1,122 @@
1
+ // @ts-check
2
+ /*
3
+ * /demo/lock 클라이언트 — 분산 락 데모(ADR-226).
4
+ *
5
+ * - 실행/시도: 버튼이 /demo/lock/run · /demo/lock/try 에 JSON POST(폼 아님 → CSRF 토큰 면제 + Origin 검증,
6
+ * ADR-051)로 옵션을 보내 ctx.lock.with / tryAcquire 결과(획득 여부·워커 PID·대기시간·fence)를 보여준다.
7
+ * - 상태: 2초마다 /demo/lock/status 를 폴링해 보유/대기 수(stats)와 워커별 최근 실행 로그를 갱신한다(탭 숨김 시 멈춤).
8
+ * 폴링 간격은 앱 rate limit(100/분, ADR-073) 안에 둔다.
9
+ */
10
+ ;(function () {
11
+ var $ = function (/** @type {string} */ id) {
12
+ return /** @type {any} */ (document.getElementById(id))
13
+ }
14
+ function esc(/** @type {any} */ s) {
15
+ return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
16
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
17
+ })
18
+ }
19
+
20
+ function readBody() {
21
+ return {
22
+ key: $('lk-key').value || 'demo:resource',
23
+ ttl: Number($('lk-ttl').value) || 5000,
24
+ waitMs: Number($('lk-wait').value),
25
+ holdMs: Number($('lk-hold').value),
26
+ fifo: $('lk-fifo').checked,
27
+ fence: $('lk-fence').checked,
28
+ extendable: $('lk-ext').checked,
29
+ }
30
+ }
31
+
32
+ function postJson(/** @type {string} */ url, /** @type {any} */ body) {
33
+ return fetch(url, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
36
+ body: JSON.stringify(body),
37
+ }).then(function (res) {
38
+ return res.json().then(function (j) {
39
+ if (!res.ok) throw new Error((j && j.error && j.error.code) || 'http ' + res.status)
40
+ return j.data != null ? j.data : j
41
+ })
42
+ })
43
+ }
44
+
45
+ function showResult(/** @type {any} */ d) {
46
+ var ok = d.acquired
47
+ var cls = ok ? 'alert-success' : 'alert-warning'
48
+ var label = ok ? '획득' : d.reason === 'held' ? '이미 보유 중(즉시 실패)' : '경합 — 대기 후 실패'
49
+ var bits = ['worker PID <code>' + esc(d.pid) + '</code>']
50
+ if (d.waitedMs != null) bits.push('대기 ' + esc(d.waitedMs) + 'ms')
51
+ if (d.heldMs != null) bits.push('보유 ' + esc(d.heldMs) + 'ms')
52
+ if (d.fence != null) bits.push('fence <code>' + esc(d.fence) + '</code>')
53
+ $('lk-result').innerHTML =
54
+ '<div class="alert ' + cls + ' py-2 px-3 mb-0"><strong>' + esc(label) + '</strong> · ' + bits.join(' · ') + '</div>'
55
+ }
56
+
57
+ function run(/** @type {string} */ url) {
58
+ $('lk-result').innerHTML = '<div class="text-body-secondary">실행 중…</div>'
59
+ postJson(url, readBody())
60
+ .then(showResult)
61
+ .catch(function (e) {
62
+ $('lk-result').innerHTML = '<div class="alert alert-danger py-2 px-3 mb-0">오류: ' + esc(e.message) + '</div>'
63
+ })
64
+ }
65
+
66
+ $('lk-run').addEventListener('click', function () {
67
+ run('/demo/lock/run')
68
+ })
69
+ $('lk-try').addEventListener('click', function () {
70
+ run('/demo/lock/try')
71
+ })
72
+
73
+ // ── 상태 폴링 ─────────────────────────────────────────────────────────────
74
+ var POLL_MS = 2000
75
+ function renderLog(/** @type {any[]} */ rows) {
76
+ $('lk-log').innerHTML = (rows || [])
77
+ .map(function (r) {
78
+ var res = r.acquired ? '<span class="text-success">획득</span>' : '<span class="text-warning">' + (r.reason === 'held' ? 'held' : 'wait') + '</span>'
79
+ if (r.fence != null) res += ' f' + esc(r.fence)
80
+ return (
81
+ '<tr><td class="text-body-secondary">' +
82
+ esc((r.at || '').slice(11, 19)) +
83
+ '</td><td><code>' +
84
+ esc(r.key) +
85
+ '</code></td><td>' +
86
+ esc(r.pid) +
87
+ '</td><td>' +
88
+ res +
89
+ '</td><td>' +
90
+ (r.waitedMs != null ? esc(r.waitedMs) + 'ms' : '–') +
91
+ '</td></tr>'
92
+ )
93
+ })
94
+ .join('')
95
+ }
96
+ function poll() {
97
+ if (document.hidden) {
98
+ setTimeout(poll, POLL_MS)
99
+ return
100
+ }
101
+ fetch('/demo/lock/status', { headers: { accept: 'application/json' } })
102
+ .then(function (res) {
103
+ return res.json()
104
+ })
105
+ .then(function (j) {
106
+ var d = j.data != null ? j.data : j
107
+ if (d.stats) {
108
+ $('lk-active').textContent = String(d.stats.active)
109
+ $('lk-waiting').textContent = String(d.stats.waiting)
110
+ $('lk-driver').textContent = d.stats.driver
111
+ }
112
+ renderLog(d.runLog)
113
+ })
114
+ .catch(function () {
115
+ /* 폴링 실패는 비치명적 — 다음 주기에 재시도 */
116
+ })
117
+ .then(function () {
118
+ setTimeout(poll, POLL_MS)
119
+ })
120
+ }
121
+ poll()
122
+ })()
@@ -0,0 +1,43 @@
1
+ // @ts-check
2
+ /**
3
+ * apps/main/routes/bus.js — /demo/bus 메시지 버스 데모 UI 라우트(자동 로딩, loadRoutes, ADR-157).
4
+ *
5
+ * `ctx.bus.emit/.on/.request`(ADR-227) 사용자 메시지 버스 API 를 시연한다. 페이지는 `webRequireAuth`(ADR-155)로
6
+ * 보호한다. 발행/요청/수신조회는 페이지가 fetch(JSON)로 호출 — JSON POST 는 CSRF 면제 + Origin 검사라
7
+ * body 스키마는 `additionalProperties:false` 로 닫는다(ADR-074 envelope).
8
+ */
9
+ import { BusController } from '../controllers/bus-controller.js'
10
+ import { webRequireAuth } from '../middleware/web-auth.js'
11
+
12
+ /** emit body — subject(구체, wildcard 금지는 매니저가 검증) + payload(임의 객체) + 옵트인 플래그. */
13
+ const emitBody = {
14
+ type: 'object',
15
+ required: ['subject'],
16
+ properties: {
17
+ subject: { type: 'string', minLength: 1, maxLength: 120 },
18
+ payload: { type: 'object' },
19
+ persist: { type: 'boolean' },
20
+ ordered: { type: 'boolean' },
21
+ },
22
+ additionalProperties: false,
23
+ }
24
+
25
+ /** request body — subject + payload + timeout. */
26
+ const requestBody = {
27
+ type: 'object',
28
+ properties: {
29
+ subject: { type: 'string', minLength: 1, maxLength: 120 },
30
+ payload: { type: 'object' },
31
+ timeout: { type: 'integer', minimum: 100, maximum: 10_000 },
32
+ },
33
+ additionalProperties: false,
34
+ }
35
+
36
+ export default (/** @type {any} */ router) => {
37
+ /** @type {{ before: Function[] }} 데모 UI 보호 가드(로그인 필요). */
38
+ const guarded = { before: [webRequireAuth] }
39
+ router.http.get('/demo/bus', BusController.index, guarded)
40
+ router.http.post('/demo/bus/emit', BusController.emit, { ...guarded, schema: { body: emitBody } })
41
+ router.http.post('/demo/bus/request', BusController.request, { ...guarded, schema: { body: requestBody } })
42
+ router.http.get('/demo/bus/events', BusController.events, guarded)
43
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ /**
3
+ * apps/main/routes/lock.js — /demo/lock 분산 락 데모 UI 라우트(자동 로딩, loadRoutes, ADR-157).
4
+ *
5
+ * `ctx.lock.with/.tryAcquire`(ADR-226) 사용자 분산 락 API 를 시연한다. 페이지는 `webRequireAuth`(ADR-155)로
6
+ * 보호한다. 락 실행/시도/상태 조회는 페이지가 fetch(JSON)로 호출한다 — JSON POST 는 CSRF 토큰 면제 + Origin
7
+ * 검사라(ADR-074 envelope) body 스키마는 `additionalProperties:false` 로 닫는다.
8
+ */
9
+ import { LockController } from '../controllers/lock-controller.js'
10
+ import { webRequireAuth } from '../middleware/web-auth.js'
11
+
12
+ /** run/tryRun body 스키마 — 타입·범위만 본다(ttl/waitMs/holdMs 는 데모 안전 상한). */
13
+ const runBody = {
14
+ type: 'object',
15
+ required: ['key'],
16
+ properties: {
17
+ key: { type: 'string', minLength: 1, maxLength: 80 },
18
+ ttl: { type: 'integer', minimum: 100, maximum: 60_000 },
19
+ waitMs: { type: 'integer', minimum: 0, maximum: 30_000 },
20
+ holdMs: { type: 'integer', minimum: 0, maximum: 15_000 },
21
+ fifo: { type: 'boolean' },
22
+ fence: { type: 'boolean' },
23
+ extendable: { type: 'boolean' },
24
+ },
25
+ additionalProperties: false,
26
+ }
27
+
28
+ export default (/** @type {any} */ router) => {
29
+ /** @type {{ before: Function[] }} 데모 UI 보호 가드(로그인 필요). */
30
+ const guarded = { before: [webRequireAuth] }
31
+ router.http.get('/demo/lock', LockController.index, guarded)
32
+ router.http.post('/demo/lock/run', LockController.run, { ...guarded, schema: { body: runBody } })
33
+ router.http.post('/demo/lock/try', LockController.tryRun, { ...guarded, schema: { body: runBody } })
34
+ router.http.get('/demo/lock/status', LockController.status, guarded)
35
+ }
@@ -1,9 +1,12 @@
1
1
  // @ts-check
2
2
  import { MegaService, MegaJobQueue } from 'mega-framework'
3
+ // raw nats 핸들(`.native`, ADR-009)로 DLQ 스트림을 직접 읽으려면 드라이버 v3 API 를 쓴다(ADR-225):
4
+ // jetstreamManager 는 함수, not-found 는 JetStreamApiError(code) 로 바뀌었다. .native 는 버전-결합 escape hatch.
5
+ import { jetstreamManager, JetStreamApiError, JetStreamApiCodes } from '@nats-io/jetstream'
3
6
  import { EmailJob } from '../jobs/email-job.js'
4
7
 
5
- /** JetStream NOT_FOUND API 에러 코드(스트림/메시지 없음) — mega-job-queue 동일 상수와 정합. */
6
- const NOT_FOUND_CODE = '404'
8
+ /** JetStream stream-not-found 판별(v3) — `e instanceof JetStreamApiError && code===StreamNotFound`. @param {unknown} e @returns {boolean} */
9
+ const isStreamNotFound = (e) => e instanceof JetStreamApiError && e.code === JetStreamApiCodes.StreamNotFound
7
10
 
8
11
  /**
9
12
  * EmailJob 의 DLQ 스트림/서브젝트 이름. MegaJobQueue 는 `<subject>.dlq` 로 발행하고 DLQ 스트림을
@@ -53,30 +56,34 @@ export class JobsDemoService extends MegaService {
53
56
  */
54
57
  async dlq() {
55
58
  const nc = this.ctx.bus('jobs').native
56
- const jsm = await nc.jetstreamManager()
59
+ const jsm = await jetstreamManager(nc)
57
60
  let count = 0
58
61
  try {
59
62
  const info = await jsm.streams.info(DLQ_STREAM)
60
63
  count = info.state.messages
61
64
  } catch (e) {
62
- // 스트림 미존재(404)는 "DLQ 로 간 잡이 아직 없음" — 빈 상태로 본다. 그 외(연결 장애 등)는 전파.
63
- if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e
64
- return { count: 0, latest: null }
65
+ // 스트림 미존재는 "DLQ 로 간 잡이 아직 없음" — 빈 상태(count=0)로 본다. 그 외(연결 장애 등)는 전파.
66
+ if (!isStreamNotFound(e)) throw e
67
+ return { count, latest: null }
65
68
  }
66
69
  if (count === 0) return { count, latest: null }
67
70
  let latest = null
68
71
  try {
72
+ // v3(ADR-225): getMessage 는 메시지 없음을 **null** 로 반환한다(v2 는 404 throw 였다). count>0 직후
73
+ // 경쟁 삭제로 null 이면 latest 없이 카운트만 보여준다.
69
74
  const msg = await jsm.streams.getMessage(DLQ_STREAM, { last_by_subj: DLQ_SUBJECT })
70
- const body = /** @type {any} */ (msg.json())
71
- latest = {
72
- failedAt: body.failedAt,
73
- deliveryCount: body.deliveryCount,
74
- error: body.error?.message ?? String(body.error ?? ''),
75
- payload: body.payload,
75
+ if (msg) {
76
+ const body = /** @type {any} */ (msg.json())
77
+ latest = {
78
+ failedAt: body.failedAt,
79
+ deliveryCount: body.deliveryCount,
80
+ error: body.error?.message ?? String(body.error ?? ''),
81
+ payload: body.payload,
82
+ }
76
83
  }
77
84
  } catch (e) {
78
- // 카운트는 있으나 마지막 메시지 조회가 404(경쟁 삭제 등)면 latest 없이 카운트만 보여준다. 외는 전파.
79
- if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e
85
+ // 카운트 조회 스트림이 통째로 사라진 race 면 latest 없이 카운트만.외(연결 장애 등)는 전파.
86
+ if (!isStreamNotFound(e)) throw e
80
87
  }
81
88
  return { count, latest }
82
89
  }
@@ -0,0 +1,80 @@
1
+ <% layout('layouts/main') %>
2
+
3
+ <div class="mb-4">
4
+ <h1 class="h3 mb-1"><%= t('bus_title', { defaultValue: '메시지 버스 데모' }) %></h1>
5
+ <p class="text-body-secondary small mb-0">
6
+ <%= t('bus_subtitle', { defaultValue: 'ctx.bus.emit / on / request 로 이벤트를 주고받습니다. 이 페이지는 demo.> 를 구독해 수신 이벤트를 실시간(폴링) 표시합니다 — 다른 워커가 발행해도 fan-out 으로 도착합니다.' }) %>
7
+ <a href="/guide" class="ms-1">ADR-227 · 가이드</a>
8
+ </p>
9
+ </div>
10
+
11
+ <div class="row g-3">
12
+ <!-- 발행 + 요청 -->
13
+ <div class="col-lg-5">
14
+ <div class="card mb-3">
15
+ <div class="card-body">
16
+ <h2 class="h5 card-title"><%= t('bus_emit_title', { defaultValue: '발행 (emit)' }) %></h2>
17
+ <p class="card-text text-body-secondary small mb-2"><%= t('bus_emit_desc', { defaultValue: 'subject 와 payload(JSON)를 fan-out 발행합니다. persist:true 면 JetStream 에 저장됩니다.' }) %></p>
18
+ <label class="form-label small mb-1" for="bus-subject">subject</label>
19
+ <input id="bus-subject" class="form-control form-control-sm mb-2" value="demo.order.created" maxlength="120">
20
+ <div class="d-flex flex-wrap gap-1 mb-2">
21
+ <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-2 bus-preset" data-s="demo.order.created">order.created</button>
22
+ <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-2 bus-preset" data-s="demo.order.created.eu">order.created.eu</button>
23
+ <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-2 bus-preset" data-s="demo.user.login">user.login</button>
24
+ </div>
25
+ <label class="form-label small mb-1" for="bus-payload">payload (JSON)</label>
26
+ <textarea id="bus-payload" class="form-control form-control-sm font-monospace" rows="3">{ "orderId": 42 }</textarea>
27
+ <div class="d-flex flex-wrap gap-3 mt-2">
28
+ <div class="form-check form-switch">
29
+ <input id="bus-persist" class="form-check-input" type="checkbox">
30
+ <label class="form-check-label small" for="bus-persist">persist <span class="text-body-secondary">(JetStream)</span></label>
31
+ </div>
32
+ <div class="form-check form-switch">
33
+ <input id="bus-ordered" class="form-check-input" type="checkbox">
34
+ <label class="form-check-label small" for="bus-ordered">ordered</label>
35
+ </div>
36
+ </div>
37
+ <button id="bus-emit" class="btn btn-primary btn-sm mt-2"><%= t('bus_btn_emit', { defaultValue: 'Emit' }) %></button>
38
+ <div id="bus-emit-result" class="mt-2 small"></div>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="card">
43
+ <div class="card-body">
44
+ <h2 class="h5 card-title"><%= t('bus_req_title', { defaultValue: '요청/응답 (request)' }) %></h2>
45
+ <p class="card-text text-body-secondary small mb-2"><%= t('bus_req_desc', { defaultValue: 'demo.echo 응답자가 첫 응답을 돌려줍니다 — 어느 워커가 답했는지 PID 로 보입니다.' }) %></p>
46
+ <label class="form-label small mb-1" for="bus-req-payload">payload (JSON)</label>
47
+ <textarea id="bus-req-payload" class="form-control form-control-sm font-monospace" rows="2">{ "ping": 1 }</textarea>
48
+ <button id="bus-request" class="btn btn-outline-primary btn-sm mt-2"><%= t('bus_btn_request', { defaultValue: 'Request demo.echo' }) %></button>
49
+ <div id="bus-req-result" class="mt-2 small"></div>
50
+ <div class="mt-2"><code class="small">ctx.bus.request('demo.echo', payload, { timeout })</code></div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- 수신 패널 -->
56
+ <div class="col-lg-7">
57
+ <div class="card h-100">
58
+ <div class="card-body d-flex flex-column">
59
+ <div class="d-flex justify-content-between align-items-center">
60
+ <h2 class="h5 card-title mb-0"><%= t('bus_recv_title', { defaultValue: '수신 이벤트' }) %></h2>
61
+ <div class="small">
62
+ driver <span class="badge text-bg-secondary" id="bus-driver"><%= stats ? stats.driver : '?' %></span>
63
+ · <%= t('bus_recv_pid', { defaultValue: '구독 워커' }) %> <code id="bus-pid"><%= pid %></code>
64
+ </div>
65
+ </div>
66
+ <p class="text-body-secondary small mb-2">
67
+ <%= t('bus_recv_desc', { defaultValue: '서버가 demo.> 를 구독합니다 — * 는 한 토큰, > 는 꼬리 전체. order.created / order.created.eu / user.login 모두 demo.> 에 매칭됩니다. persist 이벤트는 배지로 구분됩니다.' }) %>
68
+ </p>
69
+ <div class="table-responsive flex-grow-1" style="max-height: 520px; overflow-y: auto;">
70
+ <table class="table table-sm small mb-0">
71
+ <thead><tr><th>at</th><th>subject</th><th>persist</th><th><%= t('bus_recv_pid_col', { defaultValue: '수신 워커' }) %></th><th>payload</th></tr></thead>
72
+ <tbody id="bus-events"></tbody>
73
+ </table>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+
80
+ <script src="/static/js/bus-demo.js"></script>
@@ -45,6 +45,8 @@
45
45
  <li><a class="dropdown-item" href="/demo/ws"><%= t('nav_ws', '채팅 (WS+ASP)') %></a></li>
46
46
  <li><a class="dropdown-item" href="/demo/cron"><%= t('nav_cron', '스케줄러 (Cron)') %></a></li>
47
47
  <li><a class="dropdown-item" href="/demo/jobs"><%= t('nav_jobs', '잡 큐 (NATS)') %></a></li>
48
+ <li><a class="dropdown-item" href="/demo/lock"><%= t('nav_lock', '분산 락 (Lock)') %></a></li>
49
+ <li><a class="dropdown-item" href="/demo/bus"><%= t('nav_bus', '메시지 버스 (Bus)') %></a></li>
48
50
  <li><a class="dropdown-item" href="/demo/worker"><%= t('nav_worker', '워커 (Threads)') %></a></li>
49
51
  <li><hr class="dropdown-divider" /></li>
50
52
  <li><a class="dropdown-item" href="/demo/metrics"><%= t('nav_metrics', '메트릭') %></a></li>
@@ -0,0 +1,99 @@
1
+ <% layout('layouts/main') %>
2
+
3
+ <div class="mb-4">
4
+ <h1 class="h3 mb-1"><%= t('lock_title', { defaultValue: '분산 락 데모' }) %></h1>
5
+ <p class="text-body-secondary small mb-0">
6
+ <%= t('lock_subtitle', { defaultValue: 'ctx.lock.with / tryAcquire 로 임계구역을 보호합니다. 두 탭에서 같은 키로 동시에 실행하면 한 번에 하나만 들어가고(상호배제), FIFO 면 도착 순서대로 깨어납니다.' }) %>
7
+ <a href="/guide" class="ms-1">ADR-226 · 가이드</a>
8
+ </p>
9
+ </div>
10
+
11
+ <div class="row g-3">
12
+ <!-- 실행 폼 -->
13
+ <div class="col-lg-7">
14
+ <div class="card h-100">
15
+ <div class="card-body">
16
+ <h2 class="h5 card-title"><%= t('lock_run_title', { defaultValue: '임계구역 실행' }) %></h2>
17
+ <p class="card-text text-body-secondary small"><%= t('lock_run_desc', { defaultValue: '키를 잡고 holdMs 동안 점유한 뒤 자동 해제합니다(ctx.lock.with). 다른 시도는 waitMs 까지 대기합니다.' }) %></p>
18
+
19
+ <div class="row g-2">
20
+ <div class="col-12">
21
+ <label class="form-label small mb-1" for="lk-key"><%= t('lock_f_key', { defaultValue: '자원 키' }) %></label>
22
+ <input id="lk-key" class="form-control form-control-sm" value="demo:resource" maxlength="80">
23
+ </div>
24
+ <div class="col-6 col-md-3">
25
+ <label class="form-label small mb-1" for="lk-ttl">ttl (ms)</label>
26
+ <input id="lk-ttl" type="number" class="form-control form-control-sm" value="5000" min="100" max="60000">
27
+ </div>
28
+ <div class="col-6 col-md-3">
29
+ <label class="form-label small mb-1" for="lk-wait">waitMs</label>
30
+ <input id="lk-wait" type="number" class="form-control form-control-sm" value="3000" min="0" max="30000">
31
+ </div>
32
+ <div class="col-6 col-md-3">
33
+ <label class="form-label small mb-1" for="lk-hold">holdMs</label>
34
+ <input id="lk-hold" type="number" class="form-control form-control-sm" value="2500" min="0" max="15000">
35
+ </div>
36
+ </div>
37
+
38
+ <div class="d-flex flex-wrap gap-3 mt-3">
39
+ <div class="form-check form-switch">
40
+ <input id="lk-fifo" class="form-check-input" type="checkbox">
41
+ <label class="form-check-label small" for="lk-fifo">fifo <span class="text-body-secondary"><%= t('lock_f_fifo', { defaultValue: '(도착 순서 보장)' }) %></span></label>
42
+ </div>
43
+ <div class="form-check form-switch">
44
+ <input id="lk-fence" class="form-check-input" type="checkbox">
45
+ <label class="form-check-label small" for="lk-fence">fence <span class="text-body-secondary"><%= t('lock_f_fence', { defaultValue: '(단조 토큰)' }) %></span></label>
46
+ </div>
47
+ <div class="form-check form-switch">
48
+ <input id="lk-ext" class="form-check-input" type="checkbox">
49
+ <label class="form-check-label small" for="lk-ext">extendable <span class="text-body-secondary"><%= t('lock_f_ext', { defaultValue: '(watchdog 자동연장)' }) %></span></label>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="d-flex gap-2 mt-3">
54
+ <button id="lk-run" class="btn btn-primary btn-sm"><%= t('lock_btn_run', { defaultValue: '실행 (with)' }) %></button>
55
+ <button id="lk-try" class="btn btn-outline-secondary btn-sm"><%= t('lock_btn_try', { defaultValue: 'tryAcquire' }) %></button>
56
+ </div>
57
+
58
+ <div id="lk-result" class="mt-3 small"></div>
59
+ <div class="mt-3"><code class="small">ctx.lock.with(key, { ttl, waitMs, fifo, fence, extendable }, fn)</code></div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <!-- 상태 패널 -->
65
+ <div class="col-lg-5">
66
+ <div class="card h-100">
67
+ <div class="card-body">
68
+ <h2 class="h5 card-title"><%= t('lock_status_title', { defaultValue: '현재 상태' }) %></h2>
69
+ <div class="d-flex gap-4 mb-2">
70
+ <div>
71
+ <div class="h4 fw-bold mb-0" id="lk-active"><%= stats ? stats.active : '–' %></div>
72
+ <div class="text-body-secondary small"><%= t('lock_active', { defaultValue: '보유 중' }) %></div>
73
+ </div>
74
+ <div>
75
+ <div class="h4 fw-bold mb-0" id="lk-waiting"><%= stats ? stats.waiting : '–' %></div>
76
+ <div class="text-body-secondary small"><%= t('lock_waiting', { defaultValue: '대기 중' }) %></div>
77
+ </div>
78
+ <div>
79
+ <div class="h6 fw-bold mb-0"><span class="badge text-bg-secondary" id="lk-driver"><%= stats ? stats.driver : '?' %></span></div>
80
+ <div class="text-body-secondary small">driver</div>
81
+ </div>
82
+ </div>
83
+ <p class="text-body-secondary small mb-2">
84
+ <%= t('lock_worker_pid', { defaultValue: '이 페이지를 처리한 워커 PID' }) %>: <code><%= pid %></code>
85
+ </p>
86
+ <hr>
87
+ <h3 class="h6"><%= t('lock_log_title', { defaultValue: '최근 실행 (이 워커)' }) %></h3>
88
+ <div class="table-responsive" style="max-height: 320px; overflow-y: auto;">
89
+ <table class="table table-sm small mb-0">
90
+ <thead><tr><th>at</th><th>key</th><th>pid</th><th><%= t('lock_log_result', { defaultValue: '결과' }) %></th><th>wait</th></tr></thead>
91
+ <tbody id="lk-log"></tbody>
92
+ </table>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <script src="/static/js/lock-demo.js"></script>
@@ -636,6 +636,54 @@ caches: {
636
636
 
637
637
  이 셋은 본 문서(Service+Model+DB) 범위 밖이라 이름만 짚는다.
638
638
 
639
+ ### 분산 락 사용자 API — `ctx.lock.with/.acquire` (ADR-226)
640
+
641
+ 여러 인스턴스가 같은 자원을 동시에 건드리면 안 될 때(재고 차감, 1회성 작업 등) **분산 락**으로 임계구역을
642
+ 보호한다. 위 `redlock`(`ctx.lock('main')`, 스케줄 leader election)과 **별개 하위시스템**으로, 별명 선언 없이
643
+ 컨트롤러·서비스에서 바로 쓴다.
644
+
645
+ ```js
646
+ // 권장 — 잠그고, 끝나면(성공/예외 무관) 자동 해제
647
+ await ctx.lock.with('stock:sku-42', { ttl: 5000 }, async () => {
648
+ const row = await stock.byId(42)
649
+ await stock.update(42, { qty: row.qty - 1 }) // 이 블록은 노드를 넘어 한 번에 하나만 실행
650
+ })
651
+
652
+ // 즉시 1회 시도(대기 없음) — 못 잡으면 null
653
+ const lock = await ctx.lock.tryAcquire('job:nightly')
654
+ if (lock) { try { /* ... */ } finally { await lock.release() } }
655
+ ```
656
+
657
+ - **옵션**: `ttl`(보유 ms) · `waitMs`(획득 대기 ms, 0=즉시) · `fifo`(도착 순서 보장) · `fence`(단조 토큰
658
+ `lock.fence`) · `extendable`(긴 작업 자동 연장). `acquire` 는 못 잡으면 `lock.not_acquired`(409) throw,
659
+ `tryAcquire` 는 `null`.
660
+ - **driver 자동 폴백**: `mega.config.js` 의 `lock` 블록(주석 처리됨 — 풀어서 `cache: 'lock'` 지정)이 있으면
661
+ **redis**(진짜 분산), 없고 클러스터 워커면 **cluster**(단일 노드), 그 외엔 **memory**(단일 프로세스, 부팅 경고).
662
+ redis 활성에 **추가 .env 는 불필요** — 기존 `caches.lock`(`REDIS_LOCK_URL`)을 재사용한다.
663
+
664
+ ### 메시지 버스 사용자 API — `ctx.bus.emit/.on/.request` (ADR-227)
665
+
666
+ 서비스끼리 이벤트로 느슨하게 결합할 때(주문 생성 → 이메일·분석 각각 반응) **메시지 버스**를 쓴다. 위
667
+ `services.buses`(`ctx.bus('jobs')`, 잡 큐)와 **별개 하위시스템**으로, 별명 선언 없이 발행/구독한다.
668
+
669
+ ```js
670
+ // fan-out — 구독자 전원 수신 (핸들러는 payload, meta)
671
+ ctx.bus.emit('order.created', { orderId: 42 }, { meta: { traceId } })
672
+ ctx.bus.on('order.created', async (payload) => { await emailService.sendReceipt(payload.orderId) })
673
+ ctx.bus.on('order.*', async (payload, meta) => { /* order.created/updated 등 — '*' 는 한 토큰 */ })
674
+
675
+ // request/reply — 핸들러 '반환값'이 응답
676
+ ctx.bus.on('catalog.product', async ({ id }) => ({ id, name: 'widget' }))
677
+ const product = await ctx.bus.request('catalog.product', { id: 1 }, { timeout: 1000 })
678
+ ```
679
+
680
+ - **wildcards**(NATS 식): `order.*` = 한 토큰(`order.created` O, `order.created.eu` X), `order.>` = 꼬리 전체.
681
+ - **옵트인**: `{ persist: true }`(JetStream 영속 — nats driver 만; 그 외엔 경고 후 비영속) · `{ ordered: true }`(순서 보장).
682
+ 한 구독자가 throw 해도 다른 구독자·다음 메시지엔 영향 없다(매니저가 잡아 로깅).
683
+ - **driver 자동 폴백**: `mega.config.js` 의 `bus` 블록(주석 처리됨 — 풀어서 `nats: 'jobs'` 지정)이 있으면
684
+ **nats**(진짜 분산), 없고 클러스터 워커면 **cluster**(단일 노드), 그 외엔 **memory**(단일 프로세스, 부팅 경고).
685
+ nats 활성에 **추가 .env 는 불필요** — 기존 `buses.jobs`(`NATS_JOBS_URL`)을 재사용한다.
686
+
639
687
  ---
640
688
 
641
689
  ## 5. mega migrate — 스키마 마이그레이션 (ADR-149)
@@ -754,12 +802,68 @@ export class User extends MegaModel {
754
802
 
755
803
  - **옵트인**이다 — `static schema` 가 없는 모델(레거시 raw SQL 운용)은 그냥 무시된다.
756
804
  - 같은 `static schema` 선언은 **공통 CRUD([§2-1](#2-1-공통-crud-static-schema-opt-in-adr-212))도 함께 켠다**(SQL 어댑터) — "스키마 1선언 = 마이그레이션 + CRUD".
757
- - 타입: `serial/bigSerial/integer/bigInteger/smallInteger/real/doublePrecision/decimal(p,s)/varchar(n)/
758
- text/char(n)/boolean/timestamp/timestamptz/date/time/uuid/json/jsonb/enum(values)/bytea`.
759
- - 체인: `.primary() .notNull() .unique({name?}) .default(v | {raw}) .defaultNow() .check(expr,{name?})
760
- .references(model, col, {onDelete?, onUpdate?, name?}) .comment(text)`. 복합 PK `t.primary(['a','b'])`.
761
- - FK 대상은 **모델 이름**(file-scan 범위 안에 있어야 함 — 없으면 fail-fast). 관계는 FK 까지만 —
762
- 조인 헬퍼/lazy load 같은 ORM 기능은 없다(ADR-009).
805
+
806
+ #### 컬럼 타입 — `t.<type>()`
807
+
808
+ 타입 메서드는 `ColumnBuilder` 반환하므로 아래 **수식어를 체인**할 수 있다(`src/core/migration/schema-builder.js`).
809
+
810
+ | 분류 | 메서드 | 비고 |
811
+ | --- | --- | --- |
812
+ | 정수 | `t.serial()` · `t.bigSerial()` | 자동증가(postgres SERIAL / BIGSERIAL) |
813
+ | 정수 | `t.integer()` · `t.bigInteger()` · `t.smallInteger()` | |
814
+ | 실수 | `t.real()` · `t.doublePrecision()` | |
815
+ | 정밀수 | `t.decimal(precision, scale)` | NUMERIC(p,s) |
816
+ | 문자열 | `t.varchar(maxLength)` · `t.char(length)` · `t.text()` | |
817
+ | 논리 | `t.boolean()` | |
818
+ | 시간 | `t.timestamp()` · `t.timestamptz()` · `t.date()` · `t.time()` | |
819
+ | 기타 | `t.uuid()` · `t.json()` · `t.jsonb()` · `t.bytea()` | |
820
+ | 열거 | `t.enum(values, { name? })` | postgres 는 `TEXT + CHECK (col IN (...))` 로 렌더(native enum 미사용 — 값 추가/삭제가 제약 교체로 단순) |
821
+ | **mongo 전용** | `t.objectId()` · `t.object(shape?)` · `t.array(items?, { uniqueItems? })` | SQL dialect 는 렌더 시 거부. `object`/`array` 의 중첩 필드는 타입·길이·enum·notNull(→required)만 — unique/primary/references/check 는 **최상위 전용** |
822
+
823
+ #### 컬럼 수식어 (체인) — `ColumnBuilder`
824
+
825
+ | 메서드 | 뜻 |
826
+ | --- | --- |
827
+ | `.primary()` | 단일 컬럼 PRIMARY KEY. 복합 PK 는 `t.primary(['a','b'])`(아래) |
828
+ | `.notNull()` | NOT NULL |
829
+ | `.nullable()` | 명시 null 허용. SQL 은 기본 nullable 이라 선언적 표시(no-op), mongo 는 `['type','null']` 유니온. `.notNull()` 과 **동시 사용 불가** |
830
+ | `.unique({ name? })` | UNIQUE 제약. 이름 미지정 시 `uniq_<table>_<col>` |
831
+ | `.default(value)` | DEFAULT. literal(string/number/boolean/null) 또는 `{ raw: 'expr' }`(raw SQL 식) |
832
+ | `.defaultNow()` | `DEFAULT CURRENT_TIMESTAMP` 단축 |
833
+ | `.check(expr, { name? })` | CHECK 제약. 이름 미지정 시 `chk_<table>_<col>` |
834
+ | `.references(model, col, { onDelete?, onUpdate?, name? })` | FK. 대상은 **모델 이름**(테이블명 아님 — 스냅샷 시 해석). `onDelete`/`onUpdate` ∈ `cascade · set null · set default · restrict · no action` |
835
+ | `.comment(text)` | `COMMENT ON COLUMN` |
836
+
837
+ - 테이블 레벨: `t.primary(['a','b'])` — **복합 PRIMARY KEY**(반환값 없음, 컬럼 맵엔 안 들어감). 단일 PK 는 컬럼 체인 `.primary()`.
838
+ - FK 대상 모델은 **file-scan 범위 안에 있어야** 한다(없으면 fail-fast). 관계는 FK 까지만 — 조인 헬퍼/lazy load 같은 ORM 기능은 없다(ADR-009).
839
+
840
+ #### 인덱스 — `static indexes = (t) => [ t.index(...) ]`
841
+
842
+ ```js
843
+ static indexes = (t) => [
844
+ t.index(['role', 'createdAt']), // 복합. 이름 자동: idx_<table>_<col…> (= idx_users_role_createdAt)
845
+ t.index(['email'], { unique: true }), // unique 인덱스
846
+ t.index({ expression: 'lower(email)' }, { name: 'idx_users_email_lower' }), // 표현식 인덱스
847
+ t.index(['org_id'], { where: 'deleted_at IS NULL' }), // 부분 인덱스(조건부)
848
+ t.index(['payload'], { using: 'gin' }), // 인덱스 방법(postgres USING)
849
+ ]
850
+ ```
851
+
852
+ | `t.index(컬럼들|식, opts)` 인자 | 뜻 |
853
+ | --- | --- |
854
+ | 1번째: `'col'` / `['a','b']` / `{ expression: 'lower(x)' }` | 단일·복합 컬럼 또는 표현식 인덱스 |
855
+ | `opts.name` | 인덱스 이름 override(기본 dialect 표준명) |
856
+ | `opts.unique` | `true` 면 UNIQUE 인덱스 |
857
+ | `opts.where` | **부분 인덱스** WHERE 조건(예: `'deleted_at IS NULL'`) |
858
+ | `opts.using` | 인덱스 방법(postgres `USING` — 예: `gin`/`gist`/`hash`) |
859
+
860
+ > 컬럼에 직접 `.unique()` 를 거는 것(=UNIQUE **제약**)과 `t.index([...], { unique: true })`(=UNIQUE **인덱스**)는
861
+ > 비슷하나, 후자는 복합·표현식·부분 인덱스를 지원한다. 단일 컬럼 유일성은 `.unique()` 가 간단하다.
862
+
863
+ **자동 이름 규칙**(postgres dialect, `{ name }` 미지정 시): 인덱스 `idx_<table>_<cols>` · UNIQUE 인덱스/제약
864
+ `uniq_<table>_<cols>` · FK `fk_<table>_<col>_<reftable>` · CHECK `chk_<table>_<col>`. 63byte 초과 시 거부되니
865
+ 길면 `{ name }` 으로 짧게 지정한다.
866
+
763
867
  - 제외 규칙: `_` 로 시작하는 파일/폴더, `*.test.js`, `static skip = true`.
764
868
  - 식별자는 63byte(postgres 한도)를 넘으면 거부된다 — 합성 이름(`fk_…`)이 걸리면 `{name}` 으로
765
869
  짧은 이름을 명시하면 된다.
@@ -225,7 +225,9 @@ MegaShutdown.register('worker', async () => { await worker.stop() })
225
225
  DLQ 상태를 읽는 쪽 예시(`jobs-demo-service.js`):
226
226
 
227
227
  ```js
228
- const jsm = await this.ctx.bus('jobs').native.jetstreamManager()
228
+ // nats v3(ADR-225): jetstreamManager 는 nc 메서드가 아니라 @nats-io/jetstream 의 함수다.
229
+ import { jetstreamManager } from '@nats-io/jetstream'
230
+ const jsm = await jetstreamManager(this.ctx.bus('jobs').native)
229
231
  const info = await jsm.streams.info('MEGA_JOBS_demo_email_DLQ') // count = info.state.messages
230
232
  const msg = await jsm.streams.getMessage(DLQ_STREAM, { last_by_subj: 'demo.email.dlq' })
231
233
  ```