@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 CHANGED
@@ -1,28 +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** - 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
- ## 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
- - **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
- ## 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(...)`.
20
+ Server modules use Redis for state. Browser modules use an in-memory store — no Redis needed.
72
21
 
73
- Requires [Bun](https://bun.sh) and a Redis-compatible server (Redis 6.2+, Valkey, Dragonfly).
22
+ ### Agent Skills (optional)
74
23
 
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:
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. Atomic via Lua script.
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 + jitter, TTL auto-expiry, and Lua-based owner-only release.
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
- ### Queue features
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. 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.
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
- ### Topic features
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. 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.
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
- ### Job features
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 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.
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", // default: do not replay backlog
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 but recommended for retry-safe manual triggers
263
+ key: "ops-manual-run-1", // optional: idempotent manual trigger
358
264
  });
359
265
  ```
360
266
 
361
- ### Scheduler features
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
- - **Idempotent upsert registration**: repeated `register({ id, ... })` creates once, then updates in place.
364
- - **No fixed leader pod**: leadership uses a renewable Redis lease (`mutex`) with epoch fencing.
365
- - **Durable dispatch**: each cron slot maps to deterministic job key (`scheduleId:slotTs`) to prevent duplicates.
366
- - **Durable manual trigger**: `triggerNow({ id, key? })` submits immediately through the same durable job path. Durability begins once `triggerNow()` returns a `jobId`.
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
- Manual trigger notes:
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
- 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.
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
- ### Ephemeral features
336
+ `snapshot()` returns entries + cursor for handoff into `reader().stream()`. Optional `tenantId` for keyspace isolation.
402
337
 
403
- - **TTL-first model**: each key has an independent expiration and can be extended with `touch()`.
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
- ## Testing
340
+ Retry helper with exponential backoff for transient Redis/network errors.
412
341
 
413
- ```bash
414
- bun test --preload ./tests/preload.ts
415
- # fault-tolerance suites
416
- bun run test:fault
417
- # all test files
418
- 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 }
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
- Requires a Redis-compatible server on `localhost:6399` (configured in `tests/preload.ts`).
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
- ## Contributing
425
+ ---
424
426
 
425
- ### Setup
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
- ### Running tests
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 (composes queue + topic)
452
- scheduler.ts # Distributed cron scheduler (durable dispatch via job)
453
- ephemeral.ts # TTL-based ephemeral store with event stream
454
- retry.ts # Generic transport-aware retry utility
455
- internal/
456
- cron.ts # Cron parsing/next timestamp + timezone validation
457
- job-utils.ts # Job helper functions (retry, timeout, parsing)
458
- topic-utils.ts # Stream entry parsing helpers
459
- tests/
460
- *.test.ts # Integration tests (require Redis)
461
- *-utils.unit.test.ts # Pure unit tests
462
- preload.ts # Sets REDIS_URL for test environment
463
- index.ts # Public API exports
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
- - Keep it minimal. No abstractions for one-time operations.
470
- - Every Redis mutation must be in a Lua script for atomicity.
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
- - 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.
474
- - 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/`.
475
479
 
476
480
  ## License
477
481