@valentinkolb/sync 2.1.2 → 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 +180 -176
- 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.d.ts +1 -0
- package/index.js +3 -16994
- 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/src/registry.d.ts +130 -0
package/README.md
CHANGED
|
@@ -1,28 +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** - Seven 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, 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
|
-
- **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
|
|
24
|
-
|
|
25
|
-
For complete API details, use the modular skills in [`skills/`](./skills), especially each feature's `references/api.md`.
|
|
12
|
+
Requires `zod` as peer dependency for payload validation.
|
|
26
13
|
|
|
27
14
|
## Installation
|
|
28
15
|
|
|
@@ -30,70 +17,21 @@ For complete API details, use the modular skills in [`skills/`](./skills), espec
|
|
|
30
17
|
bun add @valentinkolb/sync zod
|
|
31
18
|
```
|
|
32
19
|
|
|
33
|
-
|
|
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(...)`.
|
|
20
|
+
Server modules use Redis for state. Browser modules use an in-memory store — no Redis needed.
|
|
72
21
|
|
|
73
|
-
|
|
22
|
+
### Agent Skills (optional)
|
|
74
23
|
|
|
75
|
-
|
|
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:
|
|
24
|
+
This repository ships reusable agent skills in [`skills/`](./skills). Install them with the [Vercel Skills CLI](https://github.com/vercel-labs/skills):
|
|
79
25
|
|
|
80
26
|
```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
27
|
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
28
|
```
|
|
93
29
|
|
|
30
|
+
---
|
|
31
|
+
|
|
94
32
|
## Rate Limit
|
|
95
33
|
|
|
96
|
-
Sliding window rate limiter.
|
|
34
|
+
Sliding window rate limiter.
|
|
97
35
|
|
|
98
36
|
```ts
|
|
99
37
|
import { ratelimit, RateLimitError } from "@valentinkolb/sync";
|
|
@@ -118,7 +56,7 @@ try {
|
|
|
118
56
|
|
|
119
57
|
## Mutex
|
|
120
58
|
|
|
121
|
-
Distributed lock with retry
|
|
59
|
+
Distributed lock with retry, TTL auto-expiry, and owner-only release.
|
|
122
60
|
|
|
123
61
|
```ts
|
|
124
62
|
import { mutex, LockError } from "@valentinkolb/sync";
|
|
@@ -188,20 +126,11 @@ const reader = q.reader();
|
|
|
188
126
|
const msg2 = await reader.recv({ signal: abortController.signal });
|
|
189
127
|
```
|
|
190
128
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
- **Lease-based delivery**: Messages are invisible to other consumers while leased. Call `msg.touch()` to extend.
|
|
194
|
-
- **Dead-letter queue**: After `maxDeliveries` failed attempts, messages move to DLQ.
|
|
195
|
-
- **Delayed messages**: `send({ delayMs })` or `nack({ delayMs })` for retry delays.
|
|
196
|
-
- **Idempotency**: `send({ idempotencyKey })` deduplicates within a configurable TTL.
|
|
197
|
-
- **Multi-tenant**: Pass `tenantId` to `send()` and `recv()` for isolated queues.
|
|
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).
|
|
129
|
+
Messages exceeding `maxDeliveries` move to DLQ. Extend active leases with `msg.touch()`. Optional `tenantId` for isolated queues.
|
|
201
130
|
|
|
202
131
|
## Topic
|
|
203
132
|
|
|
204
|
-
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.
|
|
205
134
|
|
|
206
135
|
```ts
|
|
207
136
|
import { z } from "zod";
|
|
@@ -242,20 +171,11 @@ for await (const event of t.live({ after: "0-0" })) {
|
|
|
242
171
|
}
|
|
243
172
|
```
|
|
244
173
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
- **Consumer groups**: Each group tracks its own position. Multiple consumers in the same group load-balance.
|
|
248
|
-
- **Live streaming**: `t.live()` uses XREAD for real-time, best-effort delivery to all listeners.
|
|
249
|
-
- **Replay**: Pass `after: "0-0"` to `live()` to replay all stored events.
|
|
250
|
-
- **Retention**: Automatic XTRIM based on `retentionMs`.
|
|
251
|
-
- **Multi-tenant**: Pass `tenantId` to `pub()` and `recv()` for isolated streams.
|
|
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.
|
|
174
|
+
Each consumer group tracks its own position. Retention via automatic XTRIM. Optional `tenantId` for isolated streams.
|
|
255
175
|
|
|
256
176
|
## Job
|
|
257
177
|
|
|
258
|
-
Durable job processing built on queue + topic.
|
|
178
|
+
Durable job processing built on queue + topic. Retries with backoff, cancellation, per-job event stream.
|
|
259
179
|
|
|
260
180
|
```ts
|
|
261
181
|
import { z } from "zod";
|
|
@@ -266,7 +186,6 @@ const sendOrderMail = job({
|
|
|
266
186
|
schema: z.object({ orderId: z.string(), to: z.string().email() }),
|
|
267
187
|
defaults: { maxAttempts: 3, backoff: { kind: "exp", baseMs: 1000 } },
|
|
268
188
|
process: async ({ ctx, input }) => {
|
|
269
|
-
// ctx.signal is aborted on timeout or error
|
|
270
189
|
if (ctx.signal.aborted) return;
|
|
271
190
|
|
|
272
191
|
await ctx.heartbeat(); // extend lease
|
|
@@ -291,37 +210,23 @@ const terminal = await sendOrderMail.join({ id, timeoutMs: 60_000 });
|
|
|
291
210
|
// Cancel
|
|
292
211
|
await sendOrderMail.cancel({ id, reason: "user-request" });
|
|
293
212
|
|
|
294
|
-
// Event stream
|
|
213
|
+
// Event stream (every state transition emits a typed event)
|
|
295
214
|
const events = sendOrderMail.events(id);
|
|
296
215
|
for await (const e of events.reader("orchestrator").stream({ wait: false })) {
|
|
297
|
-
console.log(e.data.type); // "submitted" | "started" | "heartbeat" | "retry" | "completed" | "failed" | "cancelled"
|
|
298
|
-
await e.commit();
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Live events
|
|
302
|
-
for await (const e of events.live({ signal: ac.signal })) {
|
|
303
216
|
console.log(e.data.type);
|
|
217
|
+
// "submitted" | "started" | "heartbeat" | "retry" | "completed" | "failed" | "cancelled"
|
|
218
|
+
await e.commit();
|
|
304
219
|
}
|
|
305
220
|
|
|
306
221
|
// Graceful shutdown
|
|
307
222
|
sendOrderMail.stop();
|
|
308
223
|
```
|
|
309
224
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
- **Automatic retries**: Fixed or exponential backoff with configurable max attempts.
|
|
313
|
-
- **Lease timeout**: Jobs that exceed `leaseMs` are automatically timed out.
|
|
314
|
-
- **Cancellation**: Cancel in-flight or queued jobs. Workers detect cancellation between steps.
|
|
315
|
-
- **Event sourcing**: Every state transition emits a typed event to a per-job topic.
|
|
316
|
-
- **Idempotent submit**: Pass `key` to deduplicate submissions atomically.
|
|
317
|
-
- **AbortSignal**: `ctx.signal` is aborted on timeout, error, or cancellation.
|
|
318
|
-
- **Graceful shutdown**: `stop()` signals the worker loop to exit.
|
|
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.
|
|
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).
|
|
321
226
|
|
|
322
227
|
## Scheduler
|
|
323
228
|
|
|
324
|
-
Distributed cron scheduler
|
|
229
|
+
Distributed cron scheduler. One leader per `id` dispatches due slots as durable jobs. Registration is idempotent.
|
|
325
230
|
|
|
326
231
|
```ts
|
|
327
232
|
import { z } from "zod";
|
|
@@ -348,34 +253,63 @@ await sched.register({
|
|
|
348
253
|
tz: "Europe/Berlin",
|
|
349
254
|
job: cleanup,
|
|
350
255
|
input: { scope: "tmp" },
|
|
351
|
-
misfire: "skip", //
|
|
256
|
+
misfire: "skip", // "skip" | "catch_up_one" | "catch_up_all"
|
|
352
257
|
meta: { owner: "ops" },
|
|
353
258
|
});
|
|
354
259
|
|
|
260
|
+
// Manual trigger (does not alter cron state)
|
|
355
261
|
await sched.triggerNow({
|
|
356
262
|
id: "cleanup-hourly",
|
|
357
|
-
key: "ops-manual-run-1", // optional
|
|
263
|
+
key: "ops-manual-run-1", // optional: idempotent manual trigger
|
|
358
264
|
});
|
|
359
265
|
```
|
|
360
266
|
|
|
361
|
-
|
|
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`.
|
|
268
|
+
|
|
269
|
+
## Registry
|
|
270
|
+
|
|
271
|
+
Typed key/value registry with prefix listing, compare-and-swap, optional TTL-backed liveness, and change streams.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { z } from "zod";
|
|
275
|
+
import { registry } from "@valentinkolb/sync";
|
|
276
|
+
|
|
277
|
+
const services = registry({
|
|
278
|
+
id: "services",
|
|
279
|
+
schema: z.object({
|
|
280
|
+
appId: z.string(),
|
|
281
|
+
kind: z.enum(["instance", "setting", "flag"]),
|
|
282
|
+
url: z.string().url().optional(),
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await services.upsert({
|
|
287
|
+
key: "apps/contacts/instances/i-1",
|
|
288
|
+
value: { appId: "contacts", kind: "instance", url: "https://contacts-1.internal" },
|
|
289
|
+
ttlMs: 15_000,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await services.touch({ key: "apps/contacts/instances/i-1" });
|
|
293
|
+
|
|
294
|
+
const active = await services.list({
|
|
295
|
+
prefix: "apps/contacts/instances/",
|
|
296
|
+
status: "active",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Snapshot + cursor for replay-safe handoff into stream
|
|
300
|
+
const snap = await services.list({ prefix: "apps/" });
|
|
301
|
+
const ac = new AbortController();
|
|
362
302
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
- **Misfire policies**: `skip` (default), `catch_up_one`, `catch_up_all` with cap (`maxCatchUpRuns`).
|
|
368
|
-
- **Failure isolation**: submit retry + backoff, dispatch DLQ, configurable threshold for auto-advance after repeated failures.
|
|
369
|
-
- **Handler safety**: optional `strictHandlers` mode (default `true`) relinquishes leadership when required handlers are missing.
|
|
303
|
+
for await (const ev of services.reader({ prefix: "apps/", after: snap.cursor }).stream({ signal: ac.signal })) {
|
|
304
|
+
console.log(ev.type);
|
|
305
|
+
}
|
|
306
|
+
```
|
|
370
307
|
|
|
371
|
-
|
|
372
|
-
- `triggerNow()` does not require `start()` and does not alter cron state (`nextRunAt`, misfire handling, due slots).
|
|
373
|
-
- `triggerNow()` reuses the registered schedule input. If you need custom input per run, call the underlying `job.submit(...)` directly.
|
|
374
|
-
- Pass `key` for retry-safe idempotent manual triggering. Without `key`, repeated calls create additional runs.
|
|
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.
|
|
375
309
|
|
|
376
310
|
## Ephemeral
|
|
377
311
|
|
|
378
|
-
|
|
312
|
+
TTL-based key/value store with event stream. Each key expires independently. Useful for presence, heartbeats, or temporary state.
|
|
379
313
|
|
|
380
314
|
```ts
|
|
381
315
|
import { z } from "zod";
|
|
@@ -395,49 +329,121 @@ console.log(snap.entries.length, snap.cursor);
|
|
|
395
329
|
|
|
396
330
|
for await (const event of presence.reader({ after: snap.cursor }).stream()) {
|
|
397
331
|
console.log(event.type);
|
|
332
|
+
// "upsert" | "touch" | "delete" | "expire" | "overflow"
|
|
398
333
|
}
|
|
399
334
|
```
|
|
400
335
|
|
|
401
|
-
|
|
336
|
+
`snapshot()` returns entries + cursor for handoff into `reader().stream()`. Optional `tenantId` for keyspace isolation.
|
|
402
337
|
|
|
403
|
-
|
|
404
|
-
- **Bounded capacity**: configurable `maxEntries` and payload-size limits.
|
|
405
|
-
- **Typed values**: schema validation on write and read-path parsing.
|
|
406
|
-
- **Change stream**: emits `upsert`, `touch`, `delete`, `expire`, and `overflow` events.
|
|
407
|
-
- **Snapshot + cursor**: take a consistent snapshot and continue with stream replay.
|
|
408
|
-
- **Tenant isolation**: optional per-operation `tenantId` for keyspace isolation.
|
|
409
|
-
- **Transport resilience in stream loops**: `reader().stream({ wait: true })` auto-retries transient transport errors by default.
|
|
338
|
+
## Retry
|
|
410
339
|
|
|
411
|
-
|
|
340
|
+
Retry helper with exponential backoff for transient Redis/network errors.
|
|
412
341
|
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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 }
|
|
355
|
+
```
|
|
356
|
+
|
|
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
|
+
---
|
|
360
|
+
|
|
361
|
+
## Browser
|
|
362
|
+
|
|
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
|
+
});
|
|
419
412
|
```
|
|
420
413
|
|
|
421
|
-
|
|
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
|
+
```
|
|
422
424
|
|
|
423
|
-
|
|
425
|
+
---
|
|
424
426
|
|
|
425
|
-
|
|
427
|
+
## Development
|
|
426
428
|
|
|
427
429
|
```bash
|
|
428
430
|
git clone https://github.com/valentinkolb/sync.git
|
|
429
|
-
cd sync
|
|
430
|
-
bun install
|
|
431
|
+
cd sync && bun install
|
|
431
432
|
```
|
|
432
433
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
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:
|
|
436
435
|
|
|
437
436
|
```bash
|
|
438
437
|
docker run -d --name valkey -p 6399:6379 valkey/valkey:latest
|
|
439
|
-
bun test --preload ./tests/preload.ts
|
|
440
|
-
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)
|
|
441
447
|
```
|
|
442
448
|
|
|
443
449
|
### Project structure
|
|
@@ -448,30 +454,28 @@ src/
|
|
|
448
454
|
mutex.ts # Distributed lock
|
|
449
455
|
queue.ts # Durable work queue
|
|
450
456
|
topic.ts # Pub/sub with consumer groups
|
|
451
|
-
job.ts # Job processing (
|
|
452
|
-
scheduler.ts # Distributed cron
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
skills/ # Modular agent skills + per-feature API references
|
|
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
|
|
465
470
|
```
|
|
466
471
|
|
|
467
472
|
### Guidelines
|
|
468
473
|
|
|
469
|
-
-
|
|
470
|
-
-
|
|
471
|
-
- 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.
|
|
472
476
|
- All modules follow the `moduleName({ id, ...config })` factory pattern.
|
|
473
|
-
-
|
|
474
|
-
-
|
|
477
|
+
- Validate at boundaries, trust internal data.
|
|
478
|
+
- Server tests go in `tests/`. Browser tests go in `tests/browser/`.
|
|
475
479
|
|
|
476
480
|
## License
|
|
477
481
|
|