@valentinkolb/sync 2.2.0 → 3.0.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 +144 -193
- package/browser/ephemeral.js +472 -0
- package/browser/index.js +21 -0
- package/browser/job.js +14687 -0
- package/browser/mutex.js +165 -0
- package/browser/queue.js +342 -0
- package/browser/ratelimit.js +124 -0
- package/browser/registry.js +662 -0
- package/browser/retry.js +94 -0
- package/browser/scheduler.js +988 -0
- package/browser/store.js +61 -0
- package/browser/topic.js +359 -0
- package/index.js +1 -18531
- package/package.json +19 -4
- package/src/browser/ephemeral.d.ts +101 -0
- package/src/browser/index.d.ts +10 -0
- package/src/browser/internal/emitter.d.ts +11 -0
- package/src/browser/internal/event-log.d.ts +33 -0
- package/src/browser/internal/id.d.ts +9 -0
- package/src/browser/internal/sleep.d.ts +2 -0
- package/src/browser/job.d.ts +107 -0
- package/src/browser/mutex.d.ts +28 -0
- package/src/browser/queue.d.ts +67 -0
- package/src/browser/ratelimit.d.ts +24 -0
- package/src/browser/registry.d.ts +131 -0
- package/src/browser/retry.d.ts +19 -0
- package/src/browser/scheduler.d.ts +164 -0
- package/src/browser/store.d.ts +17 -0
- package/src/browser/topic.d.ts +65 -0
package/README.md
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
# @valentinkolb/sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Synchronization primitives for TypeScript — available in two flavors:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **Server** (`@valentinkolb/sync`): backed by Redis (6.2+, Valkey, Dragonfly), built for [Bun](https://bun.sh). Designed for horizontally scaled systems where multiple service instances need coordinated access to shared state.
|
|
6
|
+
- **Browser** (`@valentinkolb/sync/browser`): fully in-memory, zero dependencies beyond `zod`. Designed for local-first browser apps that need the same primitives (rate limiting, queues, schedulers) without a server.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
- **Minimal dependencies** - Only `zod` as a peer dependency. Everything else is Bun built-ins and Redis Lua scripts.
|
|
9
|
-
- **Composable building blocks** - Nine focused primitives that work independently or together. `job` composes `queue` + `topic` internally, `scheduler` composes durable dispatch on top of `job`.
|
|
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, job, registry, and ephemeral payloads are validated with Zod at the boundary. Invalid data never enters Redis.
|
|
8
|
+
Both share the same API. Code written for one works on the other — just change the import path.
|
|
13
9
|
|
|
14
|
-
|
|
10
|
+
Provides nine modules: **ratelimit**, **mutex**, **queue**, **topic**, **job**, **scheduler**, **registry**, **ephemeral**, and **retry**. They work independently or compose — `job` uses `queue` + `topic` internally, `scheduler` uses `job` + `mutex`.
|
|
15
11
|
|
|
16
|
-
|
|
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
|
-
- **Scheduler** - Distributed cron scheduler with idempotent registration, leader fencing, and durable dispatch via job submission
|
|
22
|
-
- **Registry** - Generic typed service/config registry with native prefix queries, CAS, TTL-backed liveness, and push-based change streams
|
|
23
|
-
- **Ephemeral** - TTL-based ephemeral key/value store with typed snapshots and event stream for upsert/touch/delete/expire
|
|
24
|
-
- **Retry utility** - Small transport-aware retry helper with sensible defaults and per-call override
|
|
25
|
-
|
|
26
|
-
For complete API details, use the modular skills in [`skills/`](./skills), especially each feature's `references/api.md` such as `sync-registry`, `sync-scheduler`, `sync-job`, and `sync-topic`.
|
|
12
|
+
Requires `zod` as peer dependency for payload validation.
|
|
27
13
|
|
|
28
14
|
## Installation
|
|
29
15
|
|
|
@@ -31,71 +17,21 @@ For complete API details, use the modular skills in [`skills/`](./skills), espec
|
|
|
31
17
|
bun add @valentinkolb/sync zod
|
|
32
18
|
```
|
|
33
19
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
KISS transport retry helper for Redis/network hiccups.
|
|
37
|
-
Defaults are exported as `DEFAULT_RETRY_OPTIONS`; override only when needed.
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
import {
|
|
41
|
-
retry,
|
|
42
|
-
DEFAULT_RETRY_OPTIONS,
|
|
43
|
-
isRetryableTransportError,
|
|
44
|
-
} from "@valentinkolb/sync";
|
|
45
|
-
|
|
46
|
-
// default usage (recommended)
|
|
47
|
-
const value = await retry(() => fragileCall());
|
|
48
|
-
|
|
49
|
-
console.log(DEFAULT_RETRY_OPTIONS);
|
|
50
|
-
// {
|
|
51
|
-
// attempts: 8,
|
|
52
|
-
// minDelayMs: 100,
|
|
53
|
-
// maxDelayMs: 2000,
|
|
54
|
-
// factor: 2,
|
|
55
|
-
// jitter: 0.2,
|
|
56
|
-
// retryIf: isRetryableTransportError
|
|
57
|
-
// }
|
|
58
|
-
|
|
59
|
-
// per-call override (edge cases only)
|
|
60
|
-
const value2 = await retry(
|
|
61
|
-
() => fragileCall(),
|
|
62
|
-
{
|
|
63
|
-
attempts: 12,
|
|
64
|
-
maxDelayMs: 5_000,
|
|
65
|
-
retryIf: isRetryableTransportError,
|
|
66
|
-
},
|
|
67
|
-
);
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Internal default behavior:
|
|
71
|
-
- Queue/topic/registry/ephemeral `stream({ wait: true })` and topic `live()` use transport retries to stay alive across brief outages.
|
|
72
|
-
- One-shot calls (`recv()`, `stream({ wait: false })`, `send()/pub()/submit()`) keep explicit failure semantics unless you wrap them with `retry(...)`.
|
|
73
|
-
|
|
74
|
-
Requires [Bun](https://bun.sh) and a Redis-compatible server (Redis 6.2+, Valkey, Dragonfly).
|
|
20
|
+
Server modules use Redis for state. Browser modules use an in-memory store — no Redis needed.
|
|
75
21
|
|
|
76
|
-
###
|
|
22
|
+
### Agent Skills (optional)
|
|
77
23
|
|
|
78
|
-
This repository ships reusable agent skills in [`skills/`](./skills).
|
|
79
|
-
Using the [Vercel Skills CLI](https://github.com/vercel-labs/skills), install them with:
|
|
24
|
+
This repository ships reusable agent skills in [`skills/`](./skills). Install them with the [Vercel Skills CLI](https://github.com/vercel-labs/skills):
|
|
80
25
|
|
|
81
26
|
```bash
|
|
82
|
-
# list available skills from this repo
|
|
83
|
-
bunx skills add https://github.com/valentinkolb/sync --list
|
|
84
|
-
|
|
85
|
-
# install all skills (project-local)
|
|
86
27
|
bunx skills add https://github.com/valentinkolb/sync --skill '*'
|
|
87
|
-
|
|
88
|
-
# install selected skills (example)
|
|
89
|
-
bunx skills add https://github.com/valentinkolb/sync \
|
|
90
|
-
--skill sync-registry \
|
|
91
|
-
--skill sync-scheduler \
|
|
92
|
-
--skill sync-job \
|
|
93
|
-
--skill sync-retry
|
|
94
28
|
```
|
|
95
29
|
|
|
30
|
+
---
|
|
31
|
+
|
|
96
32
|
## Rate Limit
|
|
97
33
|
|
|
98
|
-
Sliding window rate limiter.
|
|
34
|
+
Sliding window rate limiter.
|
|
99
35
|
|
|
100
36
|
```ts
|
|
101
37
|
import { ratelimit, RateLimitError } from "@valentinkolb/sync";
|
|
@@ -120,7 +56,7 @@ try {
|
|
|
120
56
|
|
|
121
57
|
## Mutex
|
|
122
58
|
|
|
123
|
-
Distributed lock with retry
|
|
59
|
+
Distributed lock with retry, TTL auto-expiry, and owner-only release.
|
|
124
60
|
|
|
125
61
|
```ts
|
|
126
62
|
import { mutex, LockError } from "@valentinkolb/sync";
|
|
@@ -190,20 +126,11 @@ const reader = q.reader();
|
|
|
190
126
|
const msg2 = await reader.recv({ signal: abortController.signal });
|
|
191
127
|
```
|
|
192
128
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
- **Lease-based delivery**: Messages are invisible to other consumers while leased. Call `msg.touch()` to extend.
|
|
196
|
-
- **Dead-letter queue**: After `maxDeliveries` failed attempts, messages move to DLQ.
|
|
197
|
-
- **Delayed messages**: `send({ delayMs })` or `nack({ delayMs })` for retry delays.
|
|
198
|
-
- **Idempotency**: `send({ idempotencyKey })` deduplicates within a configurable TTL.
|
|
199
|
-
- **Multi-tenant**: Pass `tenantId` to `send()` and `recv()` for isolated queues.
|
|
200
|
-
- **AbortSignal**: Pass `signal` to `recv()` for graceful shutdown.
|
|
201
|
-
- **Transport resilience in stream loops**: `stream({ wait: true })` auto-retries transient transport errors and keeps consuming after short Redis outages.
|
|
202
|
-
- **One-shot semantics stay explicit**: `recv()` and `stream({ wait: false })` keep direct error semantics (no hidden infinite retry wrapper).
|
|
129
|
+
Messages exceeding `maxDeliveries` move to DLQ. Extend active leases with `msg.touch()`. Optional `tenantId` for isolated queues.
|
|
203
130
|
|
|
204
131
|
## Topic
|
|
205
132
|
|
|
206
|
-
Pub/sub with Redis Streams.
|
|
133
|
+
Pub/sub with Redis Streams. Consumer groups for at-least-once delivery, `live()` for best-effort streaming to all listeners.
|
|
207
134
|
|
|
208
135
|
```ts
|
|
209
136
|
import { z } from "zod";
|
|
@@ -244,20 +171,11 @@ for await (const event of t.live({ after: "0-0" })) {
|
|
|
244
171
|
}
|
|
245
172
|
```
|
|
246
173
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
- **Consumer groups**: Each group tracks its own position. Multiple consumers in the same group load-balance.
|
|
250
|
-
- **Live streaming**: `t.live()` uses XREAD for real-time, best-effort delivery to all listeners.
|
|
251
|
-
- **Replay**: Pass `after: "0-0"` to `live()` to replay all stored events.
|
|
252
|
-
- **Retention**: Automatic XTRIM based on `retentionMs`.
|
|
253
|
-
- **Multi-tenant**: Pass `tenantId` to `pub()` and `recv()` for isolated streams.
|
|
254
|
-
- **AbortSignal**: Pass `signal` to `recv()`, `stream()`, and `live()`.
|
|
255
|
-
- **Transport resilience in stream loops**: `reader().stream({ wait: true })` and `live()` auto-retry transient transport errors by default.
|
|
256
|
-
- **One-shot semantics stay explicit**: `recv()` and `stream({ wait: false })` keep direct error semantics.
|
|
174
|
+
Each consumer group tracks its own position. Retention via automatic XTRIM. Optional `tenantId` for isolated streams.
|
|
257
175
|
|
|
258
176
|
## Job
|
|
259
177
|
|
|
260
|
-
Durable job processing built on queue + topic.
|
|
178
|
+
Durable job processing built on queue + topic. Retries with backoff, cancellation, per-job event stream.
|
|
261
179
|
|
|
262
180
|
```ts
|
|
263
181
|
import { z } from "zod";
|
|
@@ -268,7 +186,6 @@ const sendOrderMail = job({
|
|
|
268
186
|
schema: z.object({ orderId: z.string(), to: z.string().email() }),
|
|
269
187
|
defaults: { maxAttempts: 3, backoff: { kind: "exp", baseMs: 1000 } },
|
|
270
188
|
process: async ({ ctx, input }) => {
|
|
271
|
-
// ctx.signal is aborted on timeout or error
|
|
272
189
|
if (ctx.signal.aborted) return;
|
|
273
190
|
|
|
274
191
|
await ctx.heartbeat(); // extend lease
|
|
@@ -293,37 +210,23 @@ const terminal = await sendOrderMail.join({ id, timeoutMs: 60_000 });
|
|
|
293
210
|
// Cancel
|
|
294
211
|
await sendOrderMail.cancel({ id, reason: "user-request" });
|
|
295
212
|
|
|
296
|
-
// Event stream
|
|
213
|
+
// Event stream (every state transition emits a typed event)
|
|
297
214
|
const events = sendOrderMail.events(id);
|
|
298
215
|
for await (const e of events.reader("orchestrator").stream({ wait: false })) {
|
|
299
|
-
console.log(e.data.type); // "submitted" | "started" | "heartbeat" | "retry" | "completed" | "failed" | "cancelled"
|
|
300
|
-
await e.commit();
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Live events
|
|
304
|
-
for await (const e of events.live({ signal: ac.signal })) {
|
|
305
216
|
console.log(e.data.type);
|
|
217
|
+
// "submitted" | "started" | "heartbeat" | "retry" | "completed" | "failed" | "cancelled"
|
|
218
|
+
await e.commit();
|
|
306
219
|
}
|
|
307
220
|
|
|
308
221
|
// Graceful shutdown
|
|
309
222
|
sendOrderMail.stop();
|
|
310
223
|
```
|
|
311
224
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
- **Automatic retries**: Fixed or exponential backoff with configurable max attempts.
|
|
315
|
-
- **Lease timeout**: Jobs that exceed `leaseMs` are automatically timed out.
|
|
316
|
-
- **Cancellation**: Cancel in-flight or queued jobs. Workers detect cancellation between steps.
|
|
317
|
-
- **Event sourcing**: Every state transition emits a typed event to a per-job topic.
|
|
318
|
-
- **Idempotent submit**: Pass `key` to deduplicate submissions atomically.
|
|
319
|
-
- **AbortSignal**: `ctx.signal` is aborted on timeout, error, or cancellation.
|
|
320
|
-
- **Graceful shutdown**: `stop()` signals the worker loop to exit.
|
|
321
|
-
- **Per-job state TTL**: Each job's state has its own Redis TTL (7 days default).
|
|
322
|
-
- **Worker transport resilience**: Internal worker receive loop auto-retries transient transport errors and self-recovers after short Redis outages.
|
|
225
|
+
Jobs exceeding `leaseMs` are timed out automatically. `ctx.signal` is aborted on timeout or cancellation. Job state in Redis expires after a configurable TTL (default 7 days).
|
|
323
226
|
|
|
324
227
|
## Scheduler
|
|
325
228
|
|
|
326
|
-
Distributed cron scheduler
|
|
229
|
+
Distributed cron scheduler. One leader per `id` dispatches due slots as durable jobs. Registration is idempotent.
|
|
327
230
|
|
|
328
231
|
```ts
|
|
329
232
|
import { z } from "zod";
|
|
@@ -350,34 +253,22 @@ await sched.register({
|
|
|
350
253
|
tz: "Europe/Berlin",
|
|
351
254
|
job: cleanup,
|
|
352
255
|
input: { scope: "tmp" },
|
|
353
|
-
misfire: "skip", //
|
|
256
|
+
misfire: "skip", // "skip" | "catch_up_one" | "catch_up_all"
|
|
354
257
|
meta: { owner: "ops" },
|
|
355
258
|
});
|
|
356
259
|
|
|
260
|
+
// Manual trigger (does not alter cron state)
|
|
357
261
|
await sched.triggerNow({
|
|
358
262
|
id: "cleanup-hourly",
|
|
359
|
-
key: "ops-manual-run-1", // optional
|
|
263
|
+
key: "ops-manual-run-1", // optional: idempotent manual trigger
|
|
360
264
|
});
|
|
361
265
|
```
|
|
362
266
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
- **Idempotent upsert registration**: repeated `register({ id, ... })` creates once, then updates in place.
|
|
366
|
-
- **No fixed leader pod**: leadership uses a renewable Redis lease (`mutex`) with epoch fencing.
|
|
367
|
-
- **Durable dispatch**: each cron slot maps to deterministic job key (`scheduleId:slotTs`) to prevent duplicates.
|
|
368
|
-
- **Durable manual trigger**: `triggerNow({ id, key? })` submits immediately through the same durable job path. Durability begins once `triggerNow()` returns a `jobId`.
|
|
369
|
-
- **Misfire policies**: `skip` (default), `catch_up_one`, `catch_up_all` with cap (`maxCatchUpRuns`).
|
|
370
|
-
- **Failure isolation**: submit retry + backoff, dispatch DLQ, configurable threshold for auto-advance after repeated failures.
|
|
371
|
-
- **Handler safety**: optional `strictHandlers` mode (default `true`) relinquishes leadership when required handlers are missing.
|
|
372
|
-
|
|
373
|
-
Manual trigger notes:
|
|
374
|
-
- `triggerNow()` does not require `start()` and does not alter cron state (`nextRunAt`, misfire handling, due slots).
|
|
375
|
-
- `triggerNow()` reuses the registered schedule input. If you need custom input per run, call the underlying `job.submit(...)` directly.
|
|
376
|
-
- Pass `key` for retry-safe idempotent manual triggering. Without `key`, repeated calls create additional runs.
|
|
267
|
+
Leader election via renewable Redis lease with epoch fencing. Each cron slot maps to a deterministic job key to prevent duplicates. `triggerNow()` does not require `start()` and reuses the registered input. Misfire policies: `skip` (default), `catch_up_one`, `catch_up_all`.
|
|
377
268
|
|
|
378
269
|
## Registry
|
|
379
270
|
|
|
380
|
-
Typed
|
|
271
|
+
Typed key/value registry with prefix listing, compare-and-swap, optional TTL-backed liveness, and change streams.
|
|
381
272
|
|
|
382
273
|
```ts
|
|
383
274
|
import { z } from "zod";
|
|
@@ -405,6 +296,7 @@ const active = await services.list({
|
|
|
405
296
|
status: "active",
|
|
406
297
|
});
|
|
407
298
|
|
|
299
|
+
// Snapshot + cursor for replay-safe handoff into stream
|
|
408
300
|
const snap = await services.list({ prefix: "apps/" });
|
|
409
301
|
const ac = new AbortController();
|
|
410
302
|
|
|
@@ -413,21 +305,11 @@ for await (const ev of services.reader({ prefix: "apps/", after: snap.cursor }).
|
|
|
413
305
|
}
|
|
414
306
|
```
|
|
415
307
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
- **Native prefix listing**: `list({ prefix })` uses Redis-side lex indexing instead of client-side filtering.
|
|
419
|
-
- **Typed payloads**: values are validated with Zod on write and parsed on read.
|
|
420
|
-
- **CAS updates**: `cas({ key, version, value })` is atomic in Redis.
|
|
421
|
-
- **Optional liveness**: records with `ttlMs` are live and refreshed via `touch()`.
|
|
422
|
-
- **Expired-state visibility**: recently expired entries can be queried via `status: "expired"`.
|
|
423
|
-
- **Snapshot + cursor**: `list()` returns a cursor for replay-safe handoff into `reader()`.
|
|
424
|
-
- **Scoped streams**: watch a single key, a namespace prefix, or the whole registry.
|
|
425
|
-
- **Namespace-safe prefixes**: `list({ prefix })` and `reader({ prefix })` expect slash-suffixed namespace prefixes such as `apps/contacts/`.
|
|
426
|
-
- **Bounded growth**: configurable `maxEntries`, payload-size limits, root-stream retention, per-stream max length, and tombstone retention.
|
|
308
|
+
CAS via `cas({ key, version, value })`. Records with `ttlMs` are refreshed via `touch()`; expired entries remain queryable via `status: "expired"`. Streams can watch a single key, a prefix, or the whole registry.
|
|
427
309
|
|
|
428
310
|
## Ephemeral
|
|
429
311
|
|
|
430
|
-
|
|
312
|
+
TTL-based key/value store with event stream. Each key expires independently. Useful for presence, heartbeats, or temporary state.
|
|
431
313
|
|
|
432
314
|
```ts
|
|
433
315
|
import { z } from "zod";
|
|
@@ -447,49 +329,121 @@ console.log(snap.entries.length, snap.cursor);
|
|
|
447
329
|
|
|
448
330
|
for await (const event of presence.reader({ after: snap.cursor }).stream()) {
|
|
449
331
|
console.log(event.type);
|
|
332
|
+
// "upsert" | "touch" | "delete" | "expire" | "overflow"
|
|
450
333
|
}
|
|
451
334
|
```
|
|
452
335
|
|
|
453
|
-
|
|
336
|
+
`snapshot()` returns entries + cursor for handoff into `reader().stream()`. Optional `tenantId` for keyspace isolation.
|
|
454
337
|
|
|
455
|
-
|
|
456
|
-
- **Bounded capacity**: configurable `maxEntries` and payload-size limits.
|
|
457
|
-
- **Typed values**: schema validation on write and read-path parsing.
|
|
458
|
-
- **Change stream**: emits `upsert`, `touch`, `delete`, `expire`, and `overflow` events.
|
|
459
|
-
- **Snapshot + cursor**: take a consistent snapshot and continue with stream replay.
|
|
460
|
-
- **Tenant isolation**: optional per-operation `tenantId` for keyspace isolation.
|
|
461
|
-
- **Transport resilience in stream loops**: `reader().stream({ wait: true })` auto-retries transient transport errors by default.
|
|
338
|
+
## Retry
|
|
462
339
|
|
|
463
|
-
|
|
340
|
+
Retry helper with exponential backoff for transient Redis/network errors.
|
|
464
341
|
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
342
|
+
```ts
|
|
343
|
+
import { retry, DEFAULT_RETRY_OPTIONS } from "@valentinkolb/sync";
|
|
344
|
+
|
|
345
|
+
const value = await retry(() => fragileCall());
|
|
346
|
+
|
|
347
|
+
// Per-call override
|
|
348
|
+
const value2 = await retry(
|
|
349
|
+
() => fragileCall(),
|
|
350
|
+
{ attempts: 12, maxDelayMs: 5_000 },
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
console.log(DEFAULT_RETRY_OPTIONS);
|
|
354
|
+
// { attempts: 8, minDelayMs: 100, maxDelayMs: 2000, factor: 2, jitter: 0.2, retryIf: isRetryableTransportError }
|
|
471
355
|
```
|
|
472
356
|
|
|
473
|
-
|
|
357
|
+
Long-lived stream loops (`stream({ wait: true })`, `live()`) use transport retries internally. One-shot calls surface errors directly unless wrapped with `retry(...)`.
|
|
358
|
+
|
|
359
|
+
---
|
|
474
360
|
|
|
475
|
-
##
|
|
361
|
+
## Browser
|
|
476
362
|
|
|
477
|
-
|
|
363
|
+
All nine modules are available as a browser-compatible build with no Redis dependency. State lives in-memory (JS heap) and is lost on page refresh.
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
import { queue, ratelimit, mutex, topic, ephemeral, registry, job, scheduler, retry } from "@valentinkolb/sync/browser";
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The API is identical to the server version — same factory pattern, same types, same methods. Just swap the import path.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { z } from "zod";
|
|
373
|
+
import { queue } from "@valentinkolb/sync/browser";
|
|
374
|
+
|
|
375
|
+
const q = queue({
|
|
376
|
+
id: "tasks",
|
|
377
|
+
schema: z.object({ url: z.string().url() }),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
await q.send({ data: { url: "https://example.com" } });
|
|
381
|
+
|
|
382
|
+
for await (const msg of q.stream({ wait: false })) {
|
|
383
|
+
await fetch(msg.data.url);
|
|
384
|
+
await msg.ack();
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Key differences from server
|
|
389
|
+
|
|
390
|
+
| | Server | Browser |
|
|
391
|
+
|---|---|---|
|
|
392
|
+
| **State** | Redis | JS heap (lost on refresh) |
|
|
393
|
+
| **Atomicity** | Lua scripts | JS single-threading |
|
|
394
|
+
| **Blocking reads** | Redis `BRPOPLPUSH` / `XREAD BLOCK` | Promise-based event emitters |
|
|
395
|
+
| **TTL** | Redis key expiry + reconciliation | `setTimeout` callbacks |
|
|
396
|
+
| **Cross-process** | Yes (multi-pod) | No (single-tab) |
|
|
397
|
+
| **Long identifier hashing** | SHA-256 | djb2 (non-cryptographic) |
|
|
398
|
+
|
|
399
|
+
### Store abstraction
|
|
400
|
+
|
|
401
|
+
Browser modules use a `MemoryStore` by default. Some modules accept an optional `store` config for custom storage:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { ratelimit, createMemoryStore, type Store } from "@valentinkolb/sync/browser";
|
|
405
|
+
|
|
406
|
+
const limiter = ratelimit({
|
|
407
|
+
id: "api",
|
|
408
|
+
limit: 10,
|
|
409
|
+
windowSecs: 60,
|
|
410
|
+
store: createMemoryStore(), // default — can be replaced
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The `Store` interface is minimal:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
interface Store {
|
|
418
|
+
get(key: string): unknown | undefined;
|
|
419
|
+
set(key: string, value: unknown, ttlMs?: number): void;
|
|
420
|
+
del(key: string): void;
|
|
421
|
+
keys(prefix?: string): string[];
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Development
|
|
478
428
|
|
|
479
429
|
```bash
|
|
480
430
|
git clone https://github.com/valentinkolb/sync.git
|
|
481
|
-
cd sync
|
|
482
|
-
bun install
|
|
431
|
+
cd sync && bun install
|
|
483
432
|
```
|
|
484
433
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
You need a Redis-compatible server on port 6399. The easiest way is Docker/Podman:
|
|
434
|
+
Server tests require a Redis-compatible server on port 6399:
|
|
488
435
|
|
|
489
436
|
```bash
|
|
490
437
|
docker run -d --name valkey -p 6399:6379 valkey/valkey:latest
|
|
491
|
-
bun test --preload ./tests/preload.ts
|
|
492
|
-
bun run test:fault
|
|
438
|
+
bun test --preload ./tests/preload.ts # server integration tests
|
|
439
|
+
bun run test:fault # fault-tolerance suites
|
|
440
|
+
bun run test:all # all server tests
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Browser tests run without Redis:
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
bun run test:browser # all browser tests (~210 tests)
|
|
493
447
|
```
|
|
494
448
|
|
|
495
449
|
### Project structure
|
|
@@ -500,31 +454,28 @@ src/
|
|
|
500
454
|
mutex.ts # Distributed lock
|
|
501
455
|
queue.ts # Durable work queue
|
|
502
456
|
topic.ts # Pub/sub with consumer groups
|
|
503
|
-
job.ts # Job processing (
|
|
504
|
-
scheduler.ts # Distributed cron
|
|
505
|
-
registry.ts # Typed registry with prefix queries
|
|
506
|
-
ephemeral.ts # TTL-based ephemeral store
|
|
507
|
-
retry.ts #
|
|
508
|
-
internal/
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
index.ts # Public API exports
|
|
517
|
-
skills/ # Modular agent skills + per-feature API references (including sync-registry)
|
|
457
|
+
job.ts # Job processing (queue + topic)
|
|
458
|
+
scheduler.ts # Distributed cron (job + mutex)
|
|
459
|
+
registry.ts # Typed registry with prefix queries and CAS
|
|
460
|
+
ephemeral.ts # TTL-based ephemeral store
|
|
461
|
+
retry.ts # Transport-aware retry utility
|
|
462
|
+
internal/ # Cron parsing, job/topic helpers (pure JS, shared)
|
|
463
|
+
browser/ # Browser-compatible in-memory implementations
|
|
464
|
+
store.ts # Store interface + MemoryStore
|
|
465
|
+
internal/ # sleep, emitter, event-log, id utilities
|
|
466
|
+
*.ts # One file per module (same API as server)
|
|
467
|
+
tests/ # Server integration + unit tests (require Redis)
|
|
468
|
+
tests/browser/ # Browser tests (no Redis needed)
|
|
469
|
+
skills/ # Agent skills with per-feature API references
|
|
518
470
|
```
|
|
519
471
|
|
|
520
472
|
### Guidelines
|
|
521
473
|
|
|
522
|
-
-
|
|
523
|
-
-
|
|
524
|
-
- Validate at boundaries (user input), trust internal data.
|
|
474
|
+
- Server: every Redis mutation must be in a Lua script for atomicity.
|
|
475
|
+
- Browser: JS single-threading provides atomicity — no locks needed.
|
|
525
476
|
- All modules follow the `moduleName({ id, ...config })` factory pattern.
|
|
526
|
-
-
|
|
527
|
-
-
|
|
477
|
+
- Validate at boundaries, trust internal data.
|
|
478
|
+
- Server tests go in `tests/`. Browser tests go in `tests/browser/`.
|
|
528
479
|
|
|
529
480
|
## License
|
|
530
481
|
|