@valentinkolb/sync 2.0.2 → 2.0.4
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 +21 -0
- package/README.md +313 -0
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Valentin Kolb
|
|
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,313 @@
|
|
|
1
|
+
# @valentinkolb/sync
|
|
2
|
+
|
|
3
|
+
Distributed synchronization primitives for Bun and TypeScript, backed by Redis.
|
|
4
|
+
|
|
5
|
+
## Philosophy
|
|
6
|
+
|
|
7
|
+
- **Bun-native** - Built for [Bun](https://bun.sh). Uses `Bun.redis`, `Bun.sleep`, `RedisClient` directly. No Node.js compatibility layers.
|
|
8
|
+
- **Minimal dependencies** - Only `zod` as a peer dependency. Everything else is Bun built-ins and Redis Lua scripts.
|
|
9
|
+
- **Composable building blocks** - Five focused primitives that work independently or together. `job` composes `queue` + `topic` internally.
|
|
10
|
+
- **Consistent API** - Every module follows the same pattern: `moduleName({ id, ...config })` returns an instance. No classes, no `.create()`, no `new`.
|
|
11
|
+
- **Atomic by default** - All Redis operations use Lua scripts for atomicity. No multi-step race conditions at the Redis level.
|
|
12
|
+
- **Schema-validated** - Queue, topic, and job payloads are validated with Zod at the boundary. Invalid data never enters Redis.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Rate limiting** - Sliding window algorithm with atomic Lua scripts
|
|
17
|
+
- **Distributed mutex** - SET NX-based locking with retry, extend, and auto-expiry
|
|
18
|
+
- **Queue** - Durable work queue with leases, DLQ, delayed messages, and idempotency
|
|
19
|
+
- **Topic** - Pub/sub with consumer groups, at-least-once delivery, and live streaming
|
|
20
|
+
- **Job** - Durable job processing built on queue + topic with retries, cancellation, and event sourcing
|
|
21
|
+
|
|
22
|
+
For a complete API reference (types, config options, Redis key patterns, internals), see [`llms.txt`](./llms.txt).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bun add @valentinkolb/sync zod
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires [Bun](https://bun.sh) and a Redis-compatible server (Redis 6.2+, Valkey, Dragonfly).
|
|
31
|
+
|
|
32
|
+
## Rate Limit
|
|
33
|
+
|
|
34
|
+
Sliding window rate limiter. Atomic via Lua script.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { ratelimit, RateLimitError } from "@valentinkolb/sync";
|
|
38
|
+
|
|
39
|
+
const limiter = ratelimit({
|
|
40
|
+
id: "api",
|
|
41
|
+
limit: 100,
|
|
42
|
+
windowSecs: 60,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const result = await limiter.check("user:123");
|
|
46
|
+
// { limited: false, remaining: 99, resetIn: 58432 }
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await limiter.checkOrThrow("user:123");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof RateLimitError) {
|
|
52
|
+
console.log(`Retry in ${error.resetIn}ms`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Mutex
|
|
58
|
+
|
|
59
|
+
Distributed lock with retry + jitter, TTL auto-expiry, and Lua-based owner-only release.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { mutex, LockError } from "@valentinkolb/sync";
|
|
63
|
+
|
|
64
|
+
const m = mutex({ id: "checkout", defaultTtl: 5000 });
|
|
65
|
+
|
|
66
|
+
// Automatic acquire + release
|
|
67
|
+
const result = await m.withLock("order:123", async (lock) => {
|
|
68
|
+
await m.extend(lock, 10_000); // extend if needed
|
|
69
|
+
return await processOrder();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Throws LockError if lock cannot be acquired
|
|
73
|
+
await m.withLockOrThrow("order:123", async () => {
|
|
74
|
+
await doExclusiveWork();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Manual acquire/release
|
|
78
|
+
const lock = await m.acquire("order:123");
|
|
79
|
+
if (lock) {
|
|
80
|
+
try { /* work */ } finally { await m.release(lock); }
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Queue
|
|
85
|
+
|
|
86
|
+
Durable work queue with at-least-once delivery, lease-based visibility, delayed messages, idempotency, and dead-letter queue.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { z } from "zod";
|
|
90
|
+
import { queue } from "@valentinkolb/sync";
|
|
91
|
+
|
|
92
|
+
const q = queue({
|
|
93
|
+
id: "mail.send",
|
|
94
|
+
schema: z.object({ to: z.string().email(), subject: z.string() }),
|
|
95
|
+
delivery: { defaultLeaseMs: 60_000, maxDeliveries: 5 },
|
|
96
|
+
limits: { maxMessageAgeMs: 7 * 24 * 60 * 60 * 1000 },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Send
|
|
100
|
+
await q.send({
|
|
101
|
+
data: { to: "user@example.com", subject: "Welcome" },
|
|
102
|
+
idempotencyKey: "welcome:user@example.com",
|
|
103
|
+
delayMs: 5_000, // optional: deliver after 5s
|
|
104
|
+
meta: { traceId: "abc-123" }, // optional metadata
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Receive + process
|
|
108
|
+
const msg = await q.recv({ wait: true, timeoutMs: 30_000 });
|
|
109
|
+
if (msg) {
|
|
110
|
+
try {
|
|
111
|
+
await sendMail(msg.data);
|
|
112
|
+
await msg.ack();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
await msg.nack({ delayMs: 5_000, error: String(error) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stream processing
|
|
119
|
+
for await (const m of q.stream()) {
|
|
120
|
+
await handle(m.data);
|
|
121
|
+
await m.ack();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Multiple readers (independent blocking clients)
|
|
125
|
+
const reader = q.reader();
|
|
126
|
+
const msg2 = await reader.recv({ signal: abortController.signal });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Queue features
|
|
130
|
+
|
|
131
|
+
- **Lease-based delivery**: Messages are invisible to other consumers while leased. Call `msg.touch()` to extend.
|
|
132
|
+
- **Dead-letter queue**: After `maxDeliveries` failed attempts, messages move to DLQ.
|
|
133
|
+
- **Delayed messages**: `send({ delayMs })` or `nack({ delayMs })` for retry delays.
|
|
134
|
+
- **Idempotency**: `send({ idempotencyKey })` deduplicates within a configurable TTL.
|
|
135
|
+
- **Multi-tenant**: Pass `tenantId` to `send()` and `recv()` for isolated queues.
|
|
136
|
+
- **AbortSignal**: Pass `signal` to `recv()` for graceful shutdown.
|
|
137
|
+
|
|
138
|
+
## Topic
|
|
139
|
+
|
|
140
|
+
Pub/sub with Redis Streams. Supports consumer groups (at-least-once, load-balanced) and live streaming (best-effort, all events).
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { z } from "zod";
|
|
144
|
+
import { topic } from "@valentinkolb/sync";
|
|
145
|
+
|
|
146
|
+
const t = topic({
|
|
147
|
+
id: "order.events",
|
|
148
|
+
schema: z.object({ type: z.string(), orderId: z.string() }),
|
|
149
|
+
retentionMs: 7 * 24 * 60 * 60 * 1000,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Publish
|
|
153
|
+
await t.pub({
|
|
154
|
+
data: { type: "order.confirmed", orderId: "o1" },
|
|
155
|
+
idempotencyKey: "confirm:o1",
|
|
156
|
+
meta: { source: "checkout" },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Consumer group reader (at-least-once, load-balanced across consumers)
|
|
160
|
+
const reader = t.reader("mailer");
|
|
161
|
+
for await (const event of reader.stream()) {
|
|
162
|
+
await sendConfirmationEmail(event.data);
|
|
163
|
+
await event.commit();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Multiple groups receive the same events independently
|
|
167
|
+
const analytics = t.reader("analytics");
|
|
168
|
+
const billing = t.reader("billing");
|
|
169
|
+
|
|
170
|
+
// Live stream (best-effort, no consumer group, no commit needed)
|
|
171
|
+
for await (const event of t.live({ signal: ac.signal })) {
|
|
172
|
+
console.log(event.data);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Replay from a specific cursor
|
|
176
|
+
for await (const event of t.live({ after: "0-0" })) {
|
|
177
|
+
// receives all stored events from the beginning
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Topic features
|
|
182
|
+
|
|
183
|
+
- **Consumer groups**: Each group tracks its own position. Multiple consumers in the same group load-balance.
|
|
184
|
+
- **Live streaming**: `t.live()` uses XREAD for real-time, best-effort delivery to all listeners.
|
|
185
|
+
- **Replay**: Pass `after: "0-0"` to `live()` to replay all stored events.
|
|
186
|
+
- **Retention**: Automatic XTRIM based on `retentionMs`.
|
|
187
|
+
- **Multi-tenant**: Pass `tenantId` to `pub()` and `recv()` for isolated streams.
|
|
188
|
+
- **AbortSignal**: Pass `signal` to `recv()`, `stream()`, and `live()`.
|
|
189
|
+
|
|
190
|
+
## Job
|
|
191
|
+
|
|
192
|
+
Durable job processing built on queue + topic. Supports retries with backoff, cancellation, event sourcing, and graceful shutdown.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { z } from "zod";
|
|
196
|
+
import { job } from "@valentinkolb/sync";
|
|
197
|
+
|
|
198
|
+
const sendOrderMail = job({
|
|
199
|
+
id: "mail.send-order",
|
|
200
|
+
schema: z.object({ orderId: z.string(), to: z.string().email() }),
|
|
201
|
+
defaults: { maxAttempts: 3, backoff: { kind: "exp", baseMs: 1000 } },
|
|
202
|
+
process: async ({ ctx, input }) => {
|
|
203
|
+
// ctx.signal is aborted on timeout or error
|
|
204
|
+
if (ctx.signal.aborted) return;
|
|
205
|
+
|
|
206
|
+
await ctx.heartbeat(); // extend lease
|
|
207
|
+
await ctx.step({ id: "send", run: () => mailProvider.send(input) });
|
|
208
|
+
return { ok: true };
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Submit
|
|
213
|
+
const id = await sendOrderMail.submit({
|
|
214
|
+
input: { orderId: "o1", to: "user@example.com" },
|
|
215
|
+
key: "mail:o1", // idempotency key
|
|
216
|
+
delayMs: 5_000, // schedule for later
|
|
217
|
+
maxAttempts: 3,
|
|
218
|
+
backoff: { kind: "exp", baseMs: 1000, maxMs: 30_000 },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Wait for completion
|
|
222
|
+
const terminal = await sendOrderMail.join({ id, timeoutMs: 60_000 });
|
|
223
|
+
// terminal.status: "completed" | "failed" | "cancelled" | "timed_out"
|
|
224
|
+
|
|
225
|
+
// Cancel
|
|
226
|
+
await sendOrderMail.cancel({ id, reason: "user-request" });
|
|
227
|
+
|
|
228
|
+
// Event stream
|
|
229
|
+
const events = sendOrderMail.events(id);
|
|
230
|
+
for await (const e of events.reader("orchestrator").stream({ wait: false })) {
|
|
231
|
+
console.log(e.data.type); // "submitted" | "started" | "heartbeat" | "retry" | "completed" | "failed" | "cancelled"
|
|
232
|
+
await e.commit();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Live events
|
|
236
|
+
for await (const e of events.live({ signal: ac.signal })) {
|
|
237
|
+
console.log(e.data.type);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Graceful shutdown
|
|
241
|
+
sendOrderMail.stop();
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Job features
|
|
245
|
+
|
|
246
|
+
- **Automatic retries**: Fixed or exponential backoff with configurable max attempts.
|
|
247
|
+
- **Lease timeout**: Jobs that exceed `leaseMs` are automatically timed out.
|
|
248
|
+
- **Cancellation**: Cancel in-flight or queued jobs. Workers detect cancellation between steps.
|
|
249
|
+
- **Event sourcing**: Every state transition emits a typed event to a per-job topic.
|
|
250
|
+
- **Idempotent submit**: Pass `key` to deduplicate submissions atomically.
|
|
251
|
+
- **AbortSignal**: `ctx.signal` is aborted on timeout, error, or cancellation.
|
|
252
|
+
- **Graceful shutdown**: `stop()` signals the worker loop to exit.
|
|
253
|
+
- **Per-job state TTL**: Each job's state has its own Redis TTL (7 days default).
|
|
254
|
+
|
|
255
|
+
## Testing
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
bun test --preload ./tests/preload.ts
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Requires a Redis-compatible server on `localhost:6399` (configured in `tests/preload.ts`).
|
|
262
|
+
|
|
263
|
+
## Contributing
|
|
264
|
+
|
|
265
|
+
### Setup
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
git clone https://github.com/valentinkolb/sync.git
|
|
269
|
+
cd sync
|
|
270
|
+
bun install
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Running tests
|
|
274
|
+
|
|
275
|
+
You need a Redis-compatible server on port 6399. The easiest way is Docker/Podman:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
docker run -d --name valkey -p 6399:6379 valkey/valkey:latest
|
|
279
|
+
bun test --preload ./tests/preload.ts
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Project structure
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
src/
|
|
286
|
+
ratelimit.ts # Sliding window rate limiter
|
|
287
|
+
mutex.ts # Distributed lock
|
|
288
|
+
queue.ts # Durable work queue
|
|
289
|
+
topic.ts # Pub/sub with consumer groups
|
|
290
|
+
job.ts # Job processing (composes queue + topic)
|
|
291
|
+
internal/
|
|
292
|
+
job-utils.ts # Job helper functions (retry, timeout, parsing)
|
|
293
|
+
topic-utils.ts # Stream entry parsing helpers
|
|
294
|
+
tests/
|
|
295
|
+
*.test.ts # Integration tests (require Redis)
|
|
296
|
+
*-utils.unit.test.ts # Pure unit tests
|
|
297
|
+
preload.ts # Sets REDIS_URL for test environment
|
|
298
|
+
index.ts # Public API exports
|
|
299
|
+
llms.txt # Complete API reference for LLMs
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Guidelines
|
|
303
|
+
|
|
304
|
+
- Keep it minimal. No abstractions for one-time operations.
|
|
305
|
+
- Every Redis mutation must be in a Lua script for atomicity.
|
|
306
|
+
- Validate at boundaries (user input), trust internal data.
|
|
307
|
+
- All modules follow the `moduleName({ id, ...config })` factory pattern.
|
|
308
|
+
- Tests go in `tests/`. Use `test:q`, `test:t`, etc. as prefix in tests to avoid collisions. Each test file has a `beforeEach` that cleans up its own keys.
|
|
309
|
+
- Run `bun test --preload ./tests/preload.ts` before submitting a PR. All tests must pass.
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
MIT
|