mega-framework 0.1.10 → 0.1.11

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 (87) 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 +48 -0
  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/types/adapters/mega-adapter.d.ts +1 -1
  67. package/types/adapters/nats-adapter.d.ts +4 -4
  68. package/types/adapters/nats-codec.d.ts +13 -0
  69. package/types/adapters/redlock-adapter.d.ts +1 -1
  70. package/types/core/app-registry.d.ts +22 -0
  71. package/types/core/bus/cluster-bus.d.ts +45 -0
  72. package/types/core/bus/contract.d.ts +164 -0
  73. package/types/core/bus/index.d.ts +100 -0
  74. package/types/core/bus/memory-bus.d.ts +45 -0
  75. package/types/core/bus/nats-bus.d.ts +41 -0
  76. package/types/core/index.d.ts +1 -0
  77. package/types/core/lock/cluster-lock.d.ts +44 -0
  78. package/types/core/lock/contract.d.ts +181 -0
  79. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  80. package/types/core/lock/index.d.ts +96 -0
  81. package/types/core/lock/memory-lock.d.ts +58 -0
  82. package/types/core/lock/redis-lock.d.ts +43 -0
  83. package/types/core/mega-app.d.ts +10 -0
  84. package/types/core/scope-registry.d.ts +6 -0
  85. package/types/index.d.ts +1 -1
  86. package/types/lib/mega-job-queue.d.ts +27 -4
  87. 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)
@@ -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
  ```