@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 CHANGED
@@ -1,29 +1,15 @@
1
1
  # @valentinkolb/sync
2
2
 
3
- Distributed synchronization primitives for Bun and TypeScript, backed by Redis.
3
+ Synchronization primitives for TypeScript available in two flavors:
4
4
 
5
- ## Philosophy
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
- - **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** - 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
- ## Features
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
- - **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
- - **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
- ## Retry Utility
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
- ### Install Agent Skills (optional)
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. Atomic via Lua script.
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 + jitter, TTL auto-expiry, and Lua-based owner-only release.
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
- ### Queue features
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. Supports consumer groups (at-least-once, load-balanced) and live streaming (best-effort, all events).
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
- ### Topic features
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. Supports retries with backoff, cancellation, event sourcing, and graceful shutdown.
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
- ### Job features
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 for horizontally scaled apps. Registration is idempotent per schedule `id`; one active leader dispatches due slots and submits durable jobs.
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", // default: do not replay backlog
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 but recommended for retry-safe manual triggers
263
+ key: "ops-manual-run-1", // optional: idempotent manual trigger
360
264
  });
361
265
  ```
362
266
 
363
- ### Scheduler features
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 service/config registry with exact-key reads, native prefix listing, compare-and-swap, optional TTL-backed liveness, and stream-based change notifications.
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
- ### Registry features
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
- Typed ephemeral key/value store with TTL semantics and stream events. Useful for short-lived state like presence, worker heartbeats, or temporary coordination hints.
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
- ### Ephemeral features
336
+ `snapshot()` returns entries + cursor for handoff into `reader().stream()`. Optional `tenantId` for keyspace isolation.
454
337
 
455
- - **TTL-first model**: each key has an independent expiration and can be extended with `touch()`.
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
- ## Testing
340
+ Retry helper with exponential backoff for transient Redis/network errors.
464
341
 
465
- ```bash
466
- bun test --preload ./tests/preload.ts
467
- # fault-tolerance suites
468
- bun run test:fault
469
- # all test files
470
- bun run test:all
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
- Requires a Redis-compatible server on `localhost:6399` (configured in `tests/preload.ts`).
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
- ## Contributing
361
+ ## Browser
476
362
 
477
- ### Setup
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
- ### Running tests
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 (composes queue + topic)
504
- scheduler.ts # Distributed cron scheduler (durable dispatch via job)
505
- registry.ts # Typed registry with prefix queries, CAS, and liveness
506
- ephemeral.ts # TTL-based ephemeral store with event stream
507
- retry.ts # Generic transport-aware retry utility
508
- internal/
509
- cron.ts # Cron parsing/next timestamp + timezone validation
510
- job-utils.ts # Job helper functions (retry, timeout, parsing)
511
- topic-utils.ts # Stream entry parsing helpers
512
- tests/
513
- *.test.ts # Integration tests (require Redis)
514
- *-utils.unit.test.ts # Pure unit tests
515
- preload.ts # Sets REDIS_URL for test environment
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
- - Keep it minimal. No abstractions for one-time operations.
523
- - Every Redis mutation must be in a Lua script for atomicity.
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
- - 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.
527
- - Run `bun test --preload ./tests/preload.ts` before submitting a PR. All tests must pass.
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