@gaonjs/async 0.2.0 → 0.2.1

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.
@@ -5,3 +5,5 @@ export declare function createTestNats(name?: string): Promise<GaonNats>;
5
5
  export declare function uniqueName(prefix: string): string;
6
6
  /** KV 버킷(스트림 KV_<bucket>)을 삭제해 테스트 잔여물을 정리한다. */
7
7
  export declare function dropBucket(nats: GaonNats, bucket: string): Promise<void>;
8
+ /** 이름으로 스트림을 삭제한다(잡·DLQ·이벤트 스트림 정리). 없으면 무시. */
9
+ export declare function dropStream(nats: GaonNats, stream: string): Promise<void>;
@@ -19,3 +19,8 @@ export async function dropBucket(nats, bucket) {
19
19
  const jsm = await jetstreamManager(nats.nc);
20
20
  await jsm.streams.delete(`KV_${bucket}`).catch(() => { });
21
21
  }
22
+ /** 이름으로 스트림을 삭제한다(잡·DLQ·이벤트 스트림 정리). 없으면 무시. */
23
+ export async function dropStream(nats, stream) {
24
+ const jsm = await jetstreamManager(nats.nc);
25
+ await jsm.streams.delete(stream).catch(() => { });
26
+ }
@@ -0,0 +1,20 @@
1
+ /** 기본 백오프 계단(ms): 1s · 5s · 30s · 5m · 1h. (§7 M7 예시 곡선) */
2
+ export declare const DEFAULT_BACKOFF_MS: readonly number[];
3
+ /** 기본 재시도 횟수(최초 실행 제외). 총 시도 = 1 + retries. */
4
+ export declare const DEFAULT_RETRIES = 3;
5
+ export interface BackoffOptions {
6
+ /** 이산 백오프 계단(ms). 생략 시 DEFAULT_BACKOFF_MS. */
7
+ readonly curve?: readonly number[];
8
+ /**
9
+ * 지터 비율(0~1). 계산된 지연에 ±ratio 범위의 난수를 곱해 흩뜨린다.
10
+ * 기본 0.2 — 몰림 방지에 충분하되 곡선 형태는 유지한다.
11
+ */
12
+ readonly jitter?: number;
13
+ }
14
+ /**
15
+ * attempt(1-based: 방금 실패한 시도 번호)에 대한 다음 대기(ms)를 계산한다.
16
+ * attempt=1 → 곡선[0], attempt=2 → 곡선[1] ... 곡선 길이를 넘으면 마지막 값.
17
+ * 지터는 결정성이 필요 없는 런타임 경로라 Math.random 을 쓴다(테스트는
18
+ * jitter:0 으로 결정성을 얻는다).
19
+ */
20
+ export declare function backoffDelayMs(attempt: number, opts?: BackoffOptions): number;
@@ -0,0 +1,31 @@
1
+ // @gaonjs/async · 재시도 백오프 곡선 (§7 M7 · 질문 9·10)
2
+ //
3
+ // 설계(§7 line 792)는 재시도를 "NAK 지연 기반 백오프"로 못박았다. 잡이
4
+ // 실패하면 다음 시도까지의 대기를 백오프 곡선에서 뽑아 지연 재적재한다.
5
+ // 곡선은 지수 증가에 지터(jitter)를 섞어 다수 워커가 동시에 재시도해
6
+ // 몰리는 것(thundering herd)을 막는다.
7
+ //
8
+ // 기본 곡선은 이산 계단값이다(1s·5s·30s·5m·1h) — M7 벤치마크로 확정.
9
+ // 짧은 오류는 빠르게, 지속 장애는 성기게 재시도해 자원을 아낀다.
10
+ // attempt 를 넘어서면 마지막 값에서 포화한다.
11
+ /** 기본 백오프 계단(ms): 1s · 5s · 30s · 5m · 1h. (§7 M7 예시 곡선) */
12
+ export const DEFAULT_BACKOFF_MS = [1000, 5000, 30000, 300000, 3600000];
13
+ /** 기본 재시도 횟수(최초 실행 제외). 총 시도 = 1 + retries. */
14
+ export const DEFAULT_RETRIES = 3;
15
+ /**
16
+ * attempt(1-based: 방금 실패한 시도 번호)에 대한 다음 대기(ms)를 계산한다.
17
+ * attempt=1 → 곡선[0], attempt=2 → 곡선[1] ... 곡선 길이를 넘으면 마지막 값.
18
+ * 지터는 결정성이 필요 없는 런타임 경로라 Math.random 을 쓴다(테스트는
19
+ * jitter:0 으로 결정성을 얻는다).
20
+ */
21
+ export function backoffDelayMs(attempt, opts = {}) {
22
+ const curve = opts.curve && opts.curve.length > 0 ? opts.curve : DEFAULT_BACKOFF_MS;
23
+ const idx = Math.min(Math.max(attempt, 1), curve.length) - 1;
24
+ const base = curve[idx];
25
+ const jitter = opts.jitter ?? 0.2;
26
+ if (jitter <= 0)
27
+ return base;
28
+ // 균등 지터: base * (1 ± jitter). 하한 0 보장.
29
+ const factor = 1 + (Math.random() * 2 - 1) * jitter;
30
+ return Math.max(0, Math.round(base * factor));
31
+ }
@@ -0,0 +1,8 @@
1
+ /** 값을 태그드 JSON 문자열로(bigint·Date 왕복 보존). */
2
+ export declare function encodePayload(value: unknown): string;
3
+ /** encodePayload 의 역함수. */
4
+ export declare function decodePayload<T>(text: string): T;
5
+ /** 태그드 JSON 을 NATS 바이트로. */
6
+ export declare function encodePayloadBytes(value: unknown): Uint8Array;
7
+ /** NATS 바이트를 태그드 JSON 으로. */
8
+ export declare function decodePayloadBytes<T>(bytes: Uint8Array): T;
package/dist/codec.js ADDED
@@ -0,0 +1,57 @@
1
+ // @gaonjs/async · 잡·이벤트 인자 코덱 (§7 M7)
2
+ //
3
+ // 잡 인자와 이벤트 페이로드는 NATS 를 건너 워커로 전달되므로 JSON 왕복이
4
+ // 필요하다. 그런데 도메인 값에는 JSON 이 기본 지원하지 않는 타입이 있다:
5
+ // - bigint — PK 관례(§4.2). JSON.stringify 는 bigint 에서 throw 한다.
6
+ // - Date — 타임스탬프 인자.
7
+ // 렌더 경계의 직렬화(M4 SerializedOf)는 문자열로 납작하게 만들지만, 잡은
8
+ // 값 타입을 그대로 복원해야 한다(handler 시그니처가 bigint 를 기대). 그래서
9
+ // 태그드 JSON 으로 왕복 가능한 인코딩을 쓴다.
10
+ function replacer(_key, value) {
11
+ if (typeof value === 'bigint')
12
+ return { __gaon: 'bigint', v: value.toString() };
13
+ return value;
14
+ }
15
+ function isTag(v) {
16
+ return typeof v === 'object' && v !== null && '__gaon' in v;
17
+ }
18
+ function reviver(_key, value) {
19
+ if (isTag(value)) {
20
+ if (value.__gaon === 'bigint')
21
+ return BigInt(value.v);
22
+ if (value.__gaon === 'date')
23
+ return new Date(value.v);
24
+ }
25
+ return value;
26
+ }
27
+ // Date 는 toJSON 이 문자열로 바꿔 버려 replacer 가 Date 인지 알 수 없다.
28
+ // 그래서 stringify 전에 Date 를 태그 객체로 치환한다(깊은 순회).
29
+ function tagDates(value) {
30
+ if (value instanceof Date)
31
+ return { __gaon: 'date', v: value.toISOString() };
32
+ if (Array.isArray(value))
33
+ return value.map(tagDates);
34
+ if (value && typeof value === 'object') {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(value))
37
+ out[k] = tagDates(v);
38
+ return out;
39
+ }
40
+ return value;
41
+ }
42
+ /** 값을 태그드 JSON 문자열로(bigint·Date 왕복 보존). */
43
+ export function encodePayload(value) {
44
+ return JSON.stringify(tagDates(value), replacer);
45
+ }
46
+ /** encodePayload 의 역함수. */
47
+ export function decodePayload(text) {
48
+ return JSON.parse(text, reviver);
49
+ }
50
+ /** 태그드 JSON 을 NATS 바이트로. */
51
+ export function encodePayloadBytes(value) {
52
+ return new TextEncoder().encode(encodePayload(value));
53
+ }
54
+ /** NATS 바이트를 태그드 JSON 으로. */
55
+ export function decodePayloadBytes(bytes) {
56
+ return decodePayload(new TextDecoder().decode(bytes));
57
+ }
package/dist/cron.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface CronMatcher {
2
+ /** 이 Date(로컬 시간)의 분 경계가 크론식과 맞는가. */
3
+ matches(date: Date): boolean;
4
+ readonly expr: string;
5
+ }
6
+ /** 5필드 크론식을 매처로 컴파일한다. 형식 오류는 수리 안내 에러로 던진다. */
7
+ export declare function parseCron(expr: string): CronMatcher;
package/dist/cron.js ADDED
@@ -0,0 +1,88 @@
1
+ // @gaonjs/async · 최소 크론 파서 (§7 M7 · line 838~849)
2
+ //
3
+ // 표준 5필드 크론(분 시 일 월 요일)의 실용 부분집합을 지원한다: `*`,
4
+ // 숫자, 리스트(`,`), 범위(`-`), 스텝(`*/n` · `a-b/n`). 요일은 0(일)~6(토),
5
+ // 7 도 일요일로 받는다. 초 단위·`?`·`L`·`#` 같은 확장 문법은 v1 범위 밖이다
6
+ // (필요하면 `s.every()` 나 `s.daily.at()` 를 쓰라고 스케줄 API 가 안내한다).
7
+ //
8
+ // 새 런타임 의존을 더하지 않으려 자체 구현한다("만들지 않고 접착한다"의
9
+ // 예외 — cron 라이브러리는 표면적이 크고, 우리에겐 이 부분집합으로 충분하다).
10
+ const MINUTE = { min: 0, max: 59 };
11
+ const HOUR = { min: 0, max: 23 };
12
+ const DOM = { min: 1, max: 31 };
13
+ const MONTH = { min: 1, max: 12 };
14
+ const DOW = { min: 0, max: 7 };
15
+ function parseField(token, field, label, expr) {
16
+ const out = new Set();
17
+ for (const part of token.split(',')) {
18
+ const [rangePart, stepPart] = part.split('/');
19
+ const step = stepPart ? Number(stepPart) : 1;
20
+ if (!Number.isInteger(step) || step < 1) {
21
+ throw cronError(expr, `${label} 스텝이 잘못됐습니다: '${part}'`);
22
+ }
23
+ let lo;
24
+ let hi;
25
+ if (rangePart === '*') {
26
+ lo = field.min;
27
+ hi = field.max;
28
+ }
29
+ else if (rangePart.includes('-')) {
30
+ const [a, b] = rangePart.split('-').map(Number);
31
+ lo = a;
32
+ hi = b;
33
+ }
34
+ else {
35
+ lo = Number(rangePart);
36
+ hi = lo;
37
+ }
38
+ if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < field.min || hi > field.max || lo > hi) {
39
+ throw cronError(expr, `${label} 값이 범위를 벗어났습니다: '${part}' (허용 ${field.min}~${field.max})`);
40
+ }
41
+ for (let v = lo; v <= hi; v += step)
42
+ out.add(v);
43
+ }
44
+ return out;
45
+ }
46
+ function cronError(expr, detail) {
47
+ return new Error(`[@gaonjs/async] 크론식을 이해할 수 없습니다: '${expr}'\n` +
48
+ `→ ${detail}\n` +
49
+ `→ 5필드(분 시 일 월 요일)를 쓰세요 — 예: '0 9 * * 1' (월요일 09:00).\n` +
50
+ `→ 간단한 반복은 s.every('10m', Job) · s.daily.at('04:00', Job) 가 더 읽기 쉽습니다.`);
51
+ }
52
+ /** 5필드 크론식을 매처로 컴파일한다. 형식 오류는 수리 안내 에러로 던진다. */
53
+ export function parseCron(expr) {
54
+ const tokens = expr.trim().split(/\s+/);
55
+ if (tokens.length !== 5) {
56
+ throw cronError(expr, `필드가 ${tokens.length}개입니다(5개여야 함: 분 시 일 월 요일)`);
57
+ }
58
+ const minutes = parseField(tokens[0], MINUTE, '분', expr);
59
+ const hours = parseField(tokens[1], HOUR, '시', expr);
60
+ const doms = parseField(tokens[2], DOM, '일', expr);
61
+ const months = parseField(tokens[3], MONTH, '월', expr);
62
+ const dowsRaw = parseField(tokens[4], DOW, '요일', expr);
63
+ // 7 → 0(일요일) 정규화.
64
+ const dows = new Set([...dowsRaw].map((d) => (d === 7 ? 0 : d)));
65
+ const domRestricted = tokens[2] !== '*';
66
+ const dowRestricted = tokens[4] !== '*';
67
+ return {
68
+ expr,
69
+ matches(date) {
70
+ if (!minutes.has(date.getMinutes()))
71
+ return false;
72
+ if (!hours.has(date.getHours()))
73
+ return false;
74
+ if (!months.has(date.getMonth() + 1))
75
+ return false;
76
+ const domOk = doms.has(date.getDate());
77
+ const dowOk = dows.has(date.getDay());
78
+ // 표준 크론 규칙: 일·요일이 둘 다 제한되면 OR(둘 중 하나만 맞아도 실행).
79
+ if (domRestricted && dowRestricted)
80
+ return domOk || dowOk;
81
+ if (domRestricted)
82
+ return domOk;
83
+ if (dowRestricted)
84
+ return dowOk;
85
+ return true;
86
+ },
87
+ };
88
+ }
package/dist/dlq.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import type { DlqRecord } from './worker.js';
3
+ /** DLQ 레코드 + 스트림 시퀀스(삭제·재적재에 필요). */
4
+ export interface DlqEntry extends DlqRecord {
5
+ readonly seq: number;
6
+ }
7
+ /** DLQ 의 실패 잡을 최신순으로 나열한다(limit 개). */
8
+ export declare function listDlq(nats: GaonNats, limit?: number): Promise<DlqEntry[]>;
9
+ /** id 로 DLQ 레코드를 찾는다(최신 우선). 없으면 undefined. */
10
+ export declare function findDlq(nats: GaonNats, id: string): Promise<DlqEntry | undefined>;
11
+ /**
12
+ * DLQ 잡을 잡 큐로 재적재한다(attempt 0, 즉시 실행). 성공하면 DLQ 레코드를
13
+ * 삭제한다. enqueue 전송이 설정돼 있어야 한다(configureJobs) — 미설정이면
14
+ * 주어진 nats 로 임시 설정한다.
15
+ */
16
+ export declare function retryDlq(nats: GaonNats, id: string): Promise<DlqEntry>;
17
+ /** DLQ 를 통째로 비운다(운영 정리용). 삭제 개수를 돌려준다. */
18
+ export declare function purgeDlq(nats: GaonNats): Promise<number>;
package/dist/dlq.js ADDED
@@ -0,0 +1,70 @@
1
+ // @gaonjs/async · DLQ 조회·재적재 (§7 M7 · line 886~887)
2
+ //
3
+ // 최대 재시도를 소진한 잡은 DLQ 스트림에 레코드로 남는다(worker.ts). 운영자·
4
+ // 개발 대시보드가 이를 훑어보고(`gaon jobs list --failed`) 원인을 고친 뒤
5
+ // 재실행한다(`gaon jobs retry <id>`). 재적재는 잡을 attempt 0 으로 잡 큐에
6
+ // 다시 넣고 DLQ 레코드를 지운다.
7
+ import { randomUUID } from 'node:crypto';
8
+ import { jetstreamManager } from '@nats-io/jetstream';
9
+ import { DLQ_STREAM, DLQ_SUBJECT, ensureDlqStream } from './streams.js';
10
+ import { decodePayloadBytes } from './codec.js';
11
+ import { enqueueRaw, configureJobs } from './jobs.js';
12
+ /** DLQ 의 실패 잡을 최신순으로 나열한다(limit 개). */
13
+ export async function listDlq(nats, limit = 100) {
14
+ await ensureDlqStream(nats);
15
+ const jsm = await jetstreamManager(nats.nc);
16
+ const info = await jsm.streams.info(DLQ_STREAM);
17
+ const last = info.state.last_seq;
18
+ const first = info.state.first_seq;
19
+ const out = [];
20
+ for (let seq = last; seq >= first && out.length < limit; seq--) {
21
+ const stored = await jsm.streams.getMessage(DLQ_STREAM, { seq }).catch(() => null);
22
+ if (!stored)
23
+ continue;
24
+ const rec = decodePayloadBytes(stored.data);
25
+ out.push({ ...rec, seq: stored.seq });
26
+ }
27
+ return out;
28
+ }
29
+ /** id 로 DLQ 레코드를 찾는다(최신 우선). 없으면 undefined. */
30
+ export async function findDlq(nats, id) {
31
+ const all = await listDlq(nats, 1000);
32
+ return all.find((e) => e.id === id);
33
+ }
34
+ /**
35
+ * DLQ 잡을 잡 큐로 재적재한다(attempt 0, 즉시 실행). 성공하면 DLQ 레코드를
36
+ * 삭제한다. enqueue 전송이 설정돼 있어야 한다(configureJobs) — 미설정이면
37
+ * 주어진 nats 로 임시 설정한다.
38
+ */
39
+ export async function retryDlq(nats, id) {
40
+ const entry = await findDlq(nats, id);
41
+ if (!entry) {
42
+ throw new Error(`[@gaonjs/async] DLQ 에 id='${id}' 인 잡이 없습니다.\n` +
43
+ `→ gaon jobs list --failed 로 현재 목록을 확인하세요.`);
44
+ }
45
+ configureJobs(nats);
46
+ const now = Date.now();
47
+ const msg = {
48
+ id: randomUUID(),
49
+ name: entry.name,
50
+ queue: entry.queue,
51
+ args: entry.args,
52
+ attempt: 0,
53
+ notBefore: now,
54
+ enqueuedAt: now,
55
+ };
56
+ await enqueueRaw(msg);
57
+ // 재적재에 성공했으니 DLQ 원본을 지운다(재적재 후 실패해도 다시 DLQ 로 온다).
58
+ const jsm = await jetstreamManager(nats.nc);
59
+ await jsm.streams.deleteMessage(DLQ_STREAM, entry.seq).catch(() => { });
60
+ return entry;
61
+ }
62
+ /** DLQ 를 통째로 비운다(운영 정리용). 삭제 개수를 돌려준다. */
63
+ export async function purgeDlq(nats) {
64
+ await ensureDlqStream(nats);
65
+ const jsm = await jetstreamManager(nats.nc);
66
+ const info = await jsm.streams.info(DLQ_STREAM);
67
+ const n = info.state.messages;
68
+ await jsm.streams.purge(DLQ_STREAM, { filter: DLQ_SUBJECT });
69
+ return Number(n);
70
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * '10m' → 600000. 파싱 실패는 수리 안내를 담은 에러로 던진다(§7.5.3).
3
+ * 숫자(number)를 그대로 주면 ms 로 통과시킨다.
4
+ */
5
+ export declare function parseDuration(value: string | number): number;
@@ -0,0 +1,33 @@
1
+ // @gaonjs/async · 사람이 읽는 기간 문자열 (§7 M7)
2
+ //
3
+ // 잡 지연(`.in('10m', ...)`)과 스케줄(`s.every('10m', ...)`)에서 크론 문법을
4
+ // 몰라도 되게 하는 자연스러운 기간 표기다(§7 line 838 "크론 문법을 몰라도
5
+ // 되는 자연스러운 API"). 단위: ms · s · m · h · d. 숫자만 오면 ms 로 본다.
6
+ const UNIT_MS = {
7
+ ms: 1,
8
+ s: 1000,
9
+ m: 60000,
10
+ h: 3600000,
11
+ d: 86400000,
12
+ };
13
+ const PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/;
14
+ /**
15
+ * '10m' → 600000. 파싱 실패는 수리 안내를 담은 에러로 던진다(§7.5.3).
16
+ * 숫자(number)를 그대로 주면 ms 로 통과시킨다.
17
+ */
18
+ export function parseDuration(value) {
19
+ if (typeof value === 'number') {
20
+ if (!Number.isFinite(value) || value < 0) {
21
+ throw new Error(`[@gaonjs/async] 기간은 0 이상의 유한수여야 합니다: ${value}`);
22
+ }
23
+ return value;
24
+ }
25
+ const m = PATTERN.exec(value.trim());
26
+ if (!m) {
27
+ throw new Error(`[@gaonjs/async] 기간 형식을 이해할 수 없습니다: '${value}'\n` +
28
+ `→ '10m' · '30s' · '1h' · '2d' · '500ms' 처럼 쓰거나 숫자(ms)를 주세요.`);
29
+ }
30
+ const n = Number(m[1]);
31
+ const unit = m[2] ?? 'ms';
32
+ return Math.round(n * UNIT_MS[unit]);
33
+ }
@@ -0,0 +1,45 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import { type StreamTuning } from './streams.js';
3
+ /** shape 의 컬럼 정의에서 페이로드 타입을 뽑는다(각 컬럼의 _type). */
4
+ export type PayloadOf<Shape extends Record<string, {
5
+ readonly _type: unknown;
6
+ }>> = {
7
+ [K in keyof Shape]: Shape[K]['_type'];
8
+ };
9
+ export interface EventDef<Payload> {
10
+ readonly name: string;
11
+ /** 이벤트를 발행한다. 트랜잭션 안이면 아웃박스로, 밖이면 즉시 NATS 로. */
12
+ emit(payload: Payload): Promise<void>;
13
+ }
14
+ /** 파일 로더가 export 중에서 리스너를 식별하는 브랜드. */
15
+ export declare const LISTENER_BRAND: unique symbol;
16
+ /** 리스너(구독자). 워커가 이벤트명별 durable 컨슈머로 소비한다. */
17
+ export interface Listener<Payload = unknown> {
18
+ readonly [LISTENER_BRAND]: true;
19
+ readonly id: string;
20
+ readonly eventName: string;
21
+ readonly handler: (payload: Payload) => Promise<void> | void;
22
+ }
23
+ /** emit 전송을 설정한다(부팅 시 1회). */
24
+ export declare function configureEvents(nats: GaonNats, tuning?: StreamTuning): void;
25
+ export declare function resetEvents(): void;
26
+ /** 이벤트를 정의한다. name 은 안정적 식별자(subject·durable 의 근간). */
27
+ export declare function event<Shape extends Record<string, {
28
+ readonly _type: unknown;
29
+ }>>(name: string, _shape: Shape): EventDef<PayloadOf<Shape>>;
30
+ /**
31
+ * 이벤트를 구독한다. 반환값을 `domain/listeners/*.ts` 에서 export 하면 등록.
32
+ * id 는 durable 컨슈머 이름의 근간이라 안정적이어야 한다(파일 로더가
33
+ * 파일명에서 채운다). 미지정 시 이벤트명 + 순번으로 임시 부여한다.
34
+ */
35
+ export declare function on<Payload>(def: EventDef<Payload>, handler: (payload: Payload) => Promise<void> | void, opts?: {
36
+ id?: string;
37
+ }): Listener<Payload>;
38
+ /** 값이 리스너인가(파일 로더가 export 를 훑을 때). */
39
+ export declare function isListener(value: unknown): value is Listener;
40
+ /** 리스너의 id 를 파일명 기반으로 다시 부여해 재등록(로더용 · durable 안정화). */
41
+ export declare function reidentifyListener(listener: Listener, id: string): void;
42
+ /** 등록된 리스너 전체(워커). */
43
+ export declare function registeredListeners(): Listener[];
44
+ /** 저수준 발행(아웃박스 릴레이가 subject 로 직접 publish). */
45
+ export declare function publishToSubject(subject: string, payload: unknown, id: string): Promise<void>;
package/dist/events.js ADDED
@@ -0,0 +1,104 @@
1
+ // @gaonjs/async · 이벤트 버스 (§7 M7 · line 798~826)
2
+ //
3
+ // 이벤트는 `domain/events/` 에 정의하고 구독자는 `domain/listeners/` 에
4
+ // 파일로 둔다(파일=등록). 발행자는 누가 듣는지 모른다(결합 제거). 전송은
5
+ // JetStream 이라 영속·at-least-once — 리스너는 멱등하게 짜는 것이 관례다.
6
+ //
7
+ // export const PostPublished = event('post.published', {
8
+ // postId: t.bigint(), publisherId: t.bigint(),
9
+ // })
10
+ // await PostPublished.emit({ postId: post.id, publisherId: user.id })
11
+ //
12
+ // export default on(PostPublished, async ({ postId, publisherId }) => { ... })
13
+ //
14
+ // 페이로드 타입은 shape 의 컬럼 정의(`t.bigint()` 등)에서 구조적으로
15
+ // 추론한다 — 각 컬럼은 `_type` 을 노출하므로 @gaonjs/data 를 런타임으로
16
+ // 끌어오지 않고도(구조적 타이핑) 페이로드 모양이 끝까지 흐른다.
17
+ //
18
+ // 트랜잭션 안에서 emit 하면 같은 트랜잭션으로 아웃박스에 적재된다(outbox.ts).
19
+ // 트랜잭션 밖 emit 은 즉시 발행한다. 개발자에겐 이 분기가 보이지 않는다.
20
+ import { randomUUID } from 'node:crypto';
21
+ import { eventSubject, ensureEventsStream } from './streams.js';
22
+ import { encodePayloadBytes } from './codec.js';
23
+ import { currentOutbox } from './outboxContext.js';
24
+ /** 파일 로더가 export 중에서 리스너를 식별하는 브랜드. */
25
+ export const LISTENER_BRAND = Symbol.for('gaonjs.async.listener');
26
+ let runtime;
27
+ /** emit 전송을 설정한다(부팅 시 1회). */
28
+ export function configureEvents(nats, tuning = {}) {
29
+ runtime = { nats, tuning, ensured: false };
30
+ }
31
+ export function resetEvents() {
32
+ runtime = undefined;
33
+ listeners.length = 0;
34
+ }
35
+ function requireRuntime() {
36
+ if (!runtime) {
37
+ throw new Error(`[@gaonjs/async] 이벤트 전송이 설정되지 않았습니다.\n` +
38
+ `→ 부팅 코드에서 configureEvents(await connectNats()) 를 호출하세요.`);
39
+ }
40
+ return runtime;
41
+ }
42
+ async function publishEvent(name, payload, id) {
43
+ const rt = requireRuntime();
44
+ if (!rt.ensured) {
45
+ await ensureEventsStream(rt.nats, rt.tuning);
46
+ rt.ensured = true;
47
+ }
48
+ await rt.nats.js.publish(eventSubject(name), encodePayloadBytes(payload), { msgID: id });
49
+ }
50
+ /** 이벤트를 정의한다. name 은 안정적 식별자(subject·durable 의 근간). */
51
+ export function event(name, _shape) {
52
+ return {
53
+ name,
54
+ async emit(payload) {
55
+ const outbox = currentOutbox();
56
+ if (outbox) {
57
+ // 트랜잭션 안 — 같은 트랜잭션으로 아웃박스에 적재(이중 쓰기 방지).
58
+ await outbox.stage(eventSubject(name), payload);
59
+ }
60
+ else {
61
+ await publishEvent(name, payload, randomUUID());
62
+ }
63
+ },
64
+ };
65
+ }
66
+ // 리스너 레지스트리(워커가 컨슈머를 만들 때 훑는다).
67
+ const listeners = [];
68
+ /**
69
+ * 이벤트를 구독한다. 반환값을 `domain/listeners/*.ts` 에서 export 하면 등록.
70
+ * id 는 durable 컨슈머 이름의 근간이라 안정적이어야 한다(파일 로더가
71
+ * 파일명에서 채운다). 미지정 시 이벤트명 + 순번으로 임시 부여한다.
72
+ */
73
+ export function on(def, handler, opts = {}) {
74
+ const id = opts.id ?? `${def.name}#${listeners.length}`;
75
+ const listener = { [LISTENER_BRAND]: true, id, eventName: def.name, handler };
76
+ listeners.push(listener);
77
+ return listener;
78
+ }
79
+ /** 값이 리스너인가(파일 로더가 export 를 훑을 때). */
80
+ export function isListener(value) {
81
+ return (typeof value === 'object' && value !== null && value[LISTENER_BRAND] === true);
82
+ }
83
+ /** 리스너의 id 를 파일명 기반으로 다시 부여해 재등록(로더용 · durable 안정화). */
84
+ export function reidentifyListener(listener, id) {
85
+ const idx = listeners.indexOf(listener);
86
+ const renamed = { ...listener, id };
87
+ if (idx >= 0)
88
+ listeners[idx] = renamed;
89
+ else
90
+ listeners.push(renamed);
91
+ }
92
+ /** 등록된 리스너 전체(워커). */
93
+ export function registeredListeners() {
94
+ return [...listeners];
95
+ }
96
+ /** 저수준 발행(아웃박스 릴레이가 subject 로 직접 publish). */
97
+ export async function publishToSubject(subject, payload, id) {
98
+ const rt = requireRuntime();
99
+ if (!rt.ensured) {
100
+ await ensureEventsStream(rt.nats, rt.tuning);
101
+ rt.ensured = true;
102
+ }
103
+ await rt.nats.js.publish(subject, encodePayloadBytes(payload), { msgID: id });
104
+ }
package/dist/hub.d.ts CHANGED
@@ -5,21 +5,29 @@ export interface HubOptions {
5
5
  readonly id: string;
6
6
  /** 리스 TTL(ms). 기본 5000. */
7
7
  readonly ttlMs?: number;
8
- /** 멤버 만료 임계(ms) 시간 넘게 하트비트 없으면 leave 처리. 기본 30000. */
9
- readonly memberTimeoutMs?: number;
10
- /** 만료 스윕 주기(ms). 기본 5000. */
8
+ /** TCP 리슨 포트. 기본 4001(env GAON_HUB_PORT). */
9
+ readonly port?: number;
10
+ /** TCP 리슨 호스트. 기본 0.0.0.0. */
11
+ readonly host?: string;
12
+ /** 멀티호스트 발견용으로 KV 에 공지할 도달 주소(host:port). 기본 127.0.0.1:port. */
13
+ readonly advertiseAddr?: string;
14
+ /** 소켓 무활동 임계(ms) — 이 시간 넘게 명령/ping 없으면 반열림으로 보고 끊는다. 기본 10000. */
15
+ readonly pingTimeoutMs?: number;
16
+ /** 무활동·회수 스윕 주기(ms). 기본 2000. */
11
17
  readonly sweepMs?: number;
18
+ /** 재접속 안 되는 복원 서버를 회수하기까지의 유예(ms). 기본 15000. */
19
+ readonly reclaimGraceMs?: number;
12
20
  /** 리더십·상태 통지(옵션). */
13
21
  onState?(state: {
14
22
  leader: boolean;
15
23
  }): void;
24
+ /** 배경 에러 통지(옵션). */
25
+ onError?(err: Error): void;
16
26
  }
17
27
  export interface HubHandle {
18
28
  readonly isLeader: boolean;
29
+ /** 현재 리더가 리슨 중인 TCP 포트(테스트·디버깅용). 리더 아니면 undefined. */
30
+ readonly port: number | undefined;
19
31
  stop(): Promise<void>;
20
32
  }
21
- /**
22
- * 허브를 시작한다. 리더가 되기 전에는 대기하고, 리더가 되면 프레즌스
23
- * 명령 구독·KV 권위 갱신·델타 중계·만료 스윕을 켠다.
24
- */
25
33
  export declare function runHub(opts: HubOptions): Promise<HubHandle>;