@arbitro/client 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zenozaga and Arbitro contributors
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
13
+ all 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
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # @arbitro/client
2
+
3
+ Official TypeScript client for the [Arbitro](https://github.com/arbitro-io/arbitro) stateful flow broker.
4
+
5
+ > Status: under active development. APIs, benchmarks, defaults, and reconnect behavior may still change.
6
+
7
+ `@arbitro/client` is built for the features that make Arbitro different from a plain pub/sub broker:
8
+
9
+ - durable streams and consumers
10
+ - exact `stream` / `consumer` introspection and idempotent `upsert`
11
+ - automatic reconnect + subscription reattach
12
+ - subject-level `maxSubjectInflights` — the strongest flow-control feature in the system
13
+ - live `ack_pending` queries per consumer
14
+ - client + broker metrics for observability
15
+
16
+ ## Why Arbitro
17
+
18
+ The headline feature is **`maxSubjectInflights`** — per-subject in-flight caps with wildcard patterns inside a single consumer group, so one hot subject does not starve the rest of the workload.
19
+
20
+ That means you can run one worker pool and still say:
21
+
22
+ - `payments.critical` → max `1`
23
+ - `payments.heavy.>` → max `3`
24
+ - `payments.light.>` → max `10`
25
+
26
+ without splitting your topology into many queues just to protect fairness.
27
+
28
+ ## Requirements
29
+
30
+ - Node.js `>= 20`
31
+ - Arbitro broker reachable on `127.0.0.1:9898` or your own `--addr`
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm install @arbitro/client
37
+ ```
38
+
39
+ ## Run the broker locally (Docker)
40
+
41
+ The broker ships as a public Docker image (musl-static, ~3 MB, scratch base):
42
+
43
+ ```bash
44
+ docker run --rm -p 9898:9898 ghcr.io/arbitro-io/arbitro-server:0.1.0
45
+ ```
46
+
47
+ Pin a major/minor tag for production:
48
+
49
+ - `ghcr.io/arbitro-io/arbitro-server:0.1.0` — immutable, recommended for prod
50
+ - `ghcr.io/arbitro-io/arbitro-server:0.1` — auto-updates within `0.1.*`
51
+ - `ghcr.io/arbitro-io/arbitro-server:latest` — last tagged release
52
+
53
+ ## Quick start
54
+
55
+ ```typescript
56
+ import { ArbitroClient } from '@arbitro/client'
57
+
58
+ const client = new ArbitroClient({ servers: ['127.0.0.1:9898'] })
59
+ await client.connect()
60
+
61
+ await client.createStream('orders', {
62
+ subjectFilter: 'orders.>',
63
+ })
64
+
65
+ await client.createConsumer('orders', {
66
+ name: 'workers',
67
+ filter: 'orders.>',
68
+ })
69
+
70
+ const sub = await client.subscribe('workers', (msg) => {
71
+ console.log(msg.data().toString())
72
+ msg.ack()
73
+ })
74
+
75
+ // publish() returns Promise<void> — caller decides whether to await.
76
+ await client.publish('orders', 'orders.new', Buffer.from('hello'))
77
+ ```
78
+
79
+ ## Publish
80
+
81
+ `publish()` returns `Promise<void>` that resolves once the broker confirms receipt (`RepOk`). The TypeScript idiom is "everything async, the caller chooses to await" — the same call site supports both semantics:
82
+
83
+ ```typescript
84
+ await client.publish('orders', 'orders.new', data) // wait for broker ack
85
+ client.publish('orders', 'orders.new', data) // fire-and-forget
86
+ client.publish('orders', 'orders.new', data).catch(onError) // async error path
87
+ ```
88
+
89
+ The broker emits `RepOk` regardless of whether the caller awaits, so there's no wire-level savings from a "no-reply" variant. That's the property that lets TS expose a single, ergonomic API. Rust's lazy-future model has to pick `publish` (fire-and-forget) vs `publish_sync` (awaited) at the call site.
90
+
91
+ ## Durable management
92
+
93
+ ```typescript
94
+ await client.streamExists('orders') // true
95
+ await client.getStreamInfo('orders') // StreamInfo | null
96
+ await client.listStreams() // StreamInfo[]
97
+
98
+ await client.consumerExists('workers') // true
99
+ await client.getConsumerInfo('workers') // ConsumerInfo | null
100
+ await client.listConsumers() // ConsumerInfo[]
101
+ ```
102
+
103
+ ## Upsert / delete
104
+
105
+ `upsert*` is strict: it succeeds when the entity does not exist or already exists with an equivalent config. It does not silently mutate a conflicting durable entity.
106
+
107
+ ```typescript
108
+ await client.upsertStream('orders', { subjectFilter: 'orders.>' })
109
+ await client.upsertConsumer('orders', { name: 'workers', filter: 'orders.>' })
110
+
111
+ await client.deleteConsumer('workers')
112
+ await client.deleteStream('orders') // default: delete metadata + data
113
+ await client.deleteStream('orders', { deleteData: false }) // preserve journal bytes
114
+ ```
115
+
116
+ ## Stream / consumer sugar
117
+
118
+ ```typescript
119
+ const stream = client.stream('orders')
120
+ const consumer = stream.consumer({ name: 'workers', filter: 'orders.>' })
121
+
122
+ await consumer.create()
123
+
124
+ const sub = await consumer.subscribe((msg) => {
125
+ msg.ack()
126
+ })
127
+ ```
128
+
129
+ ## Per-subject inflight limits
130
+
131
+ `maxSubjectInflights` caps the in-flight (delivered, unacked) count per subject pattern, with full wildcard support (`*`, `>`). Only enforced when `ackPolicy: Explicit`; silently dropped for fire-and-forget consumers (the engine doesn't track inflight without acks).
132
+
133
+ ```typescript
134
+ import { AckPolicy, DeliverPolicy } from '@arbitro/client'
135
+
136
+ await client.createConsumer('orders', {
137
+ name: 'workers',
138
+ filter: 'orders.>',
139
+ ackPolicy: AckPolicy.Explicit,
140
+ deliverPolicy: DeliverPolicy.All,
141
+ maxAckPending: 20_000,
142
+ maxSubjectInflights: [
143
+ { pattern: 'orders.critical', limit: 1 },
144
+ { pattern: 'orders.heavy.>', limit: 3 },
145
+ { pattern: 'orders.light.>', limit: 10 },
146
+ ],
147
+ })
148
+ ```
149
+
150
+ ## Query pending acks
151
+
152
+ Live count of messages delivered to a consumer but not yet acked (equivalent of NATS JetStream `num_ack_pending`). One broker round-trip; engine cost is O(1) per shard.
153
+
154
+ ```typescript
155
+ // Via Consumer wrapper
156
+ const consumer = await client.stream('orders')
157
+ .consumer({ name: 'workers' })
158
+ .create()
159
+ await consumer.getPendings() // number
160
+
161
+ // Or directly via client (when you only have the id, or by name)
162
+ await client.getPending(consumerId) // number
163
+ await client.getPending('orders', 'workers') // number (resolves id by name)
164
+ ```
165
+
166
+ ## Client metrics
167
+
168
+ The client tracks atomic counters readable via `client.metrics()`. Use it as a saturation gauge for dashboards or alerts.
169
+
170
+ ```typescript
171
+ const snap = client.metrics()
172
+ // {
173
+ // publishesSent: 12048,
174
+ // publishBatchEntries: 3210,
175
+ // deliveriesReceived: 15258,
176
+ // activeSubscriptions: 7, // gauge
177
+ // acksSent: 15101,
178
+ // nacksSent: 12,
179
+ // reconnects: 0,
180
+ // pendingReplies: 0,
181
+ // }
182
+ ```
183
+
184
+ ## Typed lazy decode
185
+
186
+ ```typescript
187
+ import { schema } from '@arbitro/client'
188
+
189
+ const OrderCodec = schema({ id: 'number', status: 'string' })
190
+
191
+ const sub = await client
192
+ .stream('orders')
193
+ .consumer({ name: 'workers', filter: 'orders.>' })
194
+ .subscribe(OrderCodec, (msg) => {
195
+ console.log(msg.id, msg.status)
196
+ msg.ack()
197
+ })
198
+ ```
199
+
200
+ ### Zod codec (optional)
201
+
202
+ If you already model your payloads with [zod](https://zod.dev), use `zodCodec` for free runtime validation on decode:
203
+
204
+ ```typescript
205
+ import { ArbitroClient, zodCodec } from '@arbitro/client'
206
+ import { z } from 'zod'
207
+
208
+ const Order = z.object({ id: z.number(), status: z.string() })
209
+ const codec = zodCodec(Order)
210
+
211
+ const sub = await client
212
+ .stream('orders')
213
+ .consumer({ name: 'workers', filter: 'orders.>' })
214
+ .subscribe(codec, (msg) => {
215
+ // msg is typed as `z.output<typeof Order>` — validated on decode
216
+ msg.ack()
217
+ })
218
+ ```
219
+
220
+ `zod` is an optional peer dependency. `@arbitro/client` references zod only via `import type`, so users who never call `zodCodec` pay zero runtime cost and don't need zod installed.
221
+
222
+ ## Reconnect behavior
223
+
224
+ The TS client reconnects transport automatically and reattaches active subscriptions after reconnect. That behavior lives in the client, not in the benchmarks. This matters for:
225
+
226
+ - Docker restarts
227
+ - broker failover tests
228
+ - chaos scenarios with durable consumers
229
+
230
+ ## Benchmarks
231
+
232
+ Three primary bench families:
233
+
234
+ | File | npm script | What it measures |
235
+ |---|---|---|
236
+ | `benches/throughput.ts` | `npm run bench` | Publish + delivery throughput across modes (fire-and-forget, batch, sync, replay-ack, replay-noack). |
237
+ | `benches/limits.ts` | `npm run bench:limit` | Behaviour under `maxSubjectInflights` saturation. |
238
+ | `benches/chaos.ts` | `npm run bench:chaos` | Restart / reconnect / persistence under failure injection. |
239
+
240
+ Examples:
241
+
242
+ ```bash
243
+ npx tsx benches/throughput.ts --mode fire-and-forget --msgs 20000
244
+ npx tsx benches/throughput.ts --mode perf --seconds 10 --rate 20000 --container arbitro-server
245
+ npx tsx benches/limits.ts
246
+ npx tsx benches/chaos.ts --duration 8 --rate 50 --container arbitro-server
247
+ ```
248
+
249
+ ## Validation
250
+
251
+ ```bash
252
+ npm run typecheck
253
+ npm test
254
+ npm run test:integration # requires Docker
255
+ ```
256
+
257
+ ## Documentation
258
+
259
+ - `CONTRIBUTING.md` — dev setup, branch + commit conventions, PR review.
260
+ - `RELEASING.md` — SemVer policy and the npm publish flow.
261
+ - `.agent/rules/*.md` — internal coding rules (hot-path discipline, wire protocol, etc.).
262
+ - `CLAUDE.md` — index pointing at the rule files.
263
+
264
+ ## License
265
+
266
+ MIT — see [LICENSE](./LICENSE).