@valentinkolb/sync 2.0.5 → 2.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/README.md +158 -4
- package/index.d.ts +3 -0
- package/index.js +2034 -167
- package/package.json +1 -1
- package/src/ephemeral.d.ts +101 -0
- package/src/internal/cron.d.ts +3 -0
- package/src/job.d.ts +2 -0
- package/src/retry.d.ts +19 -0
- package/src/scheduler.d.ts +141 -0
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@ Distributed synchronization primitives for Bun and TypeScript, backed by Redis.
|
|
|
6
6
|
|
|
7
7
|
- **Bun-native** - Built for [Bun](https://bun.sh). Uses `Bun.redis`, `Bun.sleep`, `RedisClient` directly. No Node.js compatibility layers.
|
|
8
8
|
- **Minimal dependencies** - Only `zod` as a peer dependency. Everything else is Bun built-ins and Redis Lua scripts.
|
|
9
|
-
- **Composable building blocks** -
|
|
9
|
+
- **Composable building blocks** - Seven focused primitives that work independently or together. `job` composes `queue` + `topic` internally, `scheduler` composes durable dispatch on top of `job`.
|
|
10
10
|
- **Consistent API** - Every module follows the same pattern: `moduleName({ id, ...config })` returns an instance. No classes, no `.create()`, no `new`.
|
|
11
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
|
|
12
|
+
- **Schema-validated** - Queue, topic, job, and ephemeral payloads are validated with Zod at the boundary. Invalid data never enters Redis.
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
@@ -18,8 +18,11 @@ Distributed synchronization primitives for Bun and TypeScript, backed by Redis.
|
|
|
18
18
|
- **Queue** - Durable work queue with leases, DLQ, delayed messages, and idempotency
|
|
19
19
|
- **Topic** - Pub/sub with consumer groups, at-least-once delivery, and live streaming
|
|
20
20
|
- **Job** - Durable job processing built on queue + topic with retries, cancellation, and event sourcing
|
|
21
|
+
- **Scheduler** - Distributed cron scheduler with idempotent registration, leader fencing, and durable dispatch via job submission
|
|
22
|
+
- **Ephemeral** - TTL-based ephemeral key/value store with typed snapshots and event stream for upsert/touch/delete/expire
|
|
23
|
+
- **Retry utility** - Small transport-aware retry helper with sensible defaults and per-call override
|
|
21
24
|
|
|
22
|
-
For
|
|
25
|
+
For complete API details, use the modular skills in [`skills/`](./skills), especially each feature's `references/api.md`.
|
|
23
26
|
|
|
24
27
|
## Installation
|
|
25
28
|
|
|
@@ -27,8 +30,67 @@ For a complete API reference (types, config options, Redis key patterns, interna
|
|
|
27
30
|
bun add @valentinkolb/sync zod
|
|
28
31
|
```
|
|
29
32
|
|
|
33
|
+
## Retry Utility
|
|
34
|
+
|
|
35
|
+
KISS transport retry helper for Redis/network hiccups.
|
|
36
|
+
Defaults are exported as `DEFAULT_RETRY_OPTIONS`; override only when needed.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import {
|
|
40
|
+
retry,
|
|
41
|
+
DEFAULT_RETRY_OPTIONS,
|
|
42
|
+
isRetryableTransportError,
|
|
43
|
+
} from "@valentinkolb/sync";
|
|
44
|
+
|
|
45
|
+
// default usage (recommended)
|
|
46
|
+
const value = await retry(() => fragileCall());
|
|
47
|
+
|
|
48
|
+
console.log(DEFAULT_RETRY_OPTIONS);
|
|
49
|
+
// {
|
|
50
|
+
// attempts: 8,
|
|
51
|
+
// minDelayMs: 100,
|
|
52
|
+
// maxDelayMs: 2000,
|
|
53
|
+
// factor: 2,
|
|
54
|
+
// jitter: 0.2,
|
|
55
|
+
// retryIf: isRetryableTransportError
|
|
56
|
+
// }
|
|
57
|
+
|
|
58
|
+
// per-call override (edge cases only)
|
|
59
|
+
const value2 = await retry(
|
|
60
|
+
() => fragileCall(),
|
|
61
|
+
{
|
|
62
|
+
attempts: 12,
|
|
63
|
+
maxDelayMs: 5_000,
|
|
64
|
+
retryIf: isRetryableTransportError,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Internal default behavior:
|
|
70
|
+
- Queue/topic/ephemeral `stream({ wait: true })` and topic `live()` use transport retries to stay alive across brief outages.
|
|
71
|
+
- One-shot calls (`recv()`, `stream({ wait: false })`, `send()/pub()/submit()`) keep explicit failure semantics unless you wrap them with `retry(...)`.
|
|
72
|
+
|
|
30
73
|
Requires [Bun](https://bun.sh) and a Redis-compatible server (Redis 6.2+, Valkey, Dragonfly).
|
|
31
74
|
|
|
75
|
+
### Install Agent Skills (optional)
|
|
76
|
+
|
|
77
|
+
This repository ships reusable agent skills in [`skills/`](./skills).
|
|
78
|
+
Using the [Vercel Skills CLI](https://github.com/vercel-labs/skills), install them with:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# list available skills from this repo
|
|
82
|
+
bunx skills add https://github.com/valentinkolb/sync --list
|
|
83
|
+
|
|
84
|
+
# install all skills (project-local)
|
|
85
|
+
bunx skills add https://github.com/valentinkolb/sync --skill '*'
|
|
86
|
+
|
|
87
|
+
# install selected skills (example)
|
|
88
|
+
bunx skills add https://github.com/valentinkolb/sync \
|
|
89
|
+
--skill sync-scheduler \
|
|
90
|
+
--skill sync-job \
|
|
91
|
+
--skill sync-retry
|
|
92
|
+
```
|
|
93
|
+
|
|
32
94
|
## Rate Limit
|
|
33
95
|
|
|
34
96
|
Sliding window rate limiter. Atomic via Lua script.
|
|
@@ -134,6 +196,8 @@ const msg2 = await reader.recv({ signal: abortController.signal });
|
|
|
134
196
|
- **Idempotency**: `send({ idempotencyKey })` deduplicates within a configurable TTL.
|
|
135
197
|
- **Multi-tenant**: Pass `tenantId` to `send()` and `recv()` for isolated queues.
|
|
136
198
|
- **AbortSignal**: Pass `signal` to `recv()` for graceful shutdown.
|
|
199
|
+
- **Transport resilience in stream loops**: `stream({ wait: true })` auto-retries transient transport errors and keeps consuming after short Redis outages.
|
|
200
|
+
- **One-shot semantics stay explicit**: `recv()` and `stream({ wait: false })` keep direct error semantics (no hidden infinite retry wrapper).
|
|
137
201
|
|
|
138
202
|
## Topic
|
|
139
203
|
|
|
@@ -186,6 +250,8 @@ for await (const event of t.live({ after: "0-0" })) {
|
|
|
186
250
|
- **Retention**: Automatic XTRIM based on `retentionMs`.
|
|
187
251
|
- **Multi-tenant**: Pass `tenantId` to `pub()` and `recv()` for isolated streams.
|
|
188
252
|
- **AbortSignal**: Pass `signal` to `recv()`, `stream()`, and `live()`.
|
|
253
|
+
- **Transport resilience in stream loops**: `reader().stream({ wait: true })` and `live()` auto-retry transient transport errors by default.
|
|
254
|
+
- **One-shot semantics stay explicit**: `recv()` and `stream({ wait: false })` keep direct error semantics.
|
|
189
255
|
|
|
190
256
|
## Job
|
|
191
257
|
|
|
@@ -251,11 +317,94 @@ sendOrderMail.stop();
|
|
|
251
317
|
- **AbortSignal**: `ctx.signal` is aborted on timeout, error, or cancellation.
|
|
252
318
|
- **Graceful shutdown**: `stop()` signals the worker loop to exit.
|
|
253
319
|
- **Per-job state TTL**: Each job's state has its own Redis TTL (7 days default).
|
|
320
|
+
- **Worker transport resilience**: Internal worker receive loop auto-retries transient transport errors and self-recovers after short Redis outages.
|
|
321
|
+
|
|
322
|
+
## Scheduler
|
|
323
|
+
|
|
324
|
+
Distributed cron scheduler for horizontally scaled apps. Registration is idempotent per schedule `id`; one active leader dispatches due slots and submits durable jobs.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { z } from "zod";
|
|
328
|
+
import { job, scheduler } from "@valentinkolb/sync";
|
|
329
|
+
|
|
330
|
+
const cleanup = job({
|
|
331
|
+
id: "cleanup-temp",
|
|
332
|
+
schema: z.object({ scope: z.string() }),
|
|
333
|
+
process: async ({ input }) => {
|
|
334
|
+
await runCleanup(input.scope);
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const sched = scheduler({
|
|
339
|
+
id: "platform",
|
|
340
|
+
onMetric: (metric) => console.log(metric),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
sched.start();
|
|
344
|
+
|
|
345
|
+
await sched.register({
|
|
346
|
+
id: "cleanup-hourly", // idempotent key
|
|
347
|
+
cron: "0 * * * *", // every hour
|
|
348
|
+
tz: "Europe/Berlin",
|
|
349
|
+
job: cleanup,
|
|
350
|
+
input: { scope: "tmp" },
|
|
351
|
+
misfire: "skip", // default: do not replay backlog
|
|
352
|
+
meta: { owner: "ops" },
|
|
353
|
+
});
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Scheduler features
|
|
357
|
+
|
|
358
|
+
- **Idempotent upsert registration**: repeated `register({ id, ... })` creates once, then updates in place.
|
|
359
|
+
- **No fixed leader pod**: leadership uses a renewable Redis lease (`mutex`) with epoch fencing.
|
|
360
|
+
- **Durable dispatch**: each cron slot maps to deterministic job key (`scheduleId:slotTs`) to prevent duplicates.
|
|
361
|
+
- **Misfire policies**: `skip` (default), `catch_up_one`, `catch_up_all` with cap (`maxCatchUpRuns`).
|
|
362
|
+
- **Failure isolation**: submit retry + backoff, dispatch DLQ, configurable threshold for auto-advance after repeated failures.
|
|
363
|
+
- **Handler safety**: optional `strictHandlers` mode (default `true`) relinquishes leadership when required handlers are missing.
|
|
364
|
+
|
|
365
|
+
## Ephemeral
|
|
366
|
+
|
|
367
|
+
Typed ephemeral key/value store with TTL semantics and stream events. Useful for short-lived state like presence, worker heartbeats, or temporary coordination hints.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import { z } from "zod";
|
|
371
|
+
import { ephemeral } from "@valentinkolb/sync";
|
|
372
|
+
|
|
373
|
+
const presence = ephemeral({
|
|
374
|
+
id: "presence",
|
|
375
|
+
schema: z.object({ nodeId: z.string(), status: z.enum(["up", "down"]) }),
|
|
376
|
+
ttlMs: 30_000,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await presence.upsert({ key: "worker:42", value: { nodeId: "42", status: "up" } });
|
|
380
|
+
await presence.touch({ key: "worker:42" }); // extend TTL
|
|
381
|
+
|
|
382
|
+
const snap = await presence.snapshot();
|
|
383
|
+
console.log(snap.entries.length, snap.cursor);
|
|
384
|
+
|
|
385
|
+
for await (const event of presence.reader({ after: snap.cursor }).stream()) {
|
|
386
|
+
console.log(event.type);
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Ephemeral features
|
|
391
|
+
|
|
392
|
+
- **TTL-first model**: each key has an independent expiration and can be extended with `touch()`.
|
|
393
|
+
- **Bounded capacity**: configurable `maxEntries` and payload-size limits.
|
|
394
|
+
- **Typed values**: schema validation on write and read-path parsing.
|
|
395
|
+
- **Change stream**: emits `upsert`, `touch`, `delete`, `expire`, and `overflow` events.
|
|
396
|
+
- **Snapshot + cursor**: take a consistent snapshot and continue with stream replay.
|
|
397
|
+
- **Tenant isolation**: optional per-operation `tenantId` for keyspace isolation.
|
|
398
|
+
- **Transport resilience in stream loops**: `reader().stream({ wait: true })` auto-retries transient transport errors by default.
|
|
254
399
|
|
|
255
400
|
## Testing
|
|
256
401
|
|
|
257
402
|
```bash
|
|
258
403
|
bun test --preload ./tests/preload.ts
|
|
404
|
+
# fault-tolerance suites
|
|
405
|
+
bun run test:fault
|
|
406
|
+
# all test files
|
|
407
|
+
bun run test:all
|
|
259
408
|
```
|
|
260
409
|
|
|
261
410
|
Requires a Redis-compatible server on `localhost:6399` (configured in `tests/preload.ts`).
|
|
@@ -277,6 +426,7 @@ You need a Redis-compatible server on port 6399. The easiest way is Docker/Podma
|
|
|
277
426
|
```bash
|
|
278
427
|
docker run -d --name valkey -p 6399:6379 valkey/valkey:latest
|
|
279
428
|
bun test --preload ./tests/preload.ts
|
|
429
|
+
bun run test:fault
|
|
280
430
|
```
|
|
281
431
|
|
|
282
432
|
### Project structure
|
|
@@ -288,7 +438,11 @@ src/
|
|
|
288
438
|
queue.ts # Durable work queue
|
|
289
439
|
topic.ts # Pub/sub with consumer groups
|
|
290
440
|
job.ts # Job processing (composes queue + topic)
|
|
441
|
+
scheduler.ts # Distributed cron scheduler (durable dispatch via job)
|
|
442
|
+
ephemeral.ts # TTL-based ephemeral store with event stream
|
|
443
|
+
retry.ts # Generic transport-aware retry utility
|
|
291
444
|
internal/
|
|
445
|
+
cron.ts # Cron parsing/next timestamp + timezone validation
|
|
292
446
|
job-utils.ts # Job helper functions (retry, timeout, parsing)
|
|
293
447
|
topic-utils.ts # Stream entry parsing helpers
|
|
294
448
|
tests/
|
|
@@ -296,7 +450,7 @@ tests/
|
|
|
296
450
|
*-utils.unit.test.ts # Pure unit tests
|
|
297
451
|
preload.ts # Sets REDIS_URL for test environment
|
|
298
452
|
index.ts # Public API exports
|
|
299
|
-
|
|
453
|
+
skills/ # Modular agent skills + per-feature API references
|
|
300
454
|
```
|
|
301
455
|
|
|
302
456
|
### Guidelines
|
package/index.d.ts
CHANGED
|
@@ -3,3 +3,6 @@ export { mutex, LockError, type Mutex, type Lock, type MutexConfig } from "./src
|
|
|
3
3
|
export { queue, type Queue, type QueueConfig, type QueueReader, type QueueRecvConfig, type QueueSendConfig, type QueueReceived, } from "./src/queue";
|
|
4
4
|
export { topic, type Topic, type TopicConfig, type TopicReader, type TopicRecvConfig, type TopicPubConfig, type TopicDelivery, type TopicLiveConfig, type TopicLiveEvent, } from "./src/topic";
|
|
5
5
|
export { job, type JobId, type JobStatus, type JobTerminal, type SubmitOptions, type JoinOptions, type CancelOptions, type JobEvent, type JobEvents, type JobContext, type JobHandle, type JobDefinition, } from "./src/job";
|
|
6
|
+
export { scheduler, type Scheduler, type SchedulerConfig, type SchedulerRegisterConfig, type SchedulerUnregisterConfig, type SchedulerGetConfig, type SchedulerInfo, type SchedulerMetric, type SchedulerMetricsSnapshot, } from "./src/scheduler";
|
|
7
|
+
export { ephemeral, EphemeralCapacityError, EphemeralPayloadTooLargeError, type EphemeralConfig, type EphemeralUpsertConfig, type EphemeralTouchConfig, type EphemeralRemoveConfig, type EphemeralEntry, type EphemeralSnapshot, type EphemeralRecvConfig, type EphemeralEvent, type EphemeralReader, type EphemeralStore, } from "./src/ephemeral";
|
|
8
|
+
export { retry, isRetryableTransportError, DEFAULT_RETRY_OPTIONS, type RetryOptions, } from "./src/retry";
|