@gaonjs/async 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -21,3 +21,4 @@ export { runInTransaction, runOutboxRelay, ensureOutboxTable, OUTBOX_TABLE, type
21
21
  export { currentOutbox, runWithOutbox, type OutboxStager } from './outboxContext.js';
22
22
  export { schedule, runScheduler, type ScheduleDef, type ScheduleEntry, type ScheduleBuilder, type Schedulable, type SchedulerOptions, type SchedulerHandle, type SchedulerEvent, } from './schedule.js';
23
23
  export { parseCron, type CronMatcher } from './cron.js';
24
+ export { expectJobProcessed, type ExpectJobProcessedOptions } from './testing.js';
package/dist/index.js CHANGED
@@ -49,3 +49,5 @@ export { currentOutbox, runWithOutbox } from './outboxContext.js';
49
49
  // 스케줄러 (§7 · schedule DSL·크론·리더 선출 단일 발행)
50
50
  export { schedule, runScheduler, } from './schedule.js';
51
51
  export { parseCron } from './cron.js';
52
+ // 테스트 헬퍼 (결정 42) — 잡 발행→실 처리 확증. 파사드 gaonjs/testing 이 노출.
53
+ export { expectJobProcessed } from './testing.js';
package/dist/jobs.d.ts CHANGED
@@ -58,7 +58,8 @@ export declare function configureJobs(nats: GaonNats, tuning?: StreamTuning): vo
58
58
  export declare function resetJobs(): void;
59
59
  /**
60
60
  * 잡을 정의한다. 반환 객체를 `domain/jobs/*.ts` 에서 export 하면 등록이다.
61
- * 이름은 옵션으로 주거나(명시), 파일 로더가 파일명에서 채운다.
61
+ * 이름은 옵션으로 주거나(명시), 정의 지점 파일명에서 스스로 유추한다
62
+ * (파일 로더는 이 유추가 실패했을 때의 폴백 · 하위 호환).
62
63
  */
63
64
  export declare function job<Args extends readonly unknown[]>(handler: (...args: Args) => Promise<void> | void, options?: JobOptions): JobDef<Args>;
64
65
  /** 잡을 레지스트리에 등록(워커가 핸들러 조회에 쓴다). 이름 필수. */
package/dist/jobs.js CHANGED
@@ -70,13 +70,43 @@ function enqueue(name, queue, args, notBefore) {
70
70
  enqueuedAt: now,
71
71
  });
72
72
  }
73
+ /**
74
+ * 정의 지점의 모듈 파일명에서 잡 이름을 유추한다(확장자 제외).
75
+ *
76
+ * 불변식: **잡 이름은 어느 프로세스가 import 했는지에 의존하면 안 된다.**
77
+ * 파일=이름 관례를 파일 로더(`loadDomain`)에만 맡기면, 로더를 태우는
78
+ * 프로세스(`gaon work`)에서만 이름이 붙고 태우지 않는 프로세스(`gaon serve`
79
+ * 웹 컨트롤러 발행)에서는 `.later()` 가 이름 미해석으로 깨진다. 그래서
80
+ * `job()` 이 정의 시점에 스스로 파일명을 이름으로 잡는다 — 이러면 잡을
81
+ * import 한 어느 프로세스에서든(serve·work·hub·스크립트) 같은 이름이 붙는다.
82
+ * 명시 `name` 이 있으면 그것이 우선하고, 유추 실패 시 ''(로더/명시에 폴백)
83
+ * 이라 기존 동작을 깨지 않는다. `loadDomain` 이 붙이는 이름과 동일한 규칙
84
+ * (basename, 확장자 제외)이라 발행측·워커측 조회키가 항상 일치한다.
85
+ */
86
+ function inferNameFromCaller() {
87
+ const stack = new Error().stack;
88
+ if (!stack)
89
+ return '';
90
+ for (const line of stack.split('\n').slice(1)) {
91
+ // 이 모듈(jobs.ts/js) 프레임은 건너뛴다 — 그다음이 job() 호출 모듈.
92
+ if (/[/\\]jobs\.(?:ts|js|mjs|cjs)(?::|\b)/.test(line))
93
+ continue;
94
+ const m = line.match(/(?:file:\/\/)?(\/[^()\s:]+\.(?:ts|tsx|js|jsx|mjs|cjs)):\d+:\d+/);
95
+ if (!m)
96
+ continue;
97
+ const base = m[1].split(/[/\\]/).pop() ?? '';
98
+ return base.replace(/\.(?:ts|tsx|js|jsx|mjs|cjs)$/, '');
99
+ }
100
+ return '';
101
+ }
73
102
  /**
74
103
  * 잡을 정의한다. 반환 객체를 `domain/jobs/*.ts` 에서 export 하면 등록이다.
75
- * 이름은 옵션으로 주거나(명시), 파일 로더가 파일명에서 채운다.
104
+ * 이름은 옵션으로 주거나(명시), 정의 지점 파일명에서 스스로 유추한다
105
+ * (파일 로더는 이 유추가 실패했을 때의 폴백 · 하위 호환).
76
106
  */
