@gaonjs/async 0.1.3 → 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.
Files changed (49) hide show
  1. package/dist/__fixtures__/testnats.d.ts +9 -0
  2. package/dist/__fixtures__/testnats.js +26 -0
  3. package/dist/backoff.d.ts +20 -0
  4. package/dist/backoff.js +31 -0
  5. package/dist/channel.d.ts +52 -0
  6. package/dist/channel.js +17 -0
  7. package/dist/codec.d.ts +8 -0
  8. package/dist/codec.js +57 -0
  9. package/dist/cron.d.ts +7 -0
  10. package/dist/cron.js +88 -0
  11. package/dist/dlq.d.ts +18 -0
  12. package/dist/dlq.js +70 -0
  13. package/dist/duration.d.ts +5 -0
  14. package/dist/duration.js +33 -0
  15. package/dist/events.d.ts +45 -0
  16. package/dist/events.js +104 -0
  17. package/dist/hub.d.ts +33 -0
  18. package/dist/hub.js +247 -0
  19. package/dist/index.d.ts +22 -1
  20. package/dist/index.js +47 -4
  21. package/dist/jobs.d.ts +75 -0
  22. package/dist/jobs.js +138 -0
  23. package/dist/keys.d.ts +15 -0
  24. package/dist/keys.js +43 -0
  25. package/dist/lease.d.ts +31 -0
  26. package/dist/lease.js +86 -0
  27. package/dist/listeners.d.ts +37 -0
  28. package/dist/listeners.js +89 -0
  29. package/dist/nats.d.ts +28 -0
  30. package/dist/nats.js +61 -0
  31. package/dist/outbox.d.ts +34 -0
  32. package/dist/outbox.js +130 -0
  33. package/dist/outboxContext.d.ts +8 -0
  34. package/dist/outboxContext.js +16 -0
  35. package/dist/presence.d.ts +26 -0
  36. package/dist/presence.js +0 -0
  37. package/dist/protocol.d.ts +96 -0
  38. package/dist/protocol.js +66 -0
  39. package/dist/runtime.d.ts +39 -0
  40. package/dist/runtime.js +129 -0
  41. package/dist/schedule.d.ts +71 -0
  42. package/dist/schedule.js +143 -0
  43. package/dist/streams.d.ts +37 -0
  44. package/dist/streams.js +83 -0
  45. package/dist/work.d.ts +52 -0
  46. package/dist/work.js +88 -0
  47. package/dist/worker.d.ts +71 -0
  48. package/dist/worker.js +176 -0
  49. package/package.json +5 -1
