bundis 0.1.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/CLAUDE.md ADDED
@@ -0,0 +1,289 @@
1
+ # CLAUDE.md — bun-resp-sqlite
2
+
3
+ > SQLite를 저장 엔진으로 쓰는 RESP 호환 서버.
4
+ > 클라이언트는 항상 순정 `Bun.RedisClient`로 접속한다.
5
+ > 이 문서는 **설계의 단일 진실(SSOT)** 이다. 구현은 Claude Code에서 별도로 진행한다.
6
+
7
+ ---
8
+
9
+ ## 0. 한 줄 정의
10
+
11
+ `Bun.RedisClient`(RESP3 클라이언트)가 보내는 wire protocol을, Bun TCP 서버가 받아 SQLite로 처리하고 RESP로 응답한다. **서버는 클라이언트를 흉내 내지 않는다 — 프로토콜에 응답할 뿐이다.**
12
+
13
+ ```
14
+ ┌────────────────────┐ RESP3 over TCP ┌──────────────────────────────┐
15
+ │ Bun.RedisClient │ ──────────────────▶ │ bun-resp-sqlite (this) │
16
+ │ (순정, 무수정) │ ◀────────────────── │ Bun.listen() + bun:sqlite │
17
+ └────────────────────┘ RESP3 replies └──────────────────────────────┘
18
+ 애플리케이션 같은 머신/별도 프로세스
19
+ ```
20
+
21
+ ---
22
+
23
+ ## 1. 목적과 비목적 (Scope SSOT)
24
+
25
+ ### 목적
26
+ - `Bun.RedisClient`가 **코드 수정 없이** 접속·동작한다. 접속 URL만 이 서버를 가리키면 끝.
27
+ - Redis 서버 **설치 없이** Redis를 쓴다. 의존성은 Bun 런타임 하나(`bun:sqlite`, `Bun.listen` 모두 내장).
28
+ - 데이터는 SQLite 파일에 영속화된다(프로세스 재시작 후 생존).
29
+
30
+ ### 비목적 (명시적으로 하지 않는 것)
31
+ - 인프로세스 라이브러리 모드(클라이언트 시그니처 직접 구현)는 **만들지 않는다.** 접속은 무조건 RESP over TCP.
32
+ - Redis Cluster / Sentinel은 지원하지 않는다 (`Bun.RedisClient` 자체가 미지원).
33
+ - 다중 노드 HA·자동 failover는 범위 밖.
34
+ - 인메모리 전용 Redis의 모든 성능 특성을 동일 재현하지 않는다 — **인터페이스 호환이 목표지 성능 동등이 아니다.**
35
+
36
+ ---
37
+
38
+ ## 2. 호환성 계약 (Compatibility Contract — 가장 중요한 SSOT)
39
+
40
+ 호환성의 기준은 **"클라이언트 메서드를 다 구현했는가"가 아니라 "클라이언트가 보내는 바이트에 올바른 바이트로 답하는가"** 이다. 아래 4개 계층을 모두 만족해야 "완전 호환"이 성립한다.
41
+
42
+ ### 2.1 프로토콜 계층 — RESP3 필수
43
+ `Bun.RedisClient`는 RESP3로 말한다(Zig 구현). 따라서:
44
+ - 서버는 `HELLO 3` 핸드셰이크에 응답해야 한다. RESP2만 구현하면 응답 타입이 어긋난다.
45
+ - RESP3 고유 타입을 낼 수 있어야 한다: Null(`_`), Boolean(`#`), Map(`%`), Set(`~`), Double(`,`), Verbatim 등.
46
+ - 클라이언트의 자동 타입 변환 규칙(아래 §2.4)을 서버 응답이 유발해야 한다.
47
+
48
+ ### 2.2 핸드셰이크/연결 계층 — 연결 성립의 전제
49
+ 클라이언트가 connect 시 호출하는 명령은 **명령 처리 이전에** 반드시 응답해야 한다. 하나라도 빠지면 핸드셰이크에서 막힌다.
50
+
51
+ | 명령 | 역할 | 1차 구현 |
52
+ |---|---|---|
53
+ | `HELLO [3] [AUTH ...]` | 프로토콜 협상 + 서버 정보 Map 반환 | **필수** |
54
+ | `AUTH` | 인증 (URL에 자격증명 있을 때) | **필수** (no-auth면 OK 반환) |
55
+ | `PING` | 헬스체크 / keepalive | **필수** |
56
+ | `SELECT` | DB 번호 선택 (URL `/0`) | **필수** (단일 DB여도 OK 반환) |
57
+ | `INFO` | 서버 메타데이터 | **필수** (최소 필드) |
58
+ | `QUIT` | 연결 종료 | **필수** |
59
+ | `CLIENT` | 클라이언트 설정 (`CLIENT SETINFO` 등) | 권장 (OK 반환) |
60
+
61
+ > 이 명령들은 클라이언트 문서상 **자동 파이프라이닝이 비활성화**되는 명령군에 속한다(`AUTH`/`INFO`/`QUIT`/`SELECT`/`MULTI`/`EXEC`/`WATCH` 등). 서버는 이들을 단건 동기 응답으로 처리한다고 가정해도 된다.
62
+
63
+ ### 2.3 명령 계층 — 커버리지가 곧 호환성
64
+ 전용 메서드가 있는 명령은 반드시 지원한다. 나머지는 클라이언트가 `send(CMD, args[])`로 raw 전송하므로, **서버 입장에서는 전용/raw 구분이 없다 — 모두 같은 RESP 배열로 도착한다.** 즉 호환성 확장 = 명령 디스패치 테이블에 케이스 추가.
65
+
66
+ 전용 메서드 보유 명령(문서 확인됨, **1차 핵심 대상**):
67
+ - String: `GET` `SET` `GETSET`(via send) `DEL` `EXISTS` `GETBUFFER`(=GET, 바이너리 응답)
68
+ - Numeric: `INCR` `DECR`
69
+ - Expire: `EXPIRE` `TTL`
70
+ - Hash: `HSET` `HMSET` `HGET` `HMGET` `HINCRBY` `HINCRBYFLOAT`
71
+ - Set: `SADD` `SREM` `SISMEMBER` `SMEMBERS` `SRANDMEMBER` `SPOP`
72
+ - Multi-key: `MGET` `MSET` `MSETNX` `SETEX` `SETNX`
73
+
74
+ ### 2.4 응답 타입 변환 계약 — 어긋나면 "호환"이 깨지는 지점
75
+ 클라이언트는 RESP 응답을 JS 값으로 자동 변환한다. 서버는 **정확히 그 변환을 유발하는 RESP 타입**을 내야 한다. 이게 호환성의 가장 미묘한 부분.
76
+
77
+ | 클라이언트 기대 JS | 서버가 보낼 RESP 타입 |
78
+ |---|---|
79
+ | number | Integer (`:`) |
80
+ | string | Bulk/Simple String (`$`/`+`) |
81
+ | `null` | Null Bulk / RESP3 Null (`_`) |
82
+ | array | Array (`*`) |
83
+ | boolean | RESP3 Boolean (`#`) |
84
+ | object(map) | RESP3 Map (`%`) |
85
+ | array(set) | RESP3 Set (`~`) |
86
+ | Error throw | Error (`-`) + 코드 |
87
+
88
+ **명령별 특수 변환(반드시 준수):**
89
+ - `EXISTS` → 정수 1/0이 아니라 **boolean**으로 변환됨. 서버는 RESP3 Boolean으로 답해야 클라이언트 기대와 일치. (또는 클라이언트가 정수→boolean 변환을 보장하는지 통합 테스트로 고정)
90
+ - `SISMEMBER` → 동일하게 boolean.
91
+ - `getBuffer()` → 같은 `GET`이지만 Uint8Array로 받음. 서버는 동일 Bulk String을 내되 **바이너리 안전**해야 한다(임의 바이트 보존).
92
+
93
+ **에러 코드 계약:** 클라이언트는 `error.code`로 분기한다. 서버 에러 응답은 클라이언트가 아는 코드로 매핑되어야 한다:
94
+ - `ERR_REDIS_CONNECTION_CLOSED`, `ERR_REDIS_AUTHENTICATION_FAILED`, `ERR_REDIS_INVALID_RESPONSE`.
95
+ - 일반 명령 에러는 표준 RESP 에러 프리픽스(`ERR`, `WRONGTYPE`, `WRONGPASS` 등)로.
96
+
97
+ ---
98
+
99
+ ## 3. 아키텍처 (메타 설계)
100
+
101
+ ### 3.1 레이어 경계
102
+ 각 레이어는 한 가지 책임만 진다. 위→아래 단방향 의존.
103
+
104
+ ```
105
+ ┌─────────────────────────────────────────────────────────────┐
106
+ │ L1 Transport Bun.listen() TCP 서버 / 소켓 수명주기 │
107
+ │ ▼ (바이트 in/out, 연결당 상태) │
108
+ │ L2 RESP Codec RESP3 파서(스트리밍) + 직렬화기 │
109
+ │ ▼ (바이트 ↔ Command / Reply) │
110
+ │ L3 Connection 연결당 상태기계: handshake→ready→subscribe │
111
+ │ ▼ (HELLO/AUTH/SELECT, 모드 전환) │
112
+ │ L4 Dispatcher 명령 라우팅 테이블 (CMD → Handler) │
113
+ │ ▼ (arity 검증, 미지원 명령 에러) │
114
+ │ L5 Command Engine 명령별 의미론 (KV/Hash/Set/Expire...) │
115
+ │ ▼ │
116
+ │ L6 Storage StorageEngine 인터페이스 (추상) │
117
+ │ ▼ └─ SqliteStorage (bun:sqlite, WAL) │
118
+ │ L7 Side-systems ExpiryReaper / PubSubHub / TxnContext │
119
+ └─────────────────────────────────────────────────────────────┘
120
+ ```
121
+
122
+ ### 3.2 의존성 역전 — Storage는 추상 경계
123
+ `Command Engine`은 SQLite를 모른다. `StorageEngine` 인터페이스에만 의존한다. 이렇게 두면 (1) 테스트 시 인메모리 mock 교체, (2) 후일 다른 저장 엔진 실험이 열린다. **단, 이 추상화에 Redis 고유 개념을 그대로 노출하지 않는다** — KV/필드맵/정렬셋 같은 저장 원형(primitive)만 올린다.
124
+
125
+ ```
126
+ StorageEngine (interface = 저장 SSOT)
127
+ ├─ kvGet/kvSet/kvDel/kvExists
128
+ ├─ fieldGet/fieldSet/fieldDel (hash 계열의 저장 원형)
129
+ ├─ memberAdd/memberRem/memberHas (set 계열의 저장 원형)
130
+ ├─ expireSet/expireGet/sweepExpired (TTL)
131
+ └─ withTransaction(fn) (원자 단위)
132
+
133
+ └─ SqliteStorage (bun:sqlite, WAL 모드)
134
+ ```
135
+
136
+ ### 3.3 왜 이 경계인가
137
+ - **RESP Codec과 Command Engine을 분리**: 프로토콜 버그와 의미론 버그를 독립적으로 잡을 수 있다. RESP 파서는 명령이 뭔지 몰라도 된다.
138
+ - **Connection 상태기계를 독립 레이어로**: Pub/Sub 모드 전환, 핸드셰이크 진행을 한 곳에서 관리. subscribe가 연결을 "점유"하는 Redis 의미론을 여기서 강제.
139
+ - **Dispatcher를 테이블로**: 명령 추가가 곧 호환성 확장이므로, 확장 지점을 한 파일에 모은다(§7 확장 경로의 핵심).
140
+
141
+ ---
142
+
143
+ ## 4. 네트워킹 설계 (Bun 네트워킹 매핑)
144
+
145
+ 요구된 Bun 네트워킹 API를 이 시스템의 어디에 쓰는지/안 쓰는지 명확히 한다. **남용하지 않는 것도 설계다.**
146
+
147
+ | Bun API | 이 시스템에서의 역할 | 채택 |
148
+ |---|---|---|
149
+ | **TCP** (`Bun.listen`/`Bun.connect`) | **주 전송 계층.** RESP는 TCP 위에서 동작. 서버 소켓 = `Bun.listen`. | **핵심** |
150
+ | **WebSockets** | 선택적 게이트웨이. 브라우저/엣지에서 RESP-over-WS가 필요할 때 L1에 어댑터로 추가. 1차 비채택. | 확장 옵션 |
151
+ | **UDP** | RESP는 순서·신뢰성 보장이 필요해 UDP 부적합. **사용 안 함.** (모니터링 메트릭 push 등 부수 용도만 검토) | 비채택 |
152
+ | **DNS** | 클라이언트 접속 호스트 해석에만 관여. 서버는 보통 bind 주소 고정이라 직접 호출 적음. | 부수 |
153
+ | **Fetch** | RESP 경로와 무관. 운영용 HTTP 헬스/메트릭 엔드포인트(`/healthz`, Prometheus)에만 선택 사용. | 부수 |
154
+
155
+ ### 4.1 TCP 서버 핵심 요구사항
156
+ `Bun.listen({ socket: { data, open, close, error } })` 기반. 연결당 다음을 보장:
157
+
158
+ 1. **스트리밍 파싱**: TCP는 메시지 경계가 없다. `data` 콜백은 임의 청크로 쪼개져 들어온다. RESP 파서는 **연결별 누적 버퍼**를 두고, 완전한 명령이 모일 때까지 보류 → 완성될 때마다 디스패치. (RESP의 길이 프리픽스가 이걸 가능케 함.)
159
+ 2. **파이프라이닝 순서 보장**: 한 청크에 여러 명령이 있으면 **도착 순서대로 처리하고 응답도 그 순서로** 한다. 클라이언트의 자동 파이프라이닝이 이걸 전제로 하므로, 순서가 뒤바뀌면 호환이 깨진다. → 연결별 직렬 처리 큐.
160
+ 3. **연결별 상태 격리**: handshake 완료 여부, 선택된 DB, 인증 상태, subscribe 채널 집합을 **소켓별로** 보관(`socket.data`).
161
+ 4. **백프레셔**: `socket.write` 반환값/`drain` 처리. 대량 응답 시 버퍼 폭주 방지.
162
+
163
+ ---
164
+
165
+ ## 5. 저장소 설계 (SQLite 스키마 SSOT)
166
+
167
+ ### 5.1 설계 원칙
168
+ - **타입 통합 vs 분리**: Redis 타입(string/hash/set/zset)을 **키 메타 + 타입별 값 테이블**로 표현. 단일 거대 테이블은 zset 정렬·set 멤버십에서 인덱스가 꼬이므로, **메타는 통합 / 값은 타입별 분리**가 균형점.
169
+ - **TTL은 컬럼 + 스위퍼**: 만료 시각을 epoch ms로 저장. Redis의 lazy+active 만료를 모사(§5.3).
170
+ - **원자성은 SQLite 트랜잭션**: `INCR`, `SETNX`, `MSET`, `MULTI/EXEC`를 트랜잭션 한 단위로.
171
+
172
+ ### 5.2 스키마 개요 (구현 시 확정)
173
+ ```
174
+ keys (key PK, type, expire_at_ms NULL) -- 모든 키의 메타 + TTL
175
+ kv (key PK→keys, value BLOB) -- string (바이너리 안전)
176
+ hash_fields (key, field, value BLOB, PK(key,field)) -- hash
177
+ set_members (key, member, PK(key,member)) -- set
178
+ -- 확장 예약:
179
+ -- list_items (key, seq, value) -- list (lpush/rpop)
180
+ -- zset_members(key, member, score) -- zset, INDEX(key,score)
181
+ ```
182
+ - `value`는 BLOB — `getBuffer()`의 바이너리 안전성과 임의 바이트 보존을 위해 TEXT가 아닌 BLOB.
183
+ - `keys`에서 타입 충돌 시 `WRONGTYPE` 에러(Redis 의미론).
184
+ - WAL 모드 필수 — 단일 프로세스 내 동시 읽기/쓰기 처리량 확보.
185
+
186
+ ### 5.3 만료(TTL) 의미론 — 동등 효과의 핵심
187
+ Redis 체감과 맞추려면 **두 경로 모두** 구현:
188
+ 1. **Lazy(읽기 시점)**: `GET`/`EXISTS` 등에서 `expire_at_ms < now`면 즉시 미존재 취급 + 삭제. 만료 키가 응답에 새어나가지 않게.
189
+ 2. **Active(주기 스위프)**: `ExpiryReaper`가 주기적으로 만료 행 일괄 삭제. 안 하면 `DBSIZE`·디스크가 어긋남.
190
+
191
+ `bun:sqlite`는 동기 API라 단일 프로세스 내 원자성 추론이 단순하다. **단일 writer 가정**을 계약에 명시(여러 서버 프로세스가 같은 .db 파일을 공유하지 않는다).
192
+
193
+ ---
194
+
195
+ ## 6. 연결 상태기계 & 부가 시스템
196
+
197
+ ### 6.1 Connection 상태
198
+ ```
199
+ connect
200
+
201
+
202
+ ┌───────────┐ HELLO/AUTH/SELECT ok ┌────────┐
203
+ │ HANDSHAKE │ ──────────────────────▶ │ READY │
204
+ └───────────┘ └────┬───┘
205
+ │ SUBSCRIBE
206
+
207
+ ┌──────────────┐
208
+ │ SUBSCRIBED │ (명령 제한 모드)
209
+ └──────────────┘
210
+ ```
211
+ - READY에서만 일반 명령 허용. HANDSHAKE 미완 상태에서 데이터 명령이 오면 에러 또는 보류 정책 결정(구현 시).
212
+ - **SUBSCRIBED 모드**: Redis 의미론상 subscribe된 연결은 (P)SUBSCRIBE/UNSUBSCRIBE/PING/QUIT만 허용. 일반 명령은 에러. 클라이언트가 `.duplicate()`로 별도 연결을 쓰는 것을 전제.
213
+
214
+ ### 6.2 Pub/Sub (1차 포함)
215
+ - `PubSubHub`: 채널 → 구독 연결 집합. `PUBLISH`가 해당 채널 구독 연결들에 메시지 푸시.
216
+ - **단일 프로세스 메모리 허브**로 1차 구현(같은 서버에 붙은 연결 간 전달). 다중 프로세스 브로드캐스트는 비목적.
217
+ - 메시지 푸시는 RESP3 push 타입(`>`)으로. 클라이언트 `subscribe(channel, (msg, ch) => {})` 콜백과 정합.
218
+
219
+ ### 6.3 Transaction (1차 포함, 최소)
220
+ - `MULTI` → 연결별 `TxnContext`에 명령 큐잉 시작. `EXEC` → SQLite 트랜잭션으로 일괄 실행 후 결과 배열 반환. `DISCARD` → 큐 폐기.
221
+ - `WATCH`는 낙관적 락 — 1차에서는 **단일 writer 가정** 덕에 단순화 가능하나, 정확한 의미론(키 변경 감지 시 EXEC nil)은 확장 단계에서 강화.
222
+ - 이 명령군은 자동 파이프라이닝 비활성 대상이므로 연결별 순차 처리와 자연히 맞물린다.
223
+
224
+ ---
225
+
226
+ ## 7. 구현 로드맵 (MVP 우선 → 단계적 확장)
227
+
228
+ 호환성은 **명령 디스패치 테이블에 케이스를 더하는 것**으로 선형 확장된다. 단계마다 "순정 `Bun.RedisClient`로 통합 테스트 통과"가 완료 기준(DoD).
229
+
230
+ ### Phase 0 — 핸드셰이크 + 핵심 KV (MVP, 1차 목표)
231
+ - L1 TCP 서버 + L2 RESP3 코덱(파서/직렬화) + 연결별 스트리밍 버퍼·파이프라인 순서.
232
+ - 핸드셰이크: `HELLO 3` / `AUTH` / `SELECT` / `PING` / `INFO`(최소) / `QUIT`.
233
+ - 핵심 KV: `SET` `GET` `DEL` `EXISTS` `INCR` `DECR` `EXPIRE` `TTL` + `getBuffer` 바이너리 경로.
234
+ - SQLite: `keys`+`kv` 테이블, WAL, lazy 만료, `ExpiryReaper` 기본형.
235
+ - **DoD**: 순정 클라이언트로 connect→set→get→incr→expire→ttl→del→exists 전 과정 통과. 타입 변환(§2.4) 검증 통과.
236
+
237
+ ### Phase 1 — Hash / Set / Multi-key
238
+ - `HSET`/`HMSET`/`HGET`/`HMGET`/`HINCRBY`/`HINCRBYFLOAT`, `SADD`/`SREM`/`SISMEMBER`/`SMEMBERS`/`SRANDMEMBER`/`SPOP`, `MGET`/`MSET`/`MSETNX`/`SETEX`/`SETNX`.
239
+ - `hash_fields`/`set_members` 테이블. `SISMEMBER`/`EXISTS` boolean 변환 계약 고정.
240
+
241
+ ### Phase 2 — Pub/Sub + Transaction
242
+ - 연결 상태기계에 SUBSCRIBED 모드 + `PubSubHub` + RESP3 push.
243
+ - `MULTI`/`EXEC`/`DISCARD`/`WATCH`(기본).
244
+
245
+ ### Phase 3 — 확장 명령 (필요 시)
246
+ - list(`LPUSH`/`RPOP`/`LRANGE`), zset(`ZADD`/`ZRANGE`...), `SCAN`/`KEYS`, `INFO` 확장.
247
+ - 각 명령군마다 저장 테이블 + 디스패처 케이스 + 통합 테스트.
248
+
249
+ ### 영구적 비목적
250
+ - Cluster / Sentinel (클라이언트 미지원).
251
+ - 다중 프로세스 공유 .db / HA / failover.
252
+ - 인프로세스 라이브러리 모드.
253
+
254
+ ---
255
+
256
+ ## 8. 테스트 전략 (호환성 검증 SSOT)
257
+
258
+ 호환성은 "주장"이 아니라 "통과한 통합 테스트"로만 증명된다.
259
+
260
+ - **계약 테스트**: 서버를 `Bun.listen`으로 띄우고, **순정 `new RedisClient(thisServerUrl)`** 로 모든 지원 명령을 호출. 클라이언트가 반환하는 **JS 값의 타입과 값**을 단언. (메서드 흉내가 아니라 wire 호환을 검증하는 유일한 방법.)
261
+ - **타입 변환 테스트**: `EXISTS`/`SISMEMBER` → boolean, `GET` 미존재 → `null`, `getBuffer` → Uint8Array, hash → 배열/객체 등 §2.4 표를 그대로 테스트 케이스화.
262
+ - **파이프라인 테스트**: `Promise.all([...여러 명령])`로 자동 파이프라이닝을 유발하고 순서·정확성 단언.
263
+ - **만료 테스트**: `EXPIRE` 후 lazy/active 양 경로에서 미존재 확인.
264
+ - **재시작 영속성 테스트**: set → 서버 재시작 → get 생존 확인.
265
+ - **차분 테스트(선택)**: 동일 명령 시퀀스를 진짜 Redis와 이 서버에 각각 던져 클라이언트 반환값 비교.
266
+
267
+ ---
268
+
269
+ ## 9. 개발 스택 / 규약
270
+
271
+ - 런타임: **Bun** (외부 의존성 0 목표 — `bun:sqlite`, `Bun.listen` 모두 내장).
272
+ - 언어: **TypeScript** (strict). 명령 핸들러 시그니처는 타입으로 고정.
273
+ - 코드 구현은 **Claude Code에서** 진행. 본 문서는 설계·경계·계약만 규정한다.
274
+ - 우선순위 충돌 시 판단 기준: **호환성 계약(§2) > 데이터 정합성 > 성능.**
275
+
276
+ ---
277
+
278
+ ## 10. 핵심 의사결정 요약 (왜 이렇게 했나)
279
+
280
+ | 결정 | 이유 |
281
+ |---|---|
282
+ | 서버(A)만, 인프로세스(B) 비채택 | 접속은 무조건 순정 `Bun.RedisClient`. 클라이언트 메서드 흉내가 아니라 wire 호환이 SSOT. |
283
+ | RESP3 필수 | 클라이언트가 RESP3로 말함. RESP2만 하면 타입 변환이 어긋남. |
284
+ | 전송은 TCP만, UDP 비채택 | RESP는 순서·신뢰성 필요. UDP는 부적합. |
285
+ | Storage 추상 경계 | 명령 엔진이 SQLite에 직접 의존하지 않도록. 테스트·교체 가능성 확보. |
286
+ | 메타 통합 / 값 타입별 분리 | 단일 테이블은 zset·set에서 인덱스가 꼬임. 균형점. |
287
+ | TTL lazy+active 양쪽 | Redis 체감 일치. 한쪽만 하면 DBSIZE·디스크 어긋남. |
288
+ | 단일 writer 가정 명문화 | SQLite 동시성 모델과 원자성 추론을 단순화. 다중 프로세스 공유는 비목적. |
289
+ | 호환성=디스패치 테이블 확장 | 명령 추가가 선형 확장이 되도록 확장 지점을 한 곳에 집중. |
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Munsunty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # bundis
2
+
3
+ A RESP3-compatible server backed by SQLite. The stock `Bun.RedisClient` connects
4
+ to it **unmodified** — point the connection URL at this server and it just works.
5
+ No Redis install, no external dependencies: everything is Bun-native
6
+ (`Bun.listen` for TCP, `bun:sqlite` for storage, `bun:test` for tests).
7
+
8
+ See [`CLAUDE.md`](./CLAUDE.md) for the full design SSOT.
9
+
10
+ ```
11
+ Bun.RedisClient ──RESP3 over TCP──▶ bundis ──▶ SQLite (.db file)
12
+ ```
13
+
14
+ ## Install (use from another project)
15
+
16
+ ```bash
17
+ bun add bundis # from npm
18
+ bun add github:Munsunty/bundis # or straight from GitHub
19
+ ```
20
+
21
+ Ships as TypeScript source — it requires the Bun runtime (which is a given:
22
+ the server itself depends on `bun:sqlite` and `Bun.listen`).
23
+
24
+ ### Main-process mode — `embedServer()`
25
+
26
+ Runs the server inside your process. No IPC, instant startup; shares the event
27
+ loop with your app (`bun:sqlite` is synchronous).
28
+
29
+ ```ts
30
+ import { RedisClient } from "bun";
31
+ import { embedServer } from "bundis";
32
+
33
+ const server = embedServer({ port: 6379, dbPath: "./data.db" });
34
+ const client = new RedisClient(server.url); // stock client, unmodified
35
+
36
+ await client.set("k", "v");
37
+ await client.get("k"); // "v"
38
+
39
+ client.close();
40
+ server.stop();
41
+ ```
42
+
43
+ ### Separate-process mode — `spawnServer()`
44
+
45
+ Spawns the server as its own Bun process and resolves once it is accepting
46
+ connections. Isolates the SQLite writer and any blocking work from your app.
47
+
48
+ ```ts
49
+ import { RedisClient } from "bun";
50
+ import { spawnServer } from "bundis";
51
+
52
+ const server = await spawnServer({ port: 0, dbPath: "./data.db" }); // 0 = ephemeral port
53
+ const client = new RedisClient(server.url);
54
+
55
+ await client.set("k", "v");
56
+
57
+ client.close();
58
+ await server.stop(); // kills the child and waits for exit
59
+ ```
60
+
61
+ Options for both: `host` (default `127.0.0.1`), `port` (default `6379`, `0` =
62
+ ephemeral), `dbPath` (default `./data.db`, `":memory:"` for non-persistent),
63
+ `password`, `reaperIntervalMs`. `spawnServer` additionally takes `bunPath` and
64
+ `readyTimeoutMs`. The returned `url` already embeds the password when set.
65
+
66
+ ### Standalone daemon — CLI
67
+
68
+ ```bash
69
+ bunx bundis --port 6379 --db ./data.db
70
+ # (in this repo: bun run src/cli.ts)
71
+ # flags (or env): --host/REDIS_HOST --port/REDIS_PORT
72
+ # --db/REDIS_DB_PATH (":memory:" for in-memory)
73
+ # --password/REDIS_PASSWORD
74
+ ```
75
+
76
+ stdout prints one JSON ready line (`{"event":"bundis:ready",...}`);
77
+ human logs go to stderr. Then from any app:
78
+
79
+ ```ts
80
+ import { RedisClient } from "bun";
81
+ const client = new RedisClient("redis://127.0.0.1:6379");
82
+ await client.set("k", "v");
83
+ await client.get("k"); // "v"
84
+ ```
85
+
86
+ ## Supported commands
87
+
88
+ - **Handshake:** HELLO, AUTH, PING, SELECT, INFO, QUIT, CLIENT, ECHO, RESET
89
+ - **String / numeric:** SET (EX/PX/EXAT/PXAT/NX/XX/KEEPTTL/GET), GET, GETSET,
90
+ GETDEL, APPEND, STRLEN, DEL/UNLINK, EXISTS, INCR/DECR/INCRBY/DECRBY/INCRBYFLOAT
91
+ - **Multi-key:** MGET, MSET, MSETNX, SETEX, PSETEX, SETNX
92
+ - **Expiry:** EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT, TTL, PTTL, PERSIST
93
+ - **Hash:** HSET, HMSET, HSETNX, HGET, HMGET, HGETALL, HDEL, HEXISTS, HKEYS,
94
+ HVALS, HLEN, HINCRBY, HINCRBYFLOAT
95
+ - **Set:** SADD, SREM, SISMEMBER, SMEMBERS, SCARD, SRANDMEMBER, SPOP
96
+ - **Pub/Sub:** SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB
97
+ - **Transactions:** MULTI, EXEC, DISCARD, WATCH, UNWATCH
98
+
99
+ ## Test
100
+
101
+ ```bash
102
+ bun test # unit (no network) + contract (real TCP, stock client)
103
+ bun run typecheck # tsc --noEmit, strict
104
+ ```
105
+
106
+ Contract tests boot the server on an ephemeral port and drive it with a genuine
107
+ `Bun.RedisClient`, asserting on the JS values it returns — the only honest proof
108
+ of wire compatibility.
109
+
110
+ ## Layout
111
+
112
+ ```
113
+ src/
114
+ index.ts public API (embedServer / spawnServer / startServer)
115
+ cli.ts standalone daemon entry (bunx bundis)
116
+ launch.ts embed / spawn launchers
117
+ server.ts L1 transport (Bun.listen)
118
+ resp/ L2 RESP3 parser + serializer
119
+ connection.ts L3 per-connection state machine
120
+ dispatcher.ts L4 command routing table
121
+ commands/ L5 command semantics
122
+ storage/ L6 StorageEngine + SqliteStorage (WAL)
123
+ sidecar/ L7 ExpiryReaper, PubSubHub, WatchRegistry
124
+ tests/
125
+ unit/ modules in isolation (no network)
126
+ contract/ stock Bun.RedisClient over real TCP
127
+ helpers/ fixtures/
128
+ ```
129
+
130
+ Built and tested against Bun 1.3.14.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "bundis",
3
+ "version": "0.1.0",
4
+ "description": "RESP3-compatible server backed by SQLite — works with the stock Bun.RedisClient",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "module": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "bin": {
12
+ "bundis": "src/cli.ts"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md",
17
+ "CLAUDE.md"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Munsunty/bundis.git"
22
+ },
23
+ "keywords": [
24
+ "redis",
25
+ "resp",
26
+ "resp3",
27
+ "sqlite",
28
+ "bun",
29
+ "redis-server",
30
+ "embedded"
31
+ ],
32
+ "engines": {
33
+ "bun": ">=1.1.0"
34
+ },
35
+ "scripts": {
36
+ "start": "bun run src/cli.ts",
37
+ "dev": "bun --watch run src/cli.ts",
38
+ "test": "bun test",
39
+ "typecheck": "bun x tsc --noEmit"
40
+ },
41
+ "devDependencies": {
42
+ "@types/bun": "latest"
43
+ },
44
+ "peerDependencies": {
45
+ "typescript": "^5"
46
+ }
47
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CLI entry: load config from flags/env, start the server, signal readiness.
4
+ *
5
+ * Run: `bun run src/cli.ts [--host H] [--port P] [--db PATH] [--password PW]`
6
+ * (or `bunx bundis ...` when installed as a dependency).
7
+ *
8
+ * stdout carries exactly one machine-readable JSON ready line — this is the
9
+ * signal `spawnServer()` waits for. Human-facing logs go to stderr.
10
+ */
11
+
12
+ import { loadConfig } from "./config";
13
+ import { startServer } from "./server";
14
+ import { READY_EVENT } from "./launch";
15
+
16
+ const config = loadConfig();
17
+ const running = startServer(config);
18
+
19
+ console.log(
20
+ JSON.stringify({
21
+ event: READY_EVENT,
22
+ host: running.hostname,
23
+ port: running.port,
24
+ db: config.dbPath,
25
+ }),
26
+ );
27
+ console.error(
28
+ `bundis listening on ${running.hostname}:${running.port} ` +
29
+ `(db: ${config.dbPath}${config.password ? ", auth: on" : ""})`,
30
+ );
31
+
32
+ function shutdown(): void {
33
+ running.stop();
34
+ process.exit(0);
35
+ }
36
+
37
+ process.on("SIGINT", shutdown);
38
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Expiry commands (Phase 0): EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT, TTL, PTTL,
3
+ * PERSIST. TTL/PTTL return -2 (missing), -1 (no expiry), or remaining time.
4
+ */
5
+
6
+ import { R, type Reply } from "../resp/types";
7
+ import type { CommandContext } from "../engine/context";
8
+
9
+ export function expire(ctx: CommandContext): Reply {
10
+ ctx.requireArgc(2);
11
+ const atMs = ctx.nowMs + Number(ctx.int(1)) * 1000;
12
+ return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
13
+ }
14
+
15
+ export function pexpire(ctx: CommandContext): Reply {
16
+ ctx.requireArgc(2);
17
+ const atMs = ctx.nowMs + Number(ctx.int(1));
18
+ return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
19
+ }
20
+
21
+ export function expireat(ctx: CommandContext): Reply {
22
+ ctx.requireArgc(2);
23
+ const atMs = Number(ctx.int(1)) * 1000;
24
+ return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
25
+ }
26
+
27
+ export function pexpireat(ctx: CommandContext): Reply {
28
+ ctx.requireArgc(2);
29
+ const atMs = Number(ctx.int(1));
30
+ return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
31
+ }
32
+
33
+ export function ttl(ctx: CommandContext): Reply {
34
+ ctx.requireExactArgc(1);
35
+ const ms = ctx.storage.pttl(ctx.arg(0), ctx.nowMs);
36
+ if (ms < 0) return R.int(ms);
37
+ return R.int(Math.ceil(ms / 1000));
38
+ }
39
+
40
+ export function pttl(ctx: CommandContext): Reply {
41
+ ctx.requireExactArgc(1);
42
+ return R.int(ctx.storage.pttl(ctx.arg(0), ctx.nowMs));
43
+ }
44
+
45
+ export function persist(ctx: CommandContext): Reply {
46
+ ctx.requireExactArgc(1);
47
+ return R.int(ctx.storage.persist(ctx.arg(0), ctx.nowMs) ? 1 : 0);
48
+ }