77
107
  export function job(handler, options = {}) {
78
108
  const queue = options.queue ?? DEFAULT_QUEUE;
79
- let resolved = options.name ?? '';
109
+ let resolved = options.name ?? inferNameFromCaller();
80
110
  const def = {
81
111
  [JOB_BRAND]: true,
82
112
  get name() {
package/dist/nats.d.ts CHANGED
@@ -3,7 +3,10 @@ import { type JetStreamClient } from '@nats-io/jetstream';
3
3
  import { Kvm } from '@nats-io/kv';
4
4
  import type { KV, KvOptions } from '@nats-io/kv';
5
5
  export interface NatsOptions {
6
- /** 접속지. 생략 시 env GAON_NATS_URL, 그다음 nats://127.0.0.1:4222. */
6
+ /**
7
+ * 접속지. 생략 시 env `NATS_URL`(스캐폴드 .env 관례), 그다음 하위 호환
8
+ * `GAON_NATS_URL`, 그다음 nats://127.0.0.1:4222.
9
+ */
7
10
  readonly servers?: string | readonly string[];
8
11
  /** 연결 이름(모니터링 식별). serve/work/hub 프로세스가 각자 붙인다. */
9
12
  readonly name?: string;
package/dist/nats.js CHANGED
@@ -13,7 +13,11 @@ import { Kvm } from '@nats-io/kv';
13
13
  function resolveServers(opts) {
14
14
  if (opts.servers)
15
15
  return Array.isArray(opts.servers) ? [...opts.servers] : opts.servers;
16
- return process.env.GAON_NATS_URL ?? 'nats://127.0.0.1:4222';
16
+ // 스캐폴드 .env.example `NATS_URL` 을 쓰므로 그것을 정본 키로 삼는다.
17
+ // `gaon serve`(config 경유)와 `gaon work`(여기 폴백 경유)가 같은 env 를
18
+ // 보게 해 스캐폴드 프로젝트에서 워커가 사용자 env 를 놓치는 걸 막는다.
19
+ // 하위 호환으로 `GAON_NATS_URL` 도 계속 인정한다.
20
+ return process.env.NATS_URL ?? process.env.GAON_NATS_URL ?? 'nats://127.0.0.1:4222';
17
21
  }
18
22
  /**
19
23
  * NATS 에 연결하고 JetStream·KV 핸들을 준비한다. 연결 실패는 수리
@@ -30,7 +34,7 @@ export async function connectNats(opts = {}) {
30
34
  const msg = err instanceof Error ? err.message : String(err);
31
35
  throw new Error(`[@gaonjs/async] NATS 연결 실패 (${where}): ${msg}\n` +
32
36
  `→ 개발 인프라를 기동하세요: gaon dev (또는 docker compose up -d nats)\n` +
33
- `→ 접속지를 바꾸려면 GAON_NATS_URL 환경변수를 설정하세요.`);
37
+ `→ 접속지를 바꾸려면 NATS_URL 환경변수를 설정하세요 (.env.example 참조).`);
34
38
  }
35
39
  const js = jetstream(nc);
36
40
  const kvm = new Kvm(js);
@@ -0,0 +1,18 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import type { JobDef } from './jobs.js';
3
+ export interface ExpectJobProcessedOptions {
4
+ /** 실 NATS 연결(connectNats). 목업 불가 — §9. */
5
+ readonly nats: GaonNats;
6
+ /** 처리 완료 대기 상한(ms). 기본 10_000. */
7
+ readonly timeoutMs?: number;
8
+ }
9
+ /**
10
+ * `publish()` 가 발행한 잡이 실 NATS JetStream 을 거쳐 **핸들러까지 실행**
11
+ * 되는 것을 확증한다. 임시 워커를 띄워 해당 잡의 succeeded 이벤트를 기다리고,
12
+ * 어떤 경로로든(성공 실패·DLQ·타임아웃) 결과가 나오면 워커를 정리한다.
13
+ *
14
+ * - 잡 핸들러가 throw 해 재시도 소진 → DLQ 로 가면 실패로 단언한다.
15
+ * - 발행 설정(configureJobs)은 헬퍼가 주어진 nats 로 대신 해 준다 —
16
+ * 테스트가 부팅 코드를 흉내낼 필요가 없다.
17
+ */
18
+ export declare function expectJobProcessed<Args extends readonly unknown[]>(target: JobDef<Args>, publish: () => Promise<unknown>, opts: ExpectJobProcessedOptions): Promise<void>;
@@ -0,0 +1,60 @@
1
+ // @gaonjs/async · testing.ts — 비동기 테스트 헬퍼 (결정 42).
2
+ //
3
+ // 잡 "발행 → 실 처리" 검증은 어느 프로젝트든 같은 5줄(워커 기동 → 발행 →
4
+ // 폴링 대기 → 단언 → 워커 정리)을 반복하게 된다. 이 반복을 한 호출로
5
+ // 접는다 — 실 NATS JetStream 위에서 임시 워커를 띄워 해당 잡이 실제로
6
+ // 소비·처리되는 것까지 확증한다(§9 실 인프라 · 목업 없음).
7
+ //
8
+ // const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')
9
+ // await expectJobProcessed(SendWelcomeMail, () => SendWelcomeMail.later(user.id), { nats })
10
+ //
11
+ // 발행 함수를 잡 자신으로 한정하지 않는 이유: 결정 32(잡 발행 위치 자유)에
12
+ // 따라 발행이 서비스(`RegisterUser.call(...)`)나 컨트롤러 경유로 일어나는
13
+ // 흐름도 같은 헬퍼로 검증할 수 있어야 한다.
14
+ import { configureJobs } from './jobs.js';
15
+ import { runWorker } from './worker.js';
16
+ /**
17
+ * `publish()` 가 발행한 잡이 실 NATS JetStream 을 거쳐 **핸들러까지 실행**
18
+ * 되는 것을 확증한다. 임시 워커를 띄워 해당 잡의 succeeded 이벤트를 기다리고,
19
+ * 어떤 경로로든(성공 실패·DLQ·타임아웃) 결과가 나오면 워커를 정리한다.
20
+ *
21
+ * - 잡 핸들러가 throw 해 재시도 소진 → DLQ 로 가면 실패로 단언한다.
22
+ * - 발행 설정(configureJobs)은 헬퍼가 주어진 nats 로 대신 해 준다 —
23
+ * 테스트가 부팅 코드를 흉내낼 필요가 없다.
24
+ */
25
+ export async function expectJobProcessed(target, publish, opts) {
26
+ const timeoutMs = opts.timeoutMs ?? 10_000;
27
+ const name = target.name; // 이름 미해석이면 여기서 수리 안내 에러 (jobs.ts)
28
+ configureJobs(opts.nats);
29
+ let succeeded = false;
30
+ let deadError;
31
+ const worker = await runWorker({
32
+ nats: opts.nats,
33
+ onEvent(e) {
34
+ if (e.kind === 'succeeded' && e.job === name)
35
+ succeeded = true;
36
+ else if (e.kind === 'dead' && e.job === name)
37
+ deadError = e.error;
38
+ },
39
+ });
40
+ try {
41
+ await publish();
42
+ const t0 = Date.now();
43
+ while (!succeeded) {
44
+ if (deadError !== undefined) {
45
+ throw new Error(`[gaonjs/testing] 잡 '${name}' 이 재시도를 소진하고 DLQ 로 갔습니다: ${deadError}\n` +
46
+ `→ domain/jobs/${name}.ts 핸들러의 throw 원인을 고치고 다시 실행하세요.`);
47
+ }
48
+ if (Date.now() - t0 > timeoutMs) {
49
+ throw new Error(`[gaonjs/testing] 잡 '${name}' 이 ${timeoutMs}ms 안에 처리되지 않았습니다.\n` +
50
+ `→ publish 함수가 이 잡을 실제로 발행하는지(.later/.in/.at · 서비스 경유),\n` +
51
+ ` 잡 이름이 '${name}' 으로 등록돼 있는지(domain/jobs/ 파일명 또는 { name }) 확인하세요.\n` +
52
+ `→ NATS 가 떠 있는지도 확인: docker compose up -d nats`);
53
+ }
54
+ await new Promise((r) => setTimeout(r, 25));
55
+ }
56
+ }
57
+ finally {
58
+ await worker.stop();
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/async",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Gaon NATS 통합: 잡·이벤트·스케줄러·채널(ws)·허브(프레즌스) (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,14 +23,14 @@
23
23
  "dist",
24
24
  "README.md"
25
25
  ],
26
+ "scripts": {
27
+ "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
28
+ },
26
29
  "dependencies": {
30
+ "@gaonjs/core": "workspace:*",
27
31
  "@nats-io/transport-node": "^3.1.0",
28
32
  "@nats-io/jetstream": "^3.1.0",
29
33
  "@nats-io/kv": "^3.1.0",
30
- "kysely": "^0.29.4",
31
- "@gaonjs/core": "0.1.4"
32
- },
33
- "scripts": {
34
- "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
34
+ "kysely": "^0.29.4"
35
35
  }
36
- }
36
+ }
@@ -1,9 +0,0 @@
1
- import { type GaonNats } from '../nats.js';
2
- export declare const TEST_NATS_URL: string;
3
- export declare function createTestNats(name?: string): Promise<GaonNats>;
4
- /** 충돌 없는 버킷/키 프리픽스. */
5
- export declare function uniqueName(prefix: string): string;
6
- /** KV 버킷(스트림 KV_<bucket>)을 삭제해 테스트 잔여물을 정리한다. */
7
- export declare function dropBucket(nats: GaonNats, bucket: string): Promise<void>;
8
- /** 이름으로 스트림을 삭제한다(잡·DLQ·이벤트 스트림 정리). 없으면 무시. */
9
- export declare function dropStream(nats: GaonNats, stream: string): Promise<void>;
@@ -1,26 +0,0 @@
1
- // 실 NATS 테스트 인프라 (CLAUDE.md §9 — 목업·인메모리 금지).
2
- // compose.yaml 의 nats(4223, JetStream)에 붙는다. mega-nats(4222) 회피용
3
- // 전용 포트라 GAON_TEST_NATS_URL 로 재지정한다.
4
- //
5
- // 격리: NATS KV 는 트랜잭션이 없으므로, 테스트마다 고유 버킷/subject
6
- // 프리픽스를 쓰고 끝나면 스트림을 삭제해 정리한다.
7
- import { connectNats } from '../nats.js';
8
- import { jetstreamManager } from '@nats-io/jetstream';
9
- export const TEST_NATS_URL = process.env.GAON_TEST_NATS_URL ?? 'nats://127.0.0.1:4223';
10
- export async function createTestNats(name) {
11
- return connectNats({ servers: TEST_NATS_URL, name: name ?? 'gaon-test' });
12
- }
13
- /** 충돌 없는 버킷/키 프리픽스. */
14
- export function uniqueName(prefix) {
15
- return `${prefix}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
16
- }
17
- /** KV 버킷(스트림 KV_<bucket>)을 삭제해 테스트 잔여물을 정리한다. */
18
- export async function dropBucket(nats, bucket) {
19
- const jsm = await jetstreamManager(nats.nc);
20
- await jsm.streams.delete(`KV_${bucket}`).catch(() => { });
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
- }