package/dist/lease.js ADDED
@@ -0,0 +1,86 @@
1
+ // @gaonjs/async · 리스 기반 리더 선출 (§7 · 질문 19)
2
+ //
3
+ // 허브의 HA(active-standby)와 스케줄러 틱(§7 잡, M7)이 같은 메커니즘을
4
+ // 쓴다: NATS KV 버킷에 TTL 을 걸고, 리더 후보가 리더 키를 **원자적으로
5
+ // 생성**(create — 이미 있으면 실패)해 상호배제를 얻는다. 리더는 TTL 의
6
+ // 절반 주기로 키를 갱신(revision CAS)해 리스를 연장하고, 리더가 죽으면
7
+ // 갱신이 끊겨 TTL 만료 후 대기 인스턴스가 승격한다. (2026-07-22 실 NATS
8
+ // 실측: create 상호배제·TTL 만료 승격 확인.)
9
+ const DEFAULT_TTL_MS = 5000;
10
+ /**
11
+ * 리더 선출을 시작한다. 반환 핸들의 isLeader 로 상태를 읽고 stop() 으로
12
+ * 사임한다. 콜백(onElected/onRevoked)으로 리더십 전이를 통지한다.
13
+ */
14
+ export async function leaseLeader(opts) {
15
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
16
+ const refreshMs = opts.refreshMs ?? Math.max(500, Math.floor(ttlMs / 2));
17
+ const bucket = opts.bucket ?? 'gaon_lease';
18
+ // TTL 은 버킷 단위(MaxAge) — 갱신이 끊긴 리더 키가 만료돼 승계가 열린다.
19
+ const kv = await opts.nats.kv(bucket, { ttl: ttlMs });
20
+ let leader = false;
21
+ let revision = 0;
22
+ let stopped = false;
23
+ let timer;
24
+ const setLeader = (next) => {
25
+ if (next === leader)
26
+ return;
27
+ leader = next;
28
+ if (next)
29
+ opts.onElected?.();
30
+ else
31
+ opts.onRevoked?.();
32
+ };
33
+ const tick = async () => {
34
+ try {
35
+ if (!leader) {
36
+ // 후보: 리더 키를 원자적으로 생성 시도. 성공 = 승격.
37
+ try {
38
+ revision = await kv.create(opts.key, opts.id);
39
+ setLeader(true);
40
+ }
41
+ catch {
42
+ // 키가 이미 있음(다른 리더 보유) — 대기 유지.
43
+ }
44
+ }
45
+ else {
46
+ // 리더: revision CAS 로 갱신해 리스 연장. 실패 = 리스 상실.
47
+ try {
48
+ revision = await kv.update(opts.key, opts.id, revision);
49
+ }
50
+ catch {
51
+ setLeader(false);
52
+ revision = 0;
53
+ }
54
+ }
55
+ }
56
+ catch (err) {
57
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
58
+ }
59
+ finally {
60
+ if (!stopped)
61
+ timer = setTimeout(() => void tick(), refreshMs);
62
+ }
63
+ };
64
+ // 첫 시도는 즉시 — 부팅 직후 리더가 정해지도록.
65
+ await tick();
66
+ return {
67
+ get isLeader() {
68
+ return leader;
69
+ },
70
+ async stop() {
71
+ stopped = true;
72
+ if (timer)
73
+ clearTimeout(timer);
74
+ if (leader) {
75
+ // 사임: 내 revision 에서만 삭제(내가 여전히 리더일 때만) — 즉시 승계.
76
+ try {
77
+ await kv.delete(opts.key);
78
+ }
79
+ catch {
80
+ // 이미 만료·교체됐으면 무시.
81
+ }
82
+ setLeader(false);
83
+ }
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,37 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import { type StreamTuning } from './streams.js';
3
+ export type ListenerEvent = {
4
+ readonly kind: 'handled';
5
+ readonly listener: string;
6
+ readonly event: string;
7
+ } | {
8
+ readonly kind: 'retrying';
9
+ readonly listener: string;
10
+ readonly event: string;
11
+ readonly attempt: number;
12
+ } | {
13
+ readonly kind: 'dropped';
14
+ readonly listener: string;
15
+ readonly event: string;
16
+ readonly error: string;
17
+ } | {
18
+ readonly kind: 'error';
19
+ readonly error: string;
20
+ };
21
+ export interface ListenersOptions {
22
+ readonly nats: GaonNats;
23
+ /** ack 대기(ms). 기본 30000. */
24
+ readonly ackWaitMs?: number;
25
+ /** 리스너 최대 재전달(크래시·실패 합산). 기본 6. */
26
+ readonly maxDeliver?: number;
27
+ /** graceful drain 상한(ms). 기본 30000. */
28
+ readonly drainTimeoutMs?: number;
29
+ readonly tuning?: StreamTuning;
30
+ onEvent?(e: ListenerEvent): void;
31
+ }
32
+ export interface ListenersHandle {
33
+ stop(): Promise<void>;
34
+ readonly inflight: number;
35
+ }
36
+ /** 등록된 리스너들을 이벤트 스트림에 붙여 소비를 시작한다. */
37
+ export declare function runListeners(opts: ListenersOptions): Promise<ListenersHandle>;
@@ -0,0 +1,89 @@
1
+ // @gaonjs/async · 리스너 소비 (§7 M7 · line 798~826)
2
+ //
3
+ // 워커(`gaon work`)가 등록된 리스너마다 이벤트 스트림에 durable 컨슈머를
4
+ // 붙여 소비한다. 리스너 id 가 durable 이름의 근간이라, 재시작해도 같은
5
+ // 지점부터 이어 받는다(중복 배달 가능 — 리스너는 멱등 권장).
6
+ //
7
+ // 이벤트는 알림성이라 잡보다 단순하게 다룬다: 성공→ack, 실패→백오프 후
8
+ // 재전달(nak), max_deliver 소진→폐기(term, 로깅). 잡 같은 DLQ 는 두지 않되
9
+ // 재시도는 준다.
10
+ import { jetstreamManager } from '@nats-io/jetstream';
11
+ import { EVENTS_STREAM, eventSubject, listenerConsumerName, toNanos, ensureEventsStream, } from './streams.js';
12
+ import { decodePayloadBytes } from './codec.js';
13
+ import { backoffDelayMs } from './backoff.js';
14
+ import { registeredListeners } from './events.js';
15
+ const DEFAULT_ACK_WAIT_MS = 30000;
16
+ const DEFAULT_MAX_DELIVER = 6;
17
+ const DEFAULT_DRAIN_MS = 30000;
18
+ /** 등록된 리스너들을 이벤트 스트림에 붙여 소비를 시작한다. */
19
+ export async function runListeners(opts) {
20
+ const ackWaitMs = opts.ackWaitMs ?? DEFAULT_ACK_WAIT_MS;
21
+ const maxDeliver = opts.maxDeliver ?? DEFAULT_MAX_DELIVER;
22
+ const drainTimeoutMs = opts.drainTimeoutMs ?? DEFAULT_DRAIN_MS;
23
+ const emit = (e) => opts.onEvent?.(e);
24
+ await ensureEventsStream(opts.nats, opts.tuning);
25
+ const jsm = await jetstreamManager(opts.nats.nc);
26
+ const listeners = registeredListeners();
27
+ const closers = [];
28
+ const inflightAll = new Set();
29
+ for (const listener of listeners) {
30
+ const durable = listenerConsumerName(listener.id);
31
+ await jsm.consumers.add(EVENTS_STREAM, {
32
+ durable_name: durable,
33
+ filter_subject: eventSubject(listener.eventName),
34
+ ack_policy: 'explicit',
35
+ ack_wait: toNanos(ackWaitMs),
36
+ max_deliver: maxDeliver,
37
+ });
38
+ const handle = async (m) => {
39
+ const attempt = m.info.deliveryCount;
40
+ try {
41
+ const payload = decodePayloadBytes(m.data);
42
+ await listener.handler(payload);
43
+ m.ack();
44
+ emit({ kind: 'handled', listener: listener.id, event: listener.eventName });
45
+ }
46
+ catch (err) {
47
+ const error = err instanceof Error ? err.message : String(err);
48
+ if (attempt < maxDeliver) {
49
+ m.nak(backoffDelayMs(attempt, { jitter: 0.2 }));
50
+ emit({ kind: 'retrying', listener: listener.id, event: listener.eventName, attempt });
51
+ }
52
+ else {
53
+ m.term();
54
+ emit({ kind: 'dropped', listener: listener.id, event: listener.eventName, error });
55
+ }
56
+ }
57
+ };
58
+ const consumer = await opts.nats.js.consumers.get(EVENTS_STREAM, durable);
59
+ const messages = await consumer.consume({ max_messages: 1 });
60
+ const loop = (async () => {
61
+ for await (const m of messages) {
62
+ const p = handle(m).finally(() => inflightAll.delete(p));
63
+ inflightAll.add(p);
64
+ await Promise.race(inflightAll);
65
+ }
66
+ })();
67
+ closers.push(async () => {
68
+ messages.stop();
69
+ await loop.catch(() => { });
70
+ });
71
+ }
72
+ let stopped = false;
73
+ return {
74
+ get inflight() {
75
+ return inflightAll.size;
76
+ },
77
+ async stop() {
78
+ if (stopped)
79
+ return;
80
+ stopped = true;
81
+ for (const close of closers)
82
+ await close();
83
+ const deadline = Date.now() + drainTimeoutMs;
84
+ while (inflightAll.size > 0 && Date.now() < deadline) {
85
+ await Promise.race([...inflightAll, new Promise((r) => setTimeout(r, 200))]);
86
+ }
87
+ },
88
+ };
89
+ }
package/dist/nats.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type NatsConnection } from '@nats-io/transport-node';
2
+ import { type JetStreamClient } from '@nats-io/jetstream';
3
+ import { Kvm } from '@nats-io/kv';
4
+ import type { KV, KvOptions } from '@nats-io/kv';
5
+ export interface NatsOptions {
6
+ /** 접속지. 생략 시 env GAON_NATS_URL, 그다음 nats://127.0.0.1:4222. */
7
+ readonly servers?: string | readonly string[];
8
+ /** 연결 이름(모니터링 식별). serve/work/hub 프로세스가 각자 붙인다. */
9
+ readonly name?: string;
10
+ }
11
+ /** 연결 + JetStream + KV 를 한데 묶은 Gaon NATS 핸들. */
12
+ export interface GaonNats {
13
+ readonly nc: NatsConnection;
14
+ readonly js: JetStreamClient;
15
+ readonly kvm: Kvm;
16
+ /** 이름있는 KV 버킷을 열거나(없으면) 만든다. */
17
+ kv(bucket: string, opts?: Partial<KvOptions>): Promise<KV>;
18
+ /** 이름으로 JetStream 스트림을 삭제한다(없으면 무시). 운영 리셋·테스트 정리용. */
19
+ deleteStream(name: string): Promise<void>;
20
+ /** 처리 중 메시지를 흘려보내고 연결을 닫는다(graceful). */
21
+ close(): Promise<void>;
22
+ }
23
+ /**
24
+ * NATS 에 연결하고 JetStream·KV 핸들을 준비한다. 연결 실패는 수리
25
+ * 안내를 포함한 에러로 즉시 던진다(§7.5.3 — 에러가 곧 수리 안내서).
26
+ */
27
+ export declare function connectNats(opts?: NatsOptions): Promise<GaonNats>;
28
+ export type { NatsConnection, JetStreamClient, KV };
package/dist/nats.js ADDED
@@ -0,0 +1,61 @@
1
+ // @gaonjs/async · NATS 연결 (§7 실시간 · 백본)
2
+ //
3
+ // NATS 는 실시간(채널 브로드캐스트·프레즌스 전송)과 비동기(잡·이벤트,
4
+ // M7)의 공통 백본이다. 여기서는 연결 + JetStream + KV 핸들을 하나로
5
+ // 묶어 노출한다. 목업은 없다(§9) — 개발·테스트·운영 모두 실 NATS 로 돈다.
6
+ //
7
+ // 기본 접속지는 표준 포트(4222) — 사용자 프로젝트의 `gaon dev` compose 가
8
+ // 띄우는 NATS 다. 이 저장소의 테스트 인프라는 mega-nats(4222) 회피용으로
9
+ // 4223 을 쓰므로 `GAON_NATS_URL` 로 재지정한다(§9 테스트 픽스처).
10
+ import { connect } from '@nats-io/transport-node';
11
+ import { jetstream, jetstreamManager } from '@nats-io/jetstream';
12
+ import { Kvm } from '@nats-io/kv';
13
+ function resolveServers(opts) {
14
+ if (opts.servers)
15
+ return Array.isArray(opts.servers) ? [...opts.servers] : opts.servers;
16
+ return process.env.GAON_NATS_URL ?? 'nats://127.0.0.1:4222';
17
+ }
18
+ /**
19
+ * NATS 에 연결하고 JetStream·KV 핸들을 준비한다. 연결 실패는 수리
20
+ * 안내를 포함한 에러로 즉시 던진다(§7.5.3 — 에러가 곧 수리 안내서).
21
+ */
22
+ export async function connectNats(opts = {}) {
23
+ const servers = resolveServers(opts);
24
+ let nc;
25
+ try {
26
+ nc = await connect({ servers, name: opts.name });
27
+ }
28
+ catch (err) {
29
+ const where = Array.isArray(servers) ? servers.join(', ') : servers;
30
+ const msg = err instanceof Error ? err.message : String(err);
31
+ throw new Error(`[@gaonjs/async] NATS 연결 실패 (${where}): ${msg}\n` +
32
+ `→ 개발 인프라를 기동하세요: gaon dev (또는 docker compose up -d nats)\n` +
33
+ `→ 접속지를 바꾸려면 GAON_NATS_URL 환경변수를 설정하세요.`);
34
+ }
35
+ const js = jetstream(nc);
36
+ const kvm = new Kvm(js);
37
+ return {
38
+ nc,
39
+ js,
40
+ kvm,
41
+ async kv(bucket, kvOpts) {
42
+ // create 는 있으면 열고 없으면 만든다(멱등). 버킷 설정(ttl 등)은
43
+ // 최초 생성 시에만 적용된다.
44
+ return kvm.create(bucket, kvOpts);
45
+ },
46
+ async deleteStream(name) {
47
+ const jsm = await jetstreamManager(nc);
48
+ await jsm.streams.delete(name).catch(() => { });
49
+ },
50
+ async close() {
51
+ // drain 은 처리 중 메시지를 흘려보내고 닫는다. 이미 닫히는 중이거나
52
+ // in-flight 요청이 끊겨 throw 하면 강제 close 로 마무리한다.
53
+ try {
54
+ await nc.drain();
55
+ }
56
+ catch {
57
+ await nc.close().catch(() => { });
58
+ }
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,34 @@
1
+ import type { Kysely, Transaction } from 'kysely';
2
+ export declare const OUTBOX_TABLE = "_gaon_outbox";
3
+ /**
4
+ * `_gaon_outbox` 테이블을 보장한다(CREATE TABLE IF NOT EXISTS). id 는 클라이언트
5
+ * 생성 UUID 라 dialect 별 auto-increment 차이를 피한다. created_at 은 코드에서
6
+ * 넣어 dialect default 함수 의존을 없앤다.
7
+ */
8
+ export declare function ensureOutboxTable(db: Kysely<any>): Promise<void>;
9
+ /**
10
+ * 서비스 트랜잭션을 연다. 이 안에서의 emit 은 같은 트랜잭션으로 아웃박스에
11
+ * 적재된다(트랜잭션 밖 emit 은 즉시 발행). fn 이 throw 하면 롤백 —
12
+ * 비즈니스 쓰기와 아웃박스 적재가 함께 취소돼 정합이 유지된다.
13
+ */
14
+ export declare function runInTransaction<T>(db: Kysely<any>, fn: (trx: Transaction<any>) => Promise<T>): Promise<T>;
15
+ export interface RelayOptions {
16
+ /** 한 번에 처리할 최대 행 수. 기본 100. */
17
+ readonly batchSize?: number;
18
+ /** 폴링 주기(ms). 기본 1000. */
19
+ readonly pollMs?: number;
20
+ /** 진행 통지(로깅·테스트). */
21
+ onError?(err: Error): void;
22
+ onRelayed?(count: number): void;
23
+ }
24
+ export interface RelayHandle {
25
+ stop(): Promise<void>;
26
+ /** 한 번 즉시 폴링(테스트·부팅 직후 소진용). 발행 건수를 돌려준다. */
27
+ drainOnce(): Promise<number>;
28
+ }
29
+ /**
30
+ * 아웃박스 릴레이를 시작한다. 미발행 행을 폴링해 NATS 로 발행하고 표시한다.
31
+ * SKIP LOCKED 로 잠긴 행을 건너뛰어 여러 릴레이가 같은 행을 두 번 잡지
32
+ * 않는다(중복은 at-least-once 로 허용되지만 낭비를 줄인다).
33
+ */
34
+ export declare function runOutboxRelay(db: Kysely<any>, opts?: RelayOptions): RelayHandle;
package/dist/outbox.js ADDED
@@ -0,0 +1,130 @@
1
+ // @gaonjs/async · 아웃박스 패턴 (§7 M7 · line 828~836 · v1 코어 내장)
2
+ //
3
+ // 이중 쓰기(dual-write) 문제 해결: 서비스 트랜잭션이 커밋됐는데 이벤트 발행이
4
+ // 유실되는 일을 막는다. 트랜잭션 안 emit 은 같은 트랜잭션으로 `_gaon_outbox`
5
+ // 에 적재되고(runInTransaction + outboxContext), 백그라운드 릴레이(단순 폴링)가
6
+ // 미발행 행을 NATS 로 발행한 뒤 발행 표시한다. **at-least-once** — 발행 후
7
+ // 표시 사이에 죽으면 다음 폴링이 재발행하고, msgID 중복 제거가 흡수한다.
8
+ //
9
+ // DB 결합을 피하려 Kysely 핸들을 주입받는다(async→data 의존 없음). 테이블은
10
+ // 프레임웍 내부용이라 사용자 스키마 diff 밖에서 ensureOutboxTable 로 보장한다.
11
+ import { randomUUID } from 'node:crypto';
12
+ import { runWithOutbox } from './outboxContext.js';
13
+ import { encodePayload, decodePayload } from './codec.js';
14
+ import { publishToSubject } from './events.js';
15
+ export const OUTBOX_TABLE = '_gaon_outbox';
16
+ /**
17
+ * `_gaon_outbox` 테이블을 보장한다(CREATE TABLE IF NOT EXISTS). id 는 클라이언트
18
+ * 생성 UUID 라 dialect 별 auto-increment 차이를 피한다. created_at 은 코드에서
19
+ * 넣어 dialect default 함수 의존을 없앤다.
20
+ */
21
+ export async function ensureOutboxTable(db) {
22
+ await db.schema
23
+ .createTable(OUTBOX_TABLE)
24
+ .ifNotExists()
25
+ .addColumn('id', 'varchar(36)', (c) => c.primaryKey())
26
+ .addColumn('subject', 'varchar(255)', (c) => c.notNull())
27
+ .addColumn('payload', 'text', (c) => c.notNull())
28
+ .addColumn('created_at', 'timestamp', (c) => c.notNull())
29
+ .addColumn('published_at', 'timestamp')
30
+ .execute();
31
+ // 미발행 행 조회를 빠르게(published_at IS NULL 스캔).
32
+ await db.schema
33
+ .createIndex(`${OUTBOX_TABLE}_unpublished`)
34
+ .ifNotExists()
35
+ .on(OUTBOX_TABLE)
36
+ .column('published_at')
37
+ .execute()
38
+ .catch(() => { });
39
+ }
40
+ /** 트랜잭션에 묶인 stager — 같은 트랜잭션으로 outbox 행을 insert 한다. */
41
+ function makeStager(trx) {
42
+ return {
43
+ async stage(subject, payload) {
44
+ await trx
45
+ .insertInto(OUTBOX_TABLE)
46
+ .values({
47
+ id: randomUUID(),
48
+ subject,
49
+ payload: encodePayload(payload),
50
+ created_at: new Date(),
51
+ published_at: null,
52
+ })
53
+ .execute();
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * 서비스 트랜잭션을 연다. 이 안에서의 emit 은 같은 트랜잭션으로 아웃박스에
59
+ * 적재된다(트랜잭션 밖 emit 은 즉시 발행). fn 이 throw 하면 롤백 —
60
+ * 비즈니스 쓰기와 아웃박스 적재가 함께 취소돼 정합이 유지된다.
61
+ */
62
+ export function runInTransaction(db, fn) {
63
+ return db.transaction().execute((trx) => runWithOutbox(makeStager(trx), () => fn(trx)));
64
+ }
65
+ const DEFAULT_BATCH = 100;
66
+ const DEFAULT_POLL_MS = 1000;
67
+ /**
68
+ * 아웃박스 릴레이를 시작한다. 미발행 행을 폴링해 NATS 로 발행하고 표시한다.
69
+ * SKIP LOCKED 로 잠긴 행을 건너뛰어 여러 릴레이가 같은 행을 두 번 잡지
70
+ * 않는다(중복은 at-least-once 로 허용되지만 낭비를 줄인다).
71
+ */
72
+ export function runOutboxRelay(db, opts = {}) {
73
+ const batchSize = opts.batchSize ?? DEFAULT_BATCH;
74
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
75
+ let stopped = false;
76
+ let timer;
77
+ const relayBatch = async () => {
78
+ // 미발행 행을 트랜잭션 안에서 잠그고(SKIP LOCKED) 발행 후 표시한다.
79
+ // 발행(NATS)은 트랜잭션 밖 부수효과라, 표시 커밋 전에 죽으면 재발행된다
80
+ // (at-least-once). msgID=행 id 로 이벤트 스트림에서 중복 제거된다.
81
+ const rows = await db.transaction().execute(async (trx) => {
82
+ const locked = (await trx
83
+ .selectFrom(OUTBOX_TABLE)
84
+ .selectAll()
85
+ .where('published_at', 'is', null)
86
+ .orderBy('created_at')
87
+ .limit(batchSize)
88
+ .forUpdate()
89
+ .skipLocked()
90
+ .execute());
91
+ for (const row of locked) {
92
+ await publishToSubject(row.subject, decodePayload(row.payload), row.id);
93
+ await trx
94
+ .updateTable(OUTBOX_TABLE)
95
+ .set({ published_at: new Date() })
96
+ .where('id', '=', row.id)
97
+ .execute();
98
+ }
99
+ return locked;
100
+ });
101
+ if (rows.length > 0)
102
+ opts.onRelayed?.(rows.length);
103
+ return rows.length;
104
+ };
105
+ const tick = async () => {
106
+ try {
107
+ // 밀린 행이 있으면 배치를 연달아 비운다(폴링 지연 없이 소진).
108
+ let n = 0;
109
+ do {
110
+ n = await relayBatch();
111
+ } while (n >= batchSize && !stopped);
112
+ }
113
+ catch (err) {
114
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
115
+ }
116
+ finally {
117
+ if (!stopped)
118
+ timer = setTimeout(() => void tick(), pollMs);
119
+ }
120
+ };
121
+ void tick();
122
+ return {
123
+ async stop() {
124
+ stopped = true;
125
+ if (timer)
126
+ clearTimeout(timer);
127
+ },
128
+ drainOnce: relayBatch,
129
+ };
130
+ }
@@ -0,0 +1,8 @@
1
+ /** 트랜잭션에 묶인 아웃박스 적재기. 같은 트랜잭션으로 outbox 행을 쓴다. */
2
+ export interface OutboxStager {
3
+ stage(subject: string, payload: unknown): Promise<void>;
4
+ }
5
+ /** 현재 활성 아웃박스 stager(트랜잭션 안일 때만). 없으면 undefined. */
6
+ export declare function currentOutbox(): OutboxStager | undefined;
7
+ /** stager 를 문맥에 세우고 fn 을 실행한다(트랜잭션 런타임이 호출). */
8
+ export declare function runWithOutbox<T>(stager: OutboxStager, fn: () => Promise<T>): Promise<T>;
@@ -0,0 +1,16 @@
1
+ // @gaonjs/async · 아웃박스 트랜잭션 컨텍스트 (§7 M7 · line 828~836)
2
+ //
3
+ // emit() 이 "지금 트랜잭션 안인가"를 알아야 아웃박스로 적재할지 즉시 발행할지
4
+ // 정한다. 스택을 타고 내려가는 이 문맥을 AsyncLocalStorage 로 전달한다 —
5
+ // 서비스 트랜잭션이 열리면 트랜잭션에 묶인 stager 를 세우고, 그 안에서의
6
+ // emit 은 같은 트랜잭션으로 _gaon_outbox 에 적재된다. 개발자에겐 보이지 않는다.
7
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
+ const storage = new AsyncLocalStorage();
9
+ /** 현재 활성 아웃박스 stager(트랜잭션 안일 때만). 없으면 undefined. */
10
+ export function currentOutbox() {
11
+ return storage.getStore();
12
+ }
13
+ /** stager 를 문맥에 세우고 fn 을 실행한다(트랜잭션 런타임이 호출). */
14
+ export function runWithOutbox(stager, fn) {
15
+ return storage.run(stager, fn);
16
+ }
@@ -0,0 +1,26 @@
1
+ import type { GaonNats } from './nats.js';
2
+ import type { PresenceMember } from './channel.js';
3
+ import { type PresenceEvent } from './protocol.js';
4
+ export declare const PRESENCE_BUCKET = "gaon_presence";
5
+ export interface PresenceClientOptions {
6
+ readonly nats: GaonNats;
7
+ /** 이 웹서버 식별자(허브가 서버→멤버 매핑에 사용). */
8
+ readonly server: string;
9
+ /** 허브 TCP 주소 'host:port'. 생략 시 NATS KV 에 공지된 리더 엔드포인트를 발견. */
10
+ readonly hubAddr?: string;
11
+ /** ping 주기(ms). 반열림 감지 백스톱. 기본 3000(허브 pingTimeout 의 1/3 이하). */
12
+ readonly heartbeatMs?: number;
13
+ /** 재접속 백오프 최소·최대(ms). 기본 250·2000. */
14
+ readonly reconnectMinMs?: number;
15
+ readonly reconnectMaxMs?: number;
16
+ }
17
+ export interface PresenceClient {
18
+ join(channel: string, member: string, info: Record<string, unknown>): Promise<void>;
19
+ leave(channel: string, member: string): Promise<void>;
20
+ /** 채널의 현재 접속자 목록(허브 KV 권위 ∪ 로컬 멤버). */
21
+ list(channel: string): Promise<PresenceMember[]>;
22
+ /** 채널 프레즌스 델타 구독(허브가 NATS 로 broadcast). 반환 함수로 해지. */
23
+ subscribe(channel: string, onEvent: (e: PresenceEvent) => void): () => void;
24
+ close(): Promise<void>;
25
+ }
26
+ export declare function createPresenceClient(opts: PresenceClientOptions): Promise<PresenceClient>;
Binary file
@@ -0,0 +1,96 @@
1
+ import type { PresenceMember } from './channel.js';
2
+ /** 클라이언트가 채널로 보내는 메시지. data 는 onMessage 로 전달된다. */
3
+ export interface ClientMessage {
4
+ readonly t: 'msg';
5
+ readonly data: unknown;
6
+ }
7
+ export type ServerFrame = {
8
+ readonly t: 'joined';
9
+ readonly channel: string;
10
+ readonly member: string;
11
+ } | {
12
+ readonly t: 'msg';
13
+ readonly data: unknown;
14
+ } | {
15
+ readonly t: 'presence';
16
+ readonly members: readonly PresenceMember[];
17
+ } | {
18
+ readonly t: 'presence:join';
19
+ readonly member: PresenceMember;
20
+ } | {
21
+ readonly t: 'presence:leave';
22
+ readonly member: string;
23
+ } | {
24
+ readonly t: 'error';
25
+ readonly message: string;
26
+ };
27
+ /** 프레즌스 상태 변경을 허브에 알리는 이벤트. 허브가 KV 권위를 갱신한다. */
28
+ export type PresenceCommand = {
29
+ readonly op: 'join';
30
+ readonly channel: string;
31
+ readonly member: string;
32
+ readonly info: Record<string, unknown>;
33
+ readonly server: string;
34
+ } | {
35
+ readonly op: 'leave';
36
+ readonly channel: string;
37
+ readonly member: string;
38
+ readonly server: string;
39
+ } | {
40
+ readonly op: 'heartbeat';
41
+ readonly channel: string;
42
+ readonly member: string;
43
+ readonly server: string;
44
+ };
45
+ export type HubCommand = {
46
+ readonly op: 'register';
47
+ readonly server: string;
48
+ } | {
49
+ readonly op: 'join';
50
+ readonly channel: string;
51
+ readonly member: string;
52
+ readonly info: Record<string, unknown>;
53
+ } | {
54
+ readonly op: 'leave';
55
+ readonly channel: string;
56
+ readonly member: string;
57
+ } | {
58
+ readonly op: 'synced';
59
+ } | {
60
+ readonly op: 'unregister';
61
+ readonly server: string;
62
+ } | {
63
+ readonly op: 'ping';
64
+ };
65
+ export type PresenceEvent = {
66
+ readonly type: 'join';
67
+ readonly channel: string;
68
+ readonly member: PresenceMember;
69
+ } | {
70
+ readonly type: 'leave';
71
+ readonly channel: string;
72
+ readonly member: string;
73
+ };
74
+ /** channelSubject(name) 로 오가는 브로드캐스트 봉투. */
75
+ export interface BroadcastEnvelope {
76
+ readonly data: unknown;
77
+ }
78
+ /** 웹서버 → 허브 프레즌스 명령(허브가 큐 구독). */
79
+ export declare const HUB_PRESENCE_SUBJECT = "gaon.hub.presence";
80
+ /** 허브 → 전 서버 프레즌스 델타(채널별). 채널명은 내부에서 인코딩한다. */
81
+ export declare function presenceEventSubject(channel: string): string;
82
+ /** 리더 허브가 자신의 TCP 엔드포인트를 공지하는 KV 버킷·키(멀티호스트 발견). */
83
+ export declare const HUB_ENDPOINT_BUCKET = "gaon_hub";
84
+ export declare const HUB_ENDPOINT_KEY = "endpoint";
85
+ /** 값을 개행 종결 JSON 한 줄로 인코딩(TCP 전송 단위). */
86
+ export declare function encodeLine(value: unknown): string;
87
+ /**
88
+ * TCP 청크를 누적해 완결된 라인(JSON)만 파싱해 내는 디코더를 만든다.
89
+ * 소켓은 메시지 경계를 보장하지 않으므로(청크 분할·병합) 개행으로 잘라
90
+ * 완성된 줄만 넘긴다. 파싱 불가한 줄은 건너뛴다.
91
+ */
92
+ export declare function createLineDecoder<T>(): (chunk: string) => T[];
93
+ /** JSON 을 NATS 페이로드(Uint8Array)로. */
94
+ export declare function encodeJson(value: unknown): Uint8Array;
95
+ /** NATS 페이로드(Uint8Array)를 JSON 으로. 실패 시 undefined. */
96
+ export declare function decodeJson<T>(bytes: Uint8Array): T | undefined;