svelte-adapter-uws-extensions 0.4.2 → 0.5.0-next.1

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.
Files changed (46) hide show
  1. package/README.md +1996 -1045
  2. package/package.json +39 -3
  3. package/postgres/_tasks-errors.js +76 -0
  4. package/postgres/_tasks-sql.js +214 -0
  5. package/postgres/_tasks-worker-pool.js +183 -0
  6. package/postgres/_worker-harness.js +82 -0
  7. package/postgres/idempotency.d.ts +41 -0
  8. package/postgres/idempotency.js +265 -0
  9. package/postgres/jobs.d.ts +79 -0
  10. package/postgres/jobs.js +266 -0
  11. package/postgres/notify.d.ts +31 -0
  12. package/postgres/notify.js +345 -242
  13. package/postgres/replay.d.ts +21 -1
  14. package/postgres/replay.js +72 -50
  15. package/postgres/tasks.d.ts +175 -0
  16. package/postgres/tasks.js +622 -0
  17. package/redis/cursor.js +654 -695
  18. package/redis/fence.d.ts +31 -0
  19. package/redis/fence.js +125 -0
  20. package/redis/functions.d.ts +50 -0
  21. package/redis/functions.js +136 -0
  22. package/redis/groups.js +30 -36
  23. package/redis/idempotency.d.ts +58 -0
  24. package/redis/idempotency.js +182 -0
  25. package/redis/presence.d.ts +31 -0
  26. package/redis/presence.js +417 -376
  27. package/redis/pubsub.d.ts +20 -0
  28. package/redis/pubsub.js +51 -0
  29. package/redis/ratelimit.js +10 -57
  30. package/redis/replay-stream.js +351 -0
  31. package/redis/replay.d.ts +110 -0
  32. package/redis/replay.js +100 -68
  33. package/redis/sharded-pubsub.d.ts +75 -0
  34. package/redis/sharded-pubsub.js +341 -0
  35. package/shared/admission.d.ts +53 -0
  36. package/shared/admission.js +133 -0
  37. package/shared/breaker.d.ts +8 -0
  38. package/shared/breaker.js +174 -160
  39. package/shared/pg-migrate.js +18 -0
  40. package/shared/redis-scan.js +21 -0
  41. package/shared/redis-version.js +15 -0
  42. package/shared/replay-helpers.js +105 -0
  43. package/shared/sensitive.js +79 -0
  44. package/testing/mock-pg.js +530 -11
  45. package/testing/mock-platform.js +20 -0
  46. package/testing/mock-redis.js +349 -2
package/README.md CHANGED
@@ -1,1045 +1,1996 @@
1
- # svelte-adapter-uws-extensions
2
-
3
- Redis and Postgres extensions for [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws).
4
-
5
- The core adapter keeps everything in-process memory. That works great for single-server deployments, but the moment you scale to multiple instances you need shared state. This package provides drop-in replacements backed by Redis and Postgres, with the same API shapes you already know from the core plugins.
6
-
7
- ## What you get
8
-
9
- - **Distributed pub/sub** - `platform.publish()` reaches all instances, not just the local one
10
- - **Persistent replay buffers** - messages survive restarts, backed by Redis sorted sets or a Postgres table
11
- - **Cross-instance presence** - who's online across your entire fleet, with multi-tab dedup
12
- - **Distributed rate limiting** - token bucket enforced across all instances via atomic Lua script
13
- - **Distributed broadcast groups** - named groups with membership and roles that span instances
14
- - **Shared cursor state** - ephemeral positions (cursors, selections, drawing strokes) visible across instances
15
- - **Database change notifications** - Postgres LISTEN/NOTIFY forwarded straight to WebSocket clients
16
- - **Prometheus metrics** - expose extension metrics for scraping, zero overhead when disabled
17
-
18
- ---
19
-
20
- ## Table of contents
21
-
22
- **Getting started**
23
- - [Installation](#installation)
24
-
25
- **Clients**
26
- - [Redis client](#redis-client)
27
- - [Postgres client](#postgres-client)
28
-
29
- **Redis extensions**
30
- - [Pub/sub bus](#pubsub-bus)
31
- - [Replay buffer (Redis)](#replay-buffer-redis)
32
- - [Presence](#presence)
33
- - [Rate limiting](#rate-limiting)
34
- - [Broadcast groups](#broadcast-groups)
35
- - [Cursor](#cursor)
36
-
37
- **Postgres extensions**
38
- - [Replay buffer (Postgres)](#replay-buffer-postgres)
39
- - [LISTEN/NOTIFY bridge](#listennotify-bridge)
40
-
41
- **Observability**
42
- - [Prometheus metrics](#prometheus-metrics)
43
-
44
- **Reliability**
45
- - [Failure handling](#failure-handling)
46
- - [Circuit breaker](#circuit-breaker)
47
-
48
- **Operations**
49
- - [Graceful shutdown](#graceful-shutdown)
50
- - [Testing](#testing)
51
-
52
- **More**
53
- - [Related projects](#related-projects)
54
- - [License](#license)
55
-
56
- ---
57
-
58
- **Getting started**
59
-
60
- ## Installation
61
-
62
- ```bash
63
- npm install svelte-adapter-uws-extensions ioredis
64
- ```
65
-
66
- Postgres support is optional:
67
-
68
- ```bash
69
- npm install pg
70
- ```
71
-
72
- Requires `svelte-adapter-uws >= 0.2.0` as a peer dependency.
73
-
74
- ---
75
-
76
- **Clients**
77
-
78
- ## Redis client
79
-
80
- Factory that wraps [ioredis](https://github.com/redis/ioredis) with lifecycle management. All Redis extensions accept this client.
81
-
82
- ```js
83
- // src/lib/server/redis.js
84
- import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
85
-
86
- export const redis = createRedisClient({
87
- url: 'redis://localhost:6379',
88
- keyPrefix: 'myapp:' // optional, prefixes all keys
89
- });
90
- ```
91
-
92
- #### Options
93
-
94
- | Option | Default | Description |
95
- |---|---|---|
96
- | `url` | `'redis://localhost:6379'` | Redis connection URL |
97
- | `keyPrefix` | `''` | Prefix for all keys |
98
- | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
99
- | `options` | `{}` | Extra ioredis options |
100
-
101
- #### API
102
-
103
- | Method | Description |
104
- |---|---|
105
- | `redis.redis` | The underlying ioredis instance |
106
- | `redis.key(k)` | Returns `keyPrefix + k` |
107
- | `redis.duplicate(overrides?)` | New connection with same config. Pass ioredis options to override defaults. |
108
- | `redis.quit()` | Gracefully disconnect all connections |
109
-
110
- ---
111
-
112
- ## Postgres client
113
-
114
- Factory that wraps [pg](https://github.com/brianc/node-postgres) Pool with lifecycle management.
115
-
116
- ```js
117
- // src/lib/server/pg.js
118
- import { createPgClient } from 'svelte-adapter-uws-extensions/postgres';
119
-
120
- export const pg = createPgClient({
121
- connectionString: 'postgres://localhost:5432/mydb'
122
- });
123
- ```
124
-
125
- #### Options
126
-
127
- | Option | Default | Description |
128
- |---|---|---|
129
- | `connectionString` | *required* | Postgres connection string |
130
- | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
131
- | `options` | `{}` | Extra pg Pool options |
132
-
133
- #### API
134
-
135
- | Method | Description |
136
- |---|---|
137
- | `pg.pool` | The underlying pg Pool |
138
- | `pg.query(text, values?)` | Run a query |
139
- | `pg.createClient()` | New standalone pg.Client with same config (not from the pool) |
140
- | `pg.end()` | Gracefully close the pool |
141
-
142
- ---
143
-
144
- **Redis extensions**
145
-
146
- ## Pub/sub bus
147
-
148
- Distributes `platform.publish()` calls across multiple server instances via Redis pub/sub. Each instance publishes locally AND to Redis. Incoming Redis messages are forwarded to the local platform with echo suppression (messages originating from the same instance are dropped on receive, keyed by a per-process instance ID).
149
-
150
- Multiple `publish()` calls within the same event-loop tick are coalesced into a single Redis pipeline via microtask batching. This means a form action that publishes to three topics results in one pipelined round trip, not three independent commands.
151
-
152
- #### Setup
153
-
154
- ```js
155
- // src/lib/server/bus.js
156
- import { redis } from './redis.js';
157
- import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
158
-
159
- export const bus = createPubSubBus(redis);
160
- ```
161
-
162
- #### Usage
163
-
164
- ```js
165
- // src/hooks.ws.js
166
- import { bus } from '$lib/server/bus';
167
-
168
- let distributed;
169
-
170
- export function open(ws, { platform }) {
171
- // Start subscriber (idempotent, only subscribes once)
172
- bus.activate(platform);
173
- // Get a wrapped platform that publishes to Redis + local
174
- distributed = bus.wrap(platform);
175
- }
176
-
177
- export function message(ws, { data, platform }) {
178
- const msg = JSON.parse(Buffer.from(data).toString());
179
- // This publish reaches local clients AND all other instances
180
- distributed.publish('chat', 'message', msg);
181
- }
182
- ```
183
-
184
- #### Options
185
-
186
- | Option | Default | Description |
187
- |---|---|---|
188
- | `channel` | `'uws:pubsub'` | Redis channel name |
189
-
190
- #### API
191
-
192
- | Method | Description |
193
- |---|---|
194
- | `bus.wrap(platform)` | Returns a new Platform whose `publish()` sends to Redis + local |
195
- | `bus.activate(platform)` | Start the Redis subscriber (idempotent) |
196
- | `bus.deactivate()` | Stop the subscriber |
197
-
198
- ---
199
-
200
- ## Replay buffer (Redis)
201
-
202
- Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
203
-
204
- Sequence numbers are incremented atomically via a Lua script (`INCR` + `ZADD` + trim in a single `EVAL`), so concurrent publishes from multiple instances produce strictly ordered, gap-free sequences per topic. When the buffer exceeds `size`, the oldest entries are removed inside the same Lua script -- no second round trip required.
205
-
206
- When a client requests replay, the buffer checks whether the client's last-seen sequence is older than the oldest buffered entry. If it is (the buffer was trimmed past the client's position), a `truncated` event fires on `__replay:{topic}` before any `msg` events, so the client knows it missed messages and can do a full reload. This also fires when the buffer is completely empty but the sequence counter has advanced past the client's position (e.g. all entries expired via TTL).
207
-
208
- #### Setup
209
-
210
- ```js
211
- // src/lib/server/replay.js
212
- import { redis } from './redis.js';
213
- import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
214
-
215
- export const replay = createReplay(redis, {
216
- size: 500,
217
- ttl: 3600 // expire after 1 hour
218
- });
219
- ```
220
-
221
- #### Usage
222
-
223
- ```js
224
- // In a form action or API route
225
- export const actions = {
226
- send: async ({ request, platform }) => {
227
- const data = Object.fromEntries(await request.formData());
228
- const msg = await db.createMessage(data);
229
- await replay.publish(platform, 'chat', 'created', msg);
230
- }
231
- };
232
- ```
233
-
234
- ```js
235
- // In +page.server.js
236
- export async function load() {
237
- const messages = await db.getRecentMessages();
238
- return { messages, seq: await replay.seq('chat') };
239
- }
240
- ```
241
-
242
- ```js
243
- // In hooks.ws.js - handle replay requests
244
- export async function message(ws, { data, platform }) {
245
- const msg = JSON.parse(Buffer.from(data).toString());
246
- if (msg.type === 'replay') {
247
- await replay.replay(ws, msg.topic, msg.since, platform);
248
- return;
249
- }
250
- }
251
- ```
252
-
253
- #### Options
254
-
255
- | Option | Default | Description |
256
- |---|---|---|
257
- | `size` | `1000` | Max messages per topic |
258
- | `ttl` | `0` | Key expiry in seconds (0 = never) |
259
-
260
- #### API
261
-
262
- All methods are async (they hit Redis). The API otherwise matches the core plugin exactly:
263
-
264
- | Method | Description |
265
- |---|---|
266
- | `publish(platform, topic, event, data)` | Store + broadcast |
267
- | `seq(topic)` | Current sequence number |
268
- | `since(topic, seq)` | Messages after a sequence |
269
- | `replay(ws, topic, sinceSeq, platform)` | Send missed messages to one client |
270
- | `clear()` | Delete all replay data |
271
- | `clearTopic(topic)` | Delete replay data for one topic |
272
-
273
- ---
274
-
275
- ## Presence
276
-
277
- Same API as the core `createPresence` plugin, but backed by Redis hashes. Presence state is shared across instances with cross-instance join/leave notifications via Redis pub/sub.
278
-
279
- Joins are staged with full rollback on failure: local state is set up first, then the Redis hash field is written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone -- local maps, the Redis field, and any broadcast join event are reversed. This prevents ghost entries that would show a user as online when they never fully connected.
280
-
281
- Leaves use an atomic Lua script (`LEAVE_SCRIPT`) that removes this instance's field from the hash and then scans remaining fields for the same user key, ignoring stale entries. Leave is only broadcast when no other instance holds a live entry for that user, preventing premature "user left" notifications in multi-instance deployments.
282
-
283
- Zombie cleanup runs on the heartbeat interval. Each tick, every tracked WebSocket is probed via `getBufferedAmount()` -- if the call throws, the socket is dead and its presence is removed synchronously before the heartbeat writes to Redis. The heartbeat then refreshes timestamps on all live entries via a Redis pipeline and runs a server-side Lua cleanup script (`CLEANUP_SCRIPT`) that scans the hash and removes any fields whose timestamp exceeds the TTL. This handles crashed instances whose close handlers never fired.
284
-
285
- #### Setup
286
-
287
- ```js
288
- // src/lib/server/presence.js
289
- import { redis } from './redis.js';
290
- import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
291
-
292
- export const presence = createPresence(redis, {
293
- key: 'id',
294
- select: (userData) => ({ id: userData.id, name: userData.name }),
295
- heartbeat: 30000,
296
- ttl: 90
297
- });
298
- ```
299
-
300
- #### Usage
301
-
302
- ```js
303
- // src/hooks.ws.js
304
- import { presence } from '$lib/server/presence';
305
-
306
- export async function subscribe(ws, topic, { platform }) {
307
- await presence.join(ws, topic, platform);
308
- }
309
-
310
- export async function close(ws, { platform }) {
311
- await presence.leave(ws, platform);
312
- }
313
- ```
314
-
315
- #### Options
316
-
317
- | Option | Default | Description |
318
- |---|---|---|
319
- | `key` | `'id'` | Field for user dedup (multi-tab) |
320
- | `select` | strips `__`-prefixed keys | Extract public fields from userData |
321
- | `heartbeat` | `30000` | TTL refresh interval in ms |
322
- | `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
323
-
324
- #### API
325
-
326
- | Method | Description |
327
- |---|---|
328
- | `join(ws, topic, platform)` | Add connection to presence |
329
- | `leave(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
330
- | `sync(ws, topic, platform)` | Send list without joining |
331
- | `list(topic)` | Get current users |
332
- | `count(topic)` | Count unique users |
333
- | `clear()` | Reset all presence state |
334
- | `destroy()` | Stop heartbeat and subscriber |
335
- | `hooks` | `{ subscribe, close }` -- ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
336
-
337
- #### Zero-config hooks
338
-
339
- Instead of writing `subscribe` and `close` handlers manually, destructure `presence.hooks`:
340
-
341
- ```js
342
- // src/hooks.ws.js
343
- import { presence } from '$lib/server/presence';
344
- export const { subscribe, close } = presence.hooks;
345
- ```
346
-
347
- `subscribe` handles both regular topics (calls `join`) and `__presence:*` topics (calls `sync` so the client gets the current list). `close` calls `leave`.
348
-
349
- If you need custom logic (auth gating, logging), wrap the hooks:
350
-
351
- ```js
352
- import { presence } from '$lib/server/presence';
353
-
354
- export async function subscribe(ws, topic, ctx) {
355
- if (!ctx.platform.getUserData(ws).authenticated) return;
356
- await presence.hooks.subscribe(ws, topic, ctx);
357
- }
358
-
359
- export const { close } = presence.hooks;
360
- ```
361
-
362
- ---
363
-
364
- ## Rate limiting
365
-
366
- Same API as the core `createRateLimit` plugin, but backed by Redis using an atomic Lua script. Rate limits are enforced across all server instances with exactly one Redis roundtrip per `consume()` call.
367
-
368
- #### Setup
369
-
370
- ```js
371
- // src/lib/server/ratelimit.js
372
- import { redis } from './redis.js';
373
- import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
374
-
375
- export const limiter = createRateLimit(redis, {
376
- points: 10,
377
- interval: 1000,
378
- blockDuration: 30000
379
- });
380
- ```
381
-
382
- #### Usage
383
-
384
- ```js
385
- // src/hooks.ws.js
386
- import { limiter } from '$lib/server/ratelimit';
387
-
388
- export async function message(ws, { data, platform }) {
389
- const { allowed } = await limiter.consume(ws);
390
- if (!allowed) return; // drop the message
391
- // ... handle message
392
- }
393
- ```
394
-
395
- #### Options
396
-
397
- | Option | Default | Description |
398
- |---|---|---|
399
- | `points` | *required* | Tokens available per interval |
400
- | `interval` | *required* | Refill interval in ms |
401
- | `blockDuration` | `0` | Auto-ban duration in ms (0 = no ban) |
402
- | `keyBy` | `'ip'` | `'ip'`, `'connection'`, or a function |
403
-
404
- #### API
405
-
406
- All methods are async (they hit Redis). The API otherwise matches the core plugin:
407
-
408
- | Method | Description |
409
- |---|---|
410
- | `consume(ws, cost?)` | Attempt to consume tokens. `cost` must be a positive integer. |
411
- | `reset(key)` | Clear the bucket for a key |
412
- | `ban(key, duration?)` | Manually ban a key |
413
- | `unban(key)` | Remove a ban |
414
- | `clear()` | Reset all state |
415
-
416
- ---
417
-
418
- ## Broadcast groups
419
-
420
- Same API as the core `createGroup` plugin, but membership is stored in Redis so groups work across multiple server instances. Local WebSocket tracking is maintained per-instance, and cross-instance events are relayed via Redis pub/sub.
421
-
422
- #### Setup
423
-
424
- ```js
425
- // src/lib/server/lobby.js
426
- import { redis } from './redis.js';
427
- import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
428
-
429
- export const lobby = createGroup(redis, 'lobby', {
430
- maxMembers: 50,
431
- meta: { game: 'chess' }
432
- });
433
- ```
434
-
435
- Note: the API signature is `createGroup(client, name, options)` instead of `createGroup(name, options)` -- the Redis client is the first argument.
436
-
437
- #### Usage
438
-
439
- ```js
440
- // src/hooks.ws.js
441
- import { lobby } from '$lib/server/lobby';
442
-
443
- export async function subscribe(ws, topic, { platform }) {
444
- if (topic === 'lobby') await lobby.join(ws, platform);
445
- }
446
-
447
- export async function close(ws, { platform }) {
448
- await lobby.leave(ws, platform);
449
- }
450
- ```
451
-
452
- #### Options
453
-
454
- | Option | Default | Description |
455
- |---|---|---|
456
- | `maxMembers` | `Infinity` | Maximum members allowed (enforced atomically) |
457
- | `meta` | `{}` | Initial group metadata |
458
- | `memberTtl` | `120` | Member entry TTL in seconds. Entries from crashed instances expire after this period. |
459
- | `onJoin` | - | Called after a member joins |
460
- | `onLeave` | - | Called after a member leaves |
461
- | `onFull` | - | Called when a join is rejected (full) |
462
- | `onClose` | - | Called when the group is closed |
463
-
464
- #### API
465
-
466
- | Method | Description |
467
- |---|---|
468
- | `join(ws, platform, role?)` | Add a member (returns false if full/closed) |
469
- | `leave(ws, platform)` | Remove a member |
470
- | `publish(platform, event, data, role?)` | Broadcast to all or filter by role |
471
- | `send(platform, ws, event, data)` | Send to a single member |
472
- | `localMembers()` | Members on this instance |
473
- | `count()` | Total members across all instances |
474
- | `has(ws)` | Check membership on this instance |
475
- | `getMeta()` / `setMeta(meta)` | Read/write group metadata |
476
- | `close(platform)` | Dissolve the group |
477
- | `destroy()` | Stop the Redis subscriber |
478
-
479
- ---
480
-
481
- ## Cursor
482
-
483
- Same API as the core `createCursor` plugin, but cursor positions are shared across instances via Redis. Each instance throttles locally (same leading/trailing edge logic as the core), then relays broadcasts through Redis pub/sub so subscribers on other instances see cursor updates.
484
-
485
- Hash entries have a TTL so stale cursors from crashed instances get cleaned up automatically.
486
-
487
- #### Setup
488
-
489
- ```js
490
- // src/lib/server/cursors.js
491
- import { redis } from './redis.js';
492
- import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
493
-
494
- export const cursors = createCursor(redis, {
495
- throttle: 50,
496
- select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color }),
497
- ttl: 30
498
- });
499
- ```
500
-
501
- #### Usage
502
-
503
- ```js
504
- // src/hooks.ws.js
505
- import { cursors } from '$lib/server/cursors';
506
-
507
- export function message(ws, { data, platform }) {
508
- const msg = JSON.parse(Buffer.from(data).toString());
509
- if (msg.type === 'cursor') {
510
- cursors.update(ws, msg.topic, msg.position, platform);
511
- }
512
- }
513
-
514
- export function close(ws, { platform }) {
515
- cursors.remove(ws, platform);
516
- }
517
- ```
518
-
519
- #### Options
520
-
521
- | Option | Default | Description |
522
- |---|---|---|
523
- | `throttle` | `50` | Minimum ms between broadcasts per user per topic |
524
- | `select` | strips `__`-prefixed keys | Extract user data to broadcast alongside position |
525
- | `ttl` | `30` | Per-entry TTL in seconds (auto-refreshed on each broadcast). Stale entries from crashed instances are filtered out individually, even if other instances are still active on the same topic. |
526
-
527
- #### API
528
-
529
- | Method | Description |
530
- |---|---|
531
- | `update(ws, topic, data, platform)` | Broadcast cursor position (throttled per user per topic) |
532
- | `remove(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
533
- | `list(topic)` | Get current positions across all instances |
534
- | `clear()` | Reset all local and Redis state |
535
- | `destroy()` | Stop the Redis subscriber and clear timers |
536
-
537
- ---
538
-
539
- **Postgres extensions**
540
-
541
- ## Replay buffer (Postgres)
542
-
543
- Same API as the Redis replay buffer, but backed by a Postgres table. Best suited for durable audit trails or history that needs to survive longer than Redis TTLs. Sequence numbers are generated atomically via a dedicated `_seq` table using `INSERT ... ON CONFLICT DO UPDATE`, so concurrent publishes from multiple instances produce strictly ordered sequences with no duplicates or gaps.
544
-
545
- Buffer trimming runs after each publish by deleting rows with `seq <= currentSeq - maxSize`. If the trim query fails, the publish still succeeds -- the periodic background cleanup (configurable via `cleanupInterval`) catches any excess rows later.
546
-
547
- Same gap detection behavior as the Redis replay buffer: if the client's last-seen sequence is older than the oldest buffered row, or the buffer is empty but the sequence counter has advanced, a `truncated` event fires before replay.
548
-
549
- #### Setup
550
-
551
- ```js
552
- // src/lib/server/replay.js
553
- import { pg } from './pg.js';
554
- import { createReplay } from 'svelte-adapter-uws-extensions/postgres/replay';
555
-
556
- export const replay = createReplay(pg, {
557
- table: 'ws_replay',
558
- size: 1000,
559
- ttl: 86400, // 24 hours
560
- autoMigrate: true // auto-create table
561
- });
562
- ```
563
-
564
- #### Schema
565
-
566
- The table is created automatically on first use (if `autoMigrate` is true):
567
-
568
- ```sql
569
- CREATE TABLE IF NOT EXISTS ws_replay (
570
- id BIGSERIAL PRIMARY KEY,
571
- topic TEXT NOT NULL,
572
- seq BIGINT NOT NULL,
573
- event TEXT NOT NULL,
574
- data JSONB,
575
- created_at TIMESTAMPTZ DEFAULT now()
576
- );
577
- CREATE INDEX IF NOT EXISTS idx_ws_replay_topic_seq ON ws_replay (topic, seq);
578
-
579
- CREATE TABLE IF NOT EXISTS ws_replay_seq (
580
- topic TEXT PRIMARY KEY,
581
- seq BIGINT NOT NULL DEFAULT 0
582
- );
583
- ```
584
-
585
- #### Options
586
-
587
- | Option | Default | Description |
588
- |---|---|---|
589
- | `table` | `'ws_replay'` | Table name |
590
- | `size` | `1000` | Max messages per topic |
591
- | `ttl` | `0` | Row expiry in seconds (0 = never) |
592
- | `autoMigrate` | `true` | Auto-create table |
593
- | `cleanupInterval` | `60000` | Periodic cleanup interval in ms (0 to disable) |
594
-
595
- #### API
596
-
597
- Same as [Replay buffer (Redis)](#api-3), plus:
598
-
599
- | Method | Description |
600
- |---|---|
601
- | `destroy()` | Stop the cleanup timer |
602
-
603
- ---
604
-
605
- ## LISTEN/NOTIFY bridge
606
-
607
- Listens on a Postgres channel for notifications and forwards them to `platform.publish()`. You provide the trigger on your table -- this module handles the listening side.
608
-
609
- Uses a standalone connection (not from the pool) since LISTEN requires a persistent connection that stays open for the lifetime of the bridge.
610
-
611
- #### Setup
612
-
613
- ```js
614
- // src/lib/server/notify.js
615
- import { pg } from './pg.js';
616
- import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
617
-
618
- export const bridge = createNotifyBridge(pg, {
619
- channel: 'table_changes',
620
- parse: (payload) => {
621
- const row = JSON.parse(payload);
622
- return { topic: row.table, event: row.op, data: row.data };
623
- }
624
- });
625
- ```
626
-
627
- #### Usage
628
-
629
- ```js
630
- // src/hooks.ws.js
631
- import { bridge } from '$lib/server/notify';
632
-
633
- let activated = false;
634
-
635
- export function open(ws, { platform }) {
636
- if (!activated) {
637
- activated = true;
638
- bridge.activate(platform);
639
- }
640
- }
641
- ```
642
-
643
- #### Setting up the trigger
644
-
645
- Create a trigger function and attach it to your table:
646
-
647
- ```sql
648
- CREATE OR REPLACE FUNCTION notify_table_change() RETURNS trigger AS $$
649
- BEGIN
650
- PERFORM pg_notify('table_changes', json_build_object(
651
- 'table', TG_TABLE_NAME,
652
- 'op', lower(TG_OP),
653
- 'data', CASE TG_OP
654
- WHEN 'DELETE' THEN row_to_json(OLD)
655
- ELSE row_to_json(NEW)
656
- END
657
- )::text);
658
- RETURN COALESCE(NEW, OLD);
659
- END;
660
- $$ LANGUAGE plpgsql;
661
-
662
- CREATE TRIGGER messages_notify
663
- AFTER INSERT OR UPDATE OR DELETE ON messages
664
- FOR EACH ROW EXECUTE FUNCTION notify_table_change();
665
- ```
666
-
667
- Now any INSERT, UPDATE, or DELETE on the `messages` table will fire a notification. The bridge parses it and calls `platform.publish()`, which reaches all connected WebSocket clients subscribed to the topic.
668
-
669
- The client side needs no changes -- the core `crud('messages')` store already handles `created`, `updated`, and `deleted` events.
670
-
671
- #### Options
672
-
673
- | Option | Default | Description |
674
- |---|---|---|
675
- | `channel` | *required* | Postgres LISTEN channel name |
676
- | `parse` | JSON with `{ topic, event, data }` | Parse notification payload into a publish call. Return null to skip. |
677
- | `autoReconnect` | `true` | Reconnect on connection loss |
678
- | `reconnectInterval` | `3000` | ms between reconnect attempts |
679
-
680
- #### API
681
-
682
- | Method | Description |
683
- |---|---|
684
- | `activate(platform)` | Start listening (idempotent) |
685
- | `deactivate()` | Stop listening and release the connection |
686
-
687
- #### Limitations
688
-
689
- - **Payload is hard-limited to ~8000 bytes by Postgres** (`pg_notify` silently truncates or errors above this). This is a Postgres constraint, not a library limitation. The bridge warns at 7500 bytes. For large rows, send the row ID in the notification and let the client fetch the full row via an API call.
690
- - Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
691
- - This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
692
-
693
- #### When to use this instead of Redis pub/sub
694
-
695
- If your real-time events are driven by database writes and you do not need Redis for other extensions (presence, rate limiting, groups, cursors), the LISTEN/NOTIFY bridge is a simpler deployment: no Redis infrastructure, no separate pub/sub channel management, and your notifications are inherently tied to committed transactions. Use the Redis pub/sub bus when you need to broadcast events that do not originate from database writes, or when you are already running Redis for other extensions.
696
-
697
- ---
698
-
699
- **Observability**
700
-
701
- ## Prometheus metrics
702
-
703
- Exposes extension metrics in Prometheus text exposition format. No external dependencies. Zero overhead when not enabled -- every metric call uses optional chaining on a nullish reference, so V8 short-circuits on a single pointer check.
704
-
705
- #### Setup
706
-
707
- ```js
708
- // src/lib/server/metrics.js
709
- import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
710
-
711
- export const metrics = createMetrics({
712
- prefix: 'myapp_',
713
- mapTopic: (topic) => topic.startsWith('room:') ? 'room:*' : topic
714
- });
715
- ```
716
-
717
- Pass the `metrics` object to any extension via its options:
718
-
719
- ```js
720
- import { metrics } from './metrics.js';
721
- import { redis } from './redis.js';
722
- import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
723
- import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
724
- import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
725
- import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
726
- import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
727
- import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
728
-
729
- export const bus = createPubSubBus(redis, { metrics });
730
- export const presence = createPresence(redis, { metrics, key: 'id' });
731
- export const replay = createReplay(redis, { metrics });
732
- export const limiter = createRateLimit(redis, { points: 10, interval: 1000, metrics });
733
- export const lobby = createGroup(redis, 'lobby', { metrics });
734
- export const cursors = createCursor(redis, { metrics });
735
- ```
736
-
737
- #### Mounting the endpoint
738
-
739
- With uWebSockets.js:
740
-
741
- ```js
742
- app.get('/metrics', metrics.handler);
743
- ```
744
-
745
- Or use `metrics.serialize()` to get the raw text and serve it however you like.
746
-
747
- #### Options
748
-
749
- | Option | Default | Description |
750
- |---|---|---|
751
- | `prefix` | `''` | Prefix for all metric names |
752
- | `mapTopic` | identity | Map topic names to bounded label values for cardinality control |
753
- | `defaultBuckets` | `[1, 5, 10, 25, 50, 100, 250, 500, 1000]` | Default histogram buckets |
754
-
755
- Metric names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` and label names must match `[a-zA-Z_][a-zA-Z0-9_]*` (no `__` prefix). Invalid names throw at registration time. HELP text containing backslashes or newlines is escaped automatically.
756
-
757
- #### Cardinality control
758
-
759
- If your topics are user-generated (e.g. `room:abc123`), per-topic labels will grow unbounded. Use `mapTopic` to collapse them:
760
-
761
- ```js
762
- const metrics = createMetrics({
763
- mapTopic: (topic) => {
764
- if (topic.startsWith('room:')) return 'room:*';
765
- if (topic.startsWith('user:')) return 'user:*';
766
- return topic;
767
- }
768
- });
769
- ```
770
-
771
- #### Metrics reference
772
-
773
- **Pub/sub bus**
774
-
775
- | Metric | Type | Description |
776
- |---|---|---|
777
- | `pubsub_messages_relayed_total` | counter | Messages relayed to Redis |
778
- | `pubsub_messages_received_total` | counter | Messages received from Redis |
779
- | `pubsub_echo_suppressed_total` | counter | Messages dropped by echo suppression |
780
- | `pubsub_relay_batch_size` | histogram | Relay batch size per flush |
781
-
782
- **Presence**
783
-
784
- | Metric | Type | Labels | Description |
785
- |---|---|---|---|
786
- | `presence_joins_total` | counter | `topic` | Join events |
787
- | `presence_leaves_total` | counter | `topic` | Leave events |
788
- | `presence_heartbeats_total` | counter | | Heartbeat refresh cycles |
789
- | `presence_stale_cleaned_total` | counter | | Stale entries removed by cleanup |
790
-
791
- **Replay buffer (Redis and Postgres)**
792
-
793
- | Metric | Type | Labels | Description |
794
- |---|---|---|---|
795
- | `replay_publishes_total` | counter | `topic` | Messages published |
796
- | `replay_messages_replayed_total` | counter | `topic` | Messages replayed to clients |
797
- | `replay_truncations_total` | counter | `topic` | Truncation events detected |
798
-
799
- **Rate limiting**
800
-
801
- | Metric | Type | Description |
802
- |---|---|---|
803
- | `ratelimit_allowed_total` | counter | Requests allowed |
804
- | `ratelimit_denied_total` | counter | Requests denied |
805
- | `ratelimit_bans_total` | counter | Bans applied |
806
-
807
- **Broadcast groups**
808
-
809
- | Metric | Type | Labels | Description |
810
- |---|---|---|---|
811
- | `group_joins_total` | counter | `group` | Join events |
812
- | `group_joins_rejected_total` | counter | `group` | Joins rejected (full) |
813
- | `group_leaves_total` | counter | `group` | Leave events |
814
- | `group_publishes_total` | counter | `group` | Publish events |
815
-
816
- **Cursor**
817
-
818
- | Metric | Type | Labels | Description |
819
- |---|---|---|---|
820
- | `cursor_updates_total` | counter | `topic` | Cursor update calls |
821
- | `cursor_broadcasts_total` | counter | `topic` | Broadcasts actually sent |
822
- | `cursor_throttled_total` | counter | `topic` | Updates deferred by throttle |
823
-
824
- **LISTEN/NOTIFY bridge**
825
-
826
- | Metric | Type | Labels | Description |
827
- |---|---|---|---|
828
- | `notify_received_total` | counter | `channel` | Notifications received |
829
- | `notify_parse_errors_total` | counter | `channel` | Parse failures |
830
- | `notify_reconnects_total` | counter | | Reconnect attempts |
831
-
832
- ---
833
-
834
- **Reliability**
835
-
836
- ## Failure handling
837
-
838
- Every Redis and Postgres extension accepts an optional `breaker` option -- a shared [circuit breaker](#circuit-breaker) that tracks backend health across all extensions wired to it. When the breaker trips, each extension degrades differently depending on whether the operation is critical or best-effort:
839
-
840
- | Extension | Awaited operations (join, consume, publish) | Fire-and-forget operations |
841
- |---|---|---|
842
- | **Pub/sub bus** | `wrap().publish()` queues to local platform only; relay to Redis is skipped silently | Microtask relay flush is skipped entirely |
843
- | **Presence** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh and stale cleanup are skipped |
844
- | **Replay buffer** | `publish()` / `replay()` / `seq()` throw `CircuitBrokenError` | -- |
845
- | **Rate limiting** | `consume()` throws `CircuitBrokenError` (fail-closed -- requests are blocked, not allowed through) | -- |
846
- | **Broadcast groups** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh is skipped |
847
- | **Cursor** | -- | Hash writes and cross-instance relay are skipped; local throttle continues |
848
- | **LISTEN/NOTIFY** | `activate()` throws; auto-reconnect retries on its own interval | -- |
849
-
850
- The breaker is a three-state machine: **healthy** (all requests pass through) -> **broken** after N consecutive failures (all requests fail fast via `CircuitBrokenError`) -> **probing** after a timeout (one request is allowed through to test recovery) -> back to **healthy** on success. See [Circuit breaker](#circuit-breaker) for configuration.
851
-
852
- #### Notifying clients of degradation
853
-
854
- When Redis pub/sub fails, live streams on other replicas stop receiving updates. Connected clients continue showing stale data with no indication that the stream is degraded. Use the `onStateChange` callback to publish a system-level event so clients can surface this:
855
-
856
- ```js
857
- import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
858
-
859
- let distributed; // the bus.wrap(platform) reference
860
-
861
- export const breaker = createCircuitBreaker({
862
- failureThreshold: 5,
863
- resetTimeout: 30000,
864
- onStateChange: (from, to) => {
865
- if (!distributed) return;
866
- if (to === 'broken') {
867
- // Local-only publish -- Redis is down, but local clients still receive it
868
- distributed.publish('__system', 'degraded', { reason: 'backend unavailable' });
869
- } else if (from === 'broken' && to === 'healthy') {
870
- distributed.publish('__system', 'recovered', null);
871
- }
872
- }
873
- });
874
- ```
875
-
876
- On the client side, subscribe to `__system` and show a banner when the `degraded` event fires. On `recovered`, dismiss the banner and refetch stale data.
877
-
878
- ---
879
-
880
- ## Circuit breaker
881
-
882
- Prevents thundering herd when a backend goes down. When Redis or Postgres becomes unreachable, every extension that uses the breaker fails fast instead of queueing up timeouts, and fire-and-forget operations (heartbeats, relay flushes, cursor broadcasts) are skipped entirely.
883
-
884
- Three states:
885
- - **healthy** -- everything works, requests go through
886
- - **broken** -- too many failures, requests fail fast via `CircuitBrokenError`
887
- - **probing** -- one request is allowed through to test if the backend is back
888
-
889
- #### Setup
890
-
891
- ```js
892
- // src/lib/server/breaker.js
893
- import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
894
-
895
- export const breaker = createCircuitBreaker({
896
- failureThreshold: 5,
897
- resetTimeout: 30000,
898
- onStateChange: (from, to) => console.log(`circuit: ${from} -> ${to}`)
899
- });
900
- ```
901
-
902
- Pass the same breaker to all extensions that share a backend:
903
-
904
- ```js
905
- import { breaker } from './breaker.js';
906
-
907
- export const bus = createPubSubBus(redis, { breaker });
908
- export const presence = createPresence(redis, { breaker, key: 'id' });
909
- export const replay = createReplay(redis, { breaker });
910
- export const limiter = createRateLimit(redis, { points: 10, interval: 1000, breaker });
911
- ```
912
-
913
- Failures from any extension contribute to the same breaker. When one trips it, all others fail fast.
914
-
915
- #### Options
916
-
917
- | Option | Default | Description |
918
- |---|---|---|
919
- | `failureThreshold` | `5` | Consecutive failures before breaking |
920
- | `resetTimeout` | `30000` | Ms before transitioning from broken to probing |
921
- | `onStateChange` | - | Called on state transitions: `(from, to) => void` |
922
-
923
- #### API
924
-
925
- | Method / Property | Description |
926
- |---|---|
927
- | `breaker.state` | `'healthy'`, `'broken'`, or `'probing'` |
928
- | `breaker.isHealthy` | `true` only when state is `'healthy'` |
929
- | `breaker.failures` | Current consecutive failure count |
930
- | `breaker.guard()` | Throws `CircuitBrokenError` if the circuit is broken |
931
- | `breaker.success()` | Record a successful operation |
932
- | `breaker.failure()` | Record a failed operation |
933
- | `breaker.reset()` | Force back to healthy |
934
- | `breaker.destroy()` | Clear internal timers |
935
-
936
- #### How extensions use it
937
-
938
- Awaited operations (join, consume, publish) call `guard()` before the Redis/Postgres call, `success()` after, and `failure()` in the catch block. When the circuit is broken, `guard()` throws `CircuitBrokenError` and the operation never reaches the backend.
939
-
940
- Fire-and-forget operations (heartbeat refresh, relay flush, cursor broadcast) check `isHealthy` and skip entirely when the circuit is not healthy. This prevents piling up commands on a dead connection.
941
-
942
- #### Error handling
943
-
944
- ```js
945
- import { CircuitBrokenError } from 'svelte-adapter-uws-extensions/breaker';
946
-
947
- try {
948
- await replay.publish(platform, 'chat', 'msg', data);
949
- } catch (err) {
950
- if (err instanceof CircuitBrokenError) {
951
- // Backend is down -- degrade gracefully
952
- platform.publish('chat', 'msg', data); // local-only delivery
953
- }
954
- }
955
- ```
956
-
957
- ---
958
-
959
- **Operations**
960
-
961
- ## Graceful shutdown
962
-
963
- All clients listen for the `sveltekit:shutdown` event and disconnect cleanly by default. You can disable this with `autoShutdown: false` and manage the lifecycle yourself.
964
-
965
- ```js
966
- // Manual shutdown
967
- await redis.quit();
968
- await pg.end();
969
- presence.destroy();
970
- ```
971
-
972
- ---
973
-
974
- ## Testing
975
-
976
- ```bash
977
- npm test
978
- ```
979
-
980
- Tests use in-memory mocks for Redis and Postgres, no running services needed.
981
-
982
- ### Testing your own code
983
-
984
- The `svelte-adapter-uws-extensions/testing` entry point exports the same in-memory mocks used by the extensions' own test suite. Use them to test your extension-consuming code without running Redis or Postgres:
985
-
986
- ```js
987
- import { mockRedisClient, mockPlatform, mockWs } from 'svelte-adapter-uws-extensions/testing';
988
- import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
989
- import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
990
- import { describe, it, expect } from 'vitest';
991
-
992
- describe('presence', () => {
993
- it('tracks users across topics', async () => {
994
- const client = mockRedisClient();
995
- const platform = mockPlatform();
996
- const presence = createPresence(client, { key: 'id' });
997
-
998
- const ws = mockWs({ id: 'user-1', name: 'Alice' });
999
- await presence.join(ws, 'room:lobby', platform);
1000
-
1001
- expect(await presence.count('room:lobby')).toBe(1);
1002
- expect(platform.published.some(p => p.event === 'join')).toBe(true);
1003
-
1004
- presence.destroy();
1005
- });
1006
- });
1007
-
1008
- describe('rate limiting', () => {
1009
- it('blocks after exhausting points', async () => {
1010
- const client = mockRedisClient();
1011
- const limiter = createRateLimit(client, { points: 3, interval: 10000 });
1012
- const ws = mockWs({ remoteAddress: '1.2.3.4' });
1013
-
1014
- for (let i = 0; i < 3; i++) {
1015
- expect((await limiter.consume(ws)).allowed).toBe(true);
1016
- }
1017
- expect((await limiter.consume(ws)).allowed).toBe(false);
1018
- });
1019
- });
1020
- ```
1021
-
1022
- #### Available mocks
1023
-
1024
- | Export | What it mocks | Supports |
1025
- |---|---|---|
1026
- | `mockRedisClient(prefix?)` | `createRedisClient()` | Strings, hashes, sorted sets, pub/sub, pipelines, scan, Lua eval for all extension scripts |
1027
- | `mockPlatform()` | Platform API | `publish()`, `send()`, `batch()`, `topic()` -- records all calls in `.published` and `.sent` |
1028
- | `mockWs(userData?)` | uWS WebSocket | `subscribe()`, `unsubscribe()`, `getUserData()`, `getBufferedAmount()`, `close()` |
1029
- | `mockPgClient()` | `createPgClient()` | SQL parsing for replay buffer operations, sequence counters |
1030
-
1031
- The circuit breaker (`createCircuitBreaker()`) is pure logic with no I/O -- use it directly in tests, no mock needed.
1032
-
1033
- ---
1034
-
1035
- ## Related projects
1036
-
1037
- - [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws) -- The core adapter this package extends. Single-process WebSocket pub/sub, presence, replay, and more for SvelteKit on uWebSockets.js.
1038
- - [svelte-realtime](https://github.com/lanteanio/svelte-realtime) -- Opinionated full-stack starter built on the adapter. Auth, database, real-time CRUD, and deployment config out of the box.
1039
- - [svelte-realtime-demo](https://github.com/lanteanio/svelte-realtime-demo) -- Live demo of svelte-realtime. [Try it here.](https://svelte-realtime-demo.lantean.io/)
1040
-
1041
- ---
1042
-
1043
- ## License
1044
-
1045
- MIT
1
+ # svelte-adapter-uws-extensions
2
+
3
+ Redis and Postgres extensions for [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws).
4
+
5
+ The core adapter keeps everything in-process memory. That works great for single-server deployments, but the moment you scale to multiple instances you need shared state. This package provides drop-in replacements backed by Redis and Postgres, with the same API shapes you already know from the core plugins.
6
+
7
+ ## What you get
8
+
9
+ - **Distributed pub/sub** - `platform.publish()` reaches all instances, not just the local one
10
+ - **Persistent replay buffers** - messages survive restarts, backed by Redis sorted sets or a Postgres table
11
+ - **Cross-instance presence** - who's online across your entire fleet, with multi-tab dedup
12
+ - **Distributed rate limiting** - token bucket enforced across all instances via atomic Lua script
13
+ - **Distributed broadcast groups** - named groups with membership and roles that span instances
14
+ - **Shared cursor state** - ephemeral positions (cursors, selections, drawing strokes) visible across instances
15
+ - **Database change notifications** - Postgres LISTEN/NOTIFY forwarded straight to WebSocket clients
16
+ - **Prometheus metrics** - expose extension metrics for scraping, zero overhead when disabled
17
+
18
+ ---
19
+
20
+ ## Table of contents
21
+
22
+ **Getting started**
23
+ - [Installation](#installation)
24
+
25
+ **Clients**
26
+ - [Redis client](#redis-client)
27
+ - [Postgres client](#postgres-client)
28
+
29
+ **Redis extensions**
30
+ - [Pub/sub bus](#pubsub-bus)
31
+ - [Sharded pub/sub bus](#sharded-pubsub-bus)
32
+ - [Replay buffer (Redis)](#replay-buffer-redis)
33
+ - [Presence](#presence)
34
+ - [Rate limiting](#rate-limiting)
35
+ - [Broadcast groups](#broadcast-groups)
36
+ - [Cursor](#cursor)
37
+
38
+ **Postgres extensions**
39
+ - [Replay buffer (Postgres)](#replay-buffer-postgres)
40
+ - [LISTEN/NOTIFY bridge](#listennotify-bridge)
41
+ - [Job queue](#job-queue)
42
+
43
+ **Cross-backend**
44
+ - [Idempotency store](#idempotency-store)
45
+ - [Task runner](#task-runner)
46
+
47
+ **Observability**
48
+ - [Prometheus metrics](#prometheus-metrics)
49
+
50
+ **Reliability**
51
+ - [Failure handling](#failure-handling)
52
+ - [Circuit breaker](#circuit-breaker)
53
+ - [Admission control](#admission-control)
54
+ - [Redis Functions](#redis-functions)
55
+
56
+ **Operations**
57
+ - [Graceful shutdown](#graceful-shutdown)
58
+ - [Testing](#testing)
59
+
60
+ **More**
61
+ - [Related projects](#related-projects)
62
+ - [License](#license)
63
+
64
+ ---
65
+
66
+ **Getting started**
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ npm install svelte-adapter-uws-extensions ioredis
72
+ ```
73
+
74
+ Postgres support is optional:
75
+
76
+ ```bash
77
+ npm install pg
78
+ ```
79
+
80
+ Requires `svelte-adapter-uws >= 0.2.0` as a peer dependency.
81
+
82
+ ---
83
+
84
+ **Clients**
85
+
86
+ ## Redis client
87
+
88
+ Factory that wraps [ioredis](https://github.com/redis/ioredis) with lifecycle management. All Redis extensions accept this client.
89
+
90
+ ```js
91
+ // src/lib/server/redis.js
92
+ import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
93
+
94
+ export const redis = createRedisClient({
95
+ url: 'redis://localhost:6379',
96
+ keyPrefix: 'myapp:' // optional, prefixes all keys
97
+ });
98
+ ```
99
+
100
+ #### Options
101
+
102
+ | Option | Default | Description |
103
+ |---|---|---|
104
+ | `url` | `'redis://localhost:6379'` | Redis connection URL |
105
+ | `keyPrefix` | `''` | Prefix for all keys |
106
+ | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
107
+ | `options` | `{}` | Extra ioredis options |
108
+
109
+ #### API
110
+
111
+ | Method | Description |
112
+ |---|---|
113
+ | `redis.redis` | The underlying ioredis instance |
114
+ | `redis.key(k)` | Returns `keyPrefix + k` |
115
+ | `redis.duplicate(overrides?)` | New connection with same config. Pass ioredis options to override defaults. |
116
+ | `redis.quit()` | Gracefully disconnect all connections |
117
+
118
+ ---
119
+
120
+ ## Postgres client
121
+
122
+ Factory that wraps [pg](https://github.com/brianc/node-postgres) Pool with lifecycle management.
123
+
124
+ ```js
125
+ // src/lib/server/pg.js
126
+ import { createPgClient } from 'svelte-adapter-uws-extensions/postgres';
127
+
128
+ export const pg = createPgClient({
129
+ connectionString: 'postgres://localhost:5432/mydb'
130
+ });
131
+ ```
132
+
133
+ #### Options
134
+
135
+ | Option | Default | Description |
136
+ |---|---|---|
137
+ | `connectionString` | *required* | Postgres connection string |
138
+ | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
139
+ | `options` | `{}` | Extra pg Pool options |
140
+
141
+ #### API
142
+
143
+ | Method | Description |
144
+ |---|---|
145
+ | `pg.pool` | The underlying pg Pool |
146
+ | `pg.query(text, values?)` | Run a query |
147
+ | `pg.createClient()` | New standalone pg.Client with same config (not from the pool) |
148
+ | `pg.end()` | Gracefully close the pool |
149
+
150
+ ---
151
+
152
+ **Redis extensions**
153
+
154
+ ## Pub/sub bus
155
+
156
+ Distributes `platform.publish()` calls across multiple server instances via Redis pub/sub. Each instance publishes locally AND to Redis. Incoming Redis messages are forwarded to the local platform with echo suppression (messages originating from the same instance are dropped on receive, keyed by a per-process instance ID).
157
+
158
+ Multiple `publish()` calls within the same event-loop tick are coalesced into a single Redis pipeline via microtask batching. This means a form action that publishes to three topics results in one pipelined round trip, not three independent commands.
159
+
160
+ #### Setup
161
+
162
+ ```js
163
+ // src/lib/server/bus.js
164
+ import { redis } from './redis.js';
165
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
166
+
167
+ export const bus = createPubSubBus(redis);
168
+ ```
169
+
170
+ #### Usage
171
+
172
+ ```js
173
+ // src/hooks.ws.js
174
+ import { bus } from '$lib/server/bus';
175
+
176
+ let distributed;
177
+
178
+ export function open(ws, { platform }) {
179
+ // Start subscriber (idempotent, only subscribes once)
180
+ bus.activate(platform);
181
+ // Get a wrapped platform that publishes to Redis + local
182
+ distributed = bus.wrap(platform);
183
+ }
184
+
185
+ export function message(ws, { data, platform }) {
186
+ const msg = JSON.parse(Buffer.from(data).toString());
187
+ // This publish reaches local clients AND all other instances
188
+ distributed.publish('chat', 'message', msg);
189
+ }
190
+ ```
191
+
192
+ #### Options
193
+
194
+ | Option | Default | Description |
195
+ |---|---|---|
196
+ | `channel` | `'uws:pubsub'` | Redis channel name |
197
+ | `systemChannel` | `'__realtime'` | Topic for auto-emitted `degraded` / `recovered` events. `null` or `false` to disable. Requires a `breaker` |
198
+ | `onDegraded` | -- | Server-side handler invoked once when the breaker leaves the healthy state |
199
+ | `onRecovered` | -- | Server-side handler invoked once when the breaker returns to the healthy state |
200
+
201
+ See [Notifying clients of degradation](#notifying-clients-of-degradation) for the full pattern.
202
+
203
+ #### API
204
+
205
+ | Method | Description |
206
+ |---|---|
207
+ | `bus.wrap(platform)` | Returns a new Platform whose `publish()` sends to Redis + local |
208
+ | `bus.activate(platform)` | Start the Redis subscriber (idempotent) |
209
+ | `bus.deactivate()` | Stop the subscriber |
210
+
211
+ ---
212
+
213
+ ## Sharded pub/sub bus
214
+
215
+ `createPubSubBus` uses one channel for every message, so in a Redis Cluster every node receives every publish via the cluster bus. For deployments with many fine-grained topics where each subscriber only cares about a small subset (chat with millions of rooms, per-document collaboration, per-user feeds), most of that fan-out is wasted bandwidth.
216
+
217
+ `createShardedBus` (`svelte-adapter-uws-extensions/redis/sharded-pubsub`) is the SPUBLISH/SSUBSCRIBE variant: per-topic channels, dynamic subscription via `follow(topic)` / `unfollow(topic)`, no wildcards. In Redis Cluster, messages stay on the shard that owns each channel rather than fanning out to every node.
218
+
219
+ **Requires Redis 7+.** `activate()` runs `INFO server` and throws on older servers; use `createPubSubBus` for Redis 6 / older Valkey.
220
+
221
+ #### Setup
222
+
223
+ ```js
224
+ import { createShardedBus } from 'svelte-adapter-uws-extensions/redis/sharded-pubsub';
225
+
226
+ export const bus = createShardedBus(redis, {
227
+ shardKey: (topic) => topic.split(':')[0] // optional grouping
228
+ });
229
+ ```
230
+
231
+ #### Usage
232
+
233
+ ```js
234
+ // src/hooks.ws.js
235
+ import { bus } from '$lib/server/bus';
236
+
237
+ let distributed;
238
+
239
+ export async function open(ws, { platform }) {
240
+ await bus.activate(platform);
241
+ distributed = bus.wrap(platform);
242
+ }
243
+
244
+ // Wire follow / unfollow against WebSocket subscribe / unsubscribe:
245
+ export const { subscribe, unsubscribe, close } = bus.hooks;
246
+
247
+ // Or manually:
248
+ // await bus.follow('chat:room-7');
249
+ // distributed.publish('chat:room-7', 'msg', { text: 'hi' });
250
+ // await bus.unfollow('chat:room-7');
251
+ ```
252
+
253
+ `bus.hooks` is the recommended path -- it tracks per-`ws` subscription state and refcounts so the bus only `SSUBSCRIBE`s on the first follower per channel and `SUNSUBSCRIBE`s on the last one out.
254
+
255
+ #### Options
256
+
257
+ | Option | Default | Description |
258
+ |---|---|---|
259
+ | `channelPrefix` | `'uws:sharded:'` | Prefix for sharded pub/sub channels |
260
+ | `shardKey` | `(topic) => topic` | Map a topic to a shard label. The channel is `channelPrefix + shardKey(topic)`. Default: identity (one channel per topic). |
261
+
262
+ #### When to use which bus
263
+
264
+ | | `createPubSubBus` | `createShardedBus` |
265
+ |---|---|---|
266
+ | Redis version | any | 7+ |
267
+ | Topology | standalone or cluster | meaningful only in cluster |
268
+ | Channel model | one shared channel | per-topic (or per shard) |
269
+ | Subscription | every instance auto-subscribes | dynamic via `follow` / `hooks` |
270
+ | Best fit | most apps; broad-interest topics | many fine-grained topics with narrow audiences |
271
+
272
+ If you don't have a concrete cluster + fine-grained-topics use case, `createPubSubBus` is simpler and sufficient.
273
+
274
+ ---
275
+
276
+ ## Replay buffer (Redis)
277
+
278
+ Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
279
+
280
+ Sequence numbers are incremented atomically via a Lua script (`INCR` + `ZADD` + trim in a single `EVAL`), so concurrent publishes from multiple instances produce strictly ordered, gap-free sequences per topic. When the buffer exceeds `size`, the oldest entries are removed inside the same Lua script -- no second round trip required.
281
+
282
+ When a client requests replay, the buffer checks whether the client's last-seen sequence is older than the oldest buffered entry. If it is (the buffer was trimmed past the client's position), a `truncated` event fires on `__replay:{topic}` before any `msg` events, so the client knows it missed messages and can do a full reload. This also fires when the buffer is completely empty but the sequence counter has advanced past the client's position (e.g. all entries expired via TTL).
283
+
284
+ The same gap state is exposed as a callable: `gap(topic, lastSeenSeq)` returns `{ truncated, missingFrom }` without driving a full WebSocket replay. Useful for SSR loaders that want to decide between an incremental `since()` fetch and a full reload before the page even opens its socket.
285
+
286
+ #### Aggregate vs broadcast topics
287
+
288
+ Replay buffers track one sequence per topic, so the topic boundary is also the gap-detection boundary. Map topics to **aggregates** (`auction:a1b2`, `chat:room-7`, `doc:abc123`) rather than broadcast channels (`auctions:all`, `chat:everyone`). With one topic per aggregate, the buffer size budget covers a real history window per aggregate and gap detection is meaningful: if a client missed seq 14 of `chat:room-7`, you know exactly which room to refetch. With one broadcast topic, a hot aggregate can rotate the buffer past every other aggregate's history within seconds, so any reconnecting client looks "truncated" even when the aggregate they care about hasn't changed in an hour.
289
+
290
+ #### Setup
291
+
292
+ ```js
293
+ // src/lib/server/replay.js
294
+ import { redis } from './redis.js';
295
+ import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
296
+
297
+ export const replay = createReplay(redis, {
298
+ size: 500,
299
+ ttl: 3600 // expire after 1 hour
300
+ });
301
+ ```
302
+
303
+ #### Usage
304
+
305
+ ```js
306
+ // In a form action or API route
307
+ export const actions = {
308
+ send: async ({ request, platform }) => {
309
+ const data = Object.fromEntries(await request.formData());
310
+ const msg = await db.createMessage(data);
311
+ await replay.publish(platform, 'chat', 'created', msg);
312
+ }
313
+ };
314
+ ```
315
+
316
+ ```js
317
+ // In +page.server.js
318
+ export async function load() {
319
+ const messages = await db.getRecentMessages();
320
+ return { messages, seq: await replay.seq('chat') };
321
+ }
322
+ ```
323
+
324
+ ```js
325
+ // In hooks.ws.js - handle replay requests
326
+ export async function message(ws, { data, platform }) {
327
+ const msg = JSON.parse(Buffer.from(data).toString());
328
+ if (msg.type === 'replay') {
329
+ await replay.replay(ws, msg.topic, msg.since, platform);
330
+ return;
331
+ }
332
+ }
333
+ ```
334
+
335
+ #### Options
336
+
337
+ | Option | Default | Description |
338
+ |---|---|---|
339
+ | `storage` | `'sortedset'` | Backend: `'sortedset'` (default) uses ZADD; `'stream'` uses XADD. See [Stream backend](#stream-backend). |
340
+ | `size` | `1000` | Max messages per topic |
341
+ | `ttl` | `0` | Key expiry in seconds (0 = never) |
342
+ | `durability` | -- | Set to `'replicated'` for per-publish replication signalling. See [Replicated durability](#replicated-durability). |
343
+ | `minReplicas` | `1` | Minimum replicas that must ack (only with `durability: 'replicated'`). |
344
+ | `replicationTimeoutMs` | `1000` | Per-publish replication timeout in ms. `0` blocks indefinitely (Redis WAIT semantics). |
345
+
346
+ #### Stream backend
347
+
348
+ `storage: 'stream'` dispatches to a Redis Streams implementation (`XADD`/`XRANGE`) instead of the default sorted-set one. Same external contract -- the same `publish` / `seq` / `gap` / `since` / `replay` / `clear` methods, same `durability: 'replicated'` mode, same metrics. Two changes under the hood:
349
+
350
+ - Listpack encoding is more compact than sorted-set encoding for typical message shapes -- meaningfully smaller memory for buffers in the thousands of entries per topic.
351
+ - Stream IDs are `<seq>-0` where `seq` is the same INCR counter the sorted-set backend uses. `XRANGE` against `(seq-0` filters natively by sequence number, so range queries skip the app-side scan the sorted-set backend does for some paths.
352
+
353
+ ```js
354
+ const replay = createReplay(redis, {
355
+ storage: 'stream',
356
+ size: 10000
357
+ });
358
+ ```
359
+
360
+ Both backends use the same seq-counter key (`{prefix}replay:seq:{topic}`) but different buf-key prefixes (`replay:buf:{topic}` for sorted-set, `replay:streambuf:{topic}` for stream), so they can coexist on the same Redis without WRONGTYPE collisions. A single topic should pick one backend at startup and stay there -- there is no built-in migration helper for switching an existing topic from one backend to the other (greenfield deployments don't need it; if you have one in flight and need to migrate, drain consumers, copy entries with a one-off script, and switch).
361
+
362
+ The stream backend works on Redis 5+; listpack encoding is the Redis 7+ default that delivers the memory win.
363
+
364
+ #### Idempotent publish (stream backend only)
365
+
366
+ For producers that need at-most-once semantics under retry, the stream backend exposes `publishIdempotent`:
367
+
368
+ ```js
369
+ const replay = createReplay(redis, { storage: 'stream' });
370
+
371
+ const { seq, isDuplicate } = await replay.publishIdempotent(platform, 'orders', 'created', order, {
372
+ producerId: 'order-service',
373
+ requestId: order.clientOrderId // stable per-operation id supplied by the caller
374
+ });
375
+ ```
376
+
377
+ On a fresh `(producerId, requestId)` tuple, the call performs the same INCR + XADD + broadcast as `publish()` and stashes `seq` in a per-(producer, topic) dedup hash. On a repeat tuple within `idempotencyTtl` (default 48 hours), the call returns the cached seq, skips the XADD, and skips the local broadcast -- the original publish already broadcast to live consumers, and reconnecting consumers pick the entry up via `replay()` from the buffer.
378
+
379
+ The `seq` counter only advances on fresh writes, so duplicate retries do not introduce gaps that would trigger false-positive truncation events on consumers.
380
+
381
+ The dedup cache key is `{prefix}replay:idmp:{producerId}:{topic}` -- topic-scoped so the same `requestId` can be reused across topics without collision. Override the TTL per call via `opts.idempotencyTtl`, or globally via `idempotencyTtl` on `createReplay`.
382
+
383
+ This pairs with the durable task runner (`postgres/tasks`): a task that publishes to the replay buffer can pass its task id as `requestId` so worker-crash retries don't double-publish.
384
+
385
+ #### Replicated durability
386
+
387
+ For loss-sensitive flows (audit logs, financial events) opt in with `durability: 'replicated'`. After the write to the master, `publish()` runs `WAIT minReplicas replicationTimeoutMs`. If fewer than `minReplicas` replicas ack within the timeout, `publish()` throws `ReplicationTimeoutError` and skips the local broadcast -- the data is on the master only, and broadcasting would commit live consumers to state that could be lost if the master fails before replicas catch up.
388
+
389
+ ```js
390
+ import { createReplay, ReplicationTimeoutError } from 'svelte-adapter-uws-extensions/redis/replay';
391
+
392
+ const replay = createReplay(redis, {
393
+ durability: 'replicated',
394
+ minReplicas: 1,
395
+ replicationTimeoutMs: 1000
396
+ });
397
+
398
+ try {
399
+ await replay.publish(platform, 'orders', 'created', order);
400
+ } catch (err) {
401
+ if (err instanceof ReplicationTimeoutError) {
402
+ // err.ack, err.minReplicas, err.timeoutMs available for logging
403
+ // Caller decides: retry, fail the request, or accept best-effort
404
+ }
405
+ throw err;
406
+ }
407
+ ```
408
+
409
+ The data is in the buffer on the master regardless of the WAIT outcome -- other instances doing `replay()` will still see it. Only the local broadcast is suppressed when the durability signal fails. WAIT command-level errors (network/protocol) bubble up as the original error and DO count as a circuit-breaker failure; an under-acked timeout is a separate signal layer and does NOT trip the breaker.
410
+
411
+ #### API
412
+
413
+ All methods are async (they hit Redis). The API otherwise matches the core plugin exactly:
414
+
415
+ | Method | Description |
416
+ |---|---|
417
+ | `publish(platform, topic, event, data)` | Store + broadcast. May throw `ReplicationTimeoutError` when `durability: 'replicated'`. |
418
+ | `seq(topic)` | Current sequence number |
419
+ | `gap(topic, lastSeenSeq)` | Probe for a buffer gap. Returns `{ truncated, missingFrom }` |
420
+ | `since(topic, seq)` | Messages after a sequence |
421
+ | `replay(ws, topic, sinceSeq, platform)` | Send missed messages to one client |
422
+ | `clear()` | Delete all replay data |
423
+ | `clearTopic(topic)` | Delete replay data for one topic |
424
+
425
+ ---
426
+
427
+ ## Presence
428
+
429
+ Same API as the core `createPresence` plugin, but backed by Redis hashes. Presence state is shared across instances with cross-instance join/leave notifications via Redis pub/sub.
430
+
431
+ Joins are staged with full rollback on failure: local state is set up first, then the Redis hash field is written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone -- local maps, the Redis field, and any broadcast join event are reversed. This prevents ghost entries that would show a user as online when they never fully connected.
432
+
433
+ Leaves use an atomic Lua script (`LEAVE_SCRIPT`) that removes this instance's field from the hash and then scans remaining fields for the same user key, ignoring stale entries. Leave is only broadcast when no other instance holds a live entry for that user, preventing premature "user left" notifications in multi-instance deployments.
434
+
435
+ Zombie cleanup runs on the heartbeat interval. Each tick, every tracked WebSocket is probed via `getBufferedAmount()` -- if the call throws, the socket is dead and its presence is removed synchronously before the heartbeat writes to Redis. The heartbeat then refreshes timestamps on all live entries via a Redis pipeline and runs a server-side Lua cleanup script (`CLEANUP_SCRIPT`) that scans the hash and removes any fields whose timestamp exceeds the TTL. This handles crashed instances whose close handlers never fired.
436
+
437
+ #### Setup
438
+
439
+ ```js
440
+ // src/lib/server/presence.js
441
+ import { redis } from './redis.js';
442
+ import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
443
+
444
+ export const presence = createPresence(redis, {
445
+ key: 'id',
446
+ select: (userData) => ({ id: userData.id, name: userData.name }),
447
+ heartbeat: 30000,
448
+ ttl: 90
449
+ });
450
+ ```
451
+
452
+ #### Usage
453
+
454
+ ```js
455
+ // src/hooks.ws.js
456
+ import { presence } from '$lib/server/presence';
457
+
458
+ export async function subscribe(ws, topic, { platform }) {
459
+ await presence.join(ws, topic, platform);
460
+ }
461
+
462
+ export async function close(ws, { platform }) {
463
+ await presence.leave(ws, platform);
464
+ }
465
+ ```
466
+
467
+ #### Options
468
+
469
+ | Option | Default | Description |
470
+ |---|---|---|
471
+ | `key` | `'id'` | Field for user dedup (multi-tab) |
472
+ | `select` | strips `__`-prefixed keys | Extract public fields from userData |
473
+ | `heartbeat` | `30000` | TTL refresh interval in ms |
474
+ | `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
475
+ | `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `list` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
476
+
477
+ #### API
478
+
479
+ | Method | Description |
480
+ |---|---|
481
+ | `join(ws, topic, platform)` | Add connection to presence |
482
+ | `leave(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
483
+ | `sync(ws, topic, platform)` | Send list without joining |
484
+ | `list(topic)` | Get current users |
485
+ | `count(topic)` | Count unique users |
486
+ | `metrics()` | Synchronous snapshot: `{ totalOnline, heartbeatLatencyMs, staleCleanedTotal }`. See [Metrics snapshot](#metrics-snapshot). |
487
+ | `clear()` | Reset all presence state |
488
+ | `destroy()` | Stop heartbeat and subscriber |
489
+ | `hooks` | `{ subscribe, close }` -- ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
490
+
491
+ #### Metrics snapshot
492
+
493
+ `presence.metrics()` returns a synchronous snapshot of in-memory state:
494
+
495
+ | Field | Description |
496
+ |---|---|
497
+ | `totalOnline` | Sum of unique-users-per-topic across all topics this instance is locally tracking. The same user in two topics counts twice; per-topic counts sum cleanly. |
498
+ | `heartbeatLatencyMs` | Duration of the most recent heartbeat tick in milliseconds. Useful as a rough Redis-health indicator -- a tick that suddenly takes longer than usual is likely waiting on a slow Redis. |
499
+ | `staleCleanedTotal` | Cumulative count of stale fields removed by the heartbeat-driven cleanup script since this instance started. A non-zero rate means crashed sibling instances' presence is being cleaned up; a zero rate is the healthy steady state. |
500
+
501
+ The same numbers are exposed as Prometheus when a `metrics` registry is attached: `presence_total_online{topic="..."}` (gauge), `presence_heartbeat_latency_ms` (gauge), `presence_stale_cleaned_total` (counter, already shipped pre-0.5.0).
502
+
503
+ #### Keyspace cleanup mode
504
+
505
+ By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `leave` event. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
506
+
507
+ `keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore -- typically because the only tracker crashed), this instance emits an empty `list` event on `__presence:<topic>` so local subscribers can refresh their UI to "no one here."
508
+
509
+ ```js
510
+ const presence = createPresence(redis, {
511
+ key: 'id',
512
+ keyspaceNotifications: true
513
+ });
514
+ ```
515
+
516
+ **Operator burden:** Redis must be configured to publish keyspace events:
517
+
518
+ ```
519
+ CONFIG SET notify-keyspace-events Ex
520
+ ```
521
+
522
+ (or any flagset that includes both `K`/`E` and `x` -- e.g. `Ex`, `KEA`, etc.) If the `psubscribe` call fails because keyspace events are off, the failure is logged once and the rest of the tracker keeps working without the keyspace branch.
523
+
524
+ **Scope:** this is hash-key expiry (whole topic gone), not per-field expiry. Per-field cleanup of stale entries from crashed instances continues to run via the heartbeat-driven cleanup script. Per-field hash TTLs would require Redis 7.4+ `HEXPIRE` and a different storage layout; that's a future evolution, not part of this mode.
525
+
526
+ #### Zero-config hooks
527
+
528
+ Instead of writing `subscribe` and `close` handlers manually, destructure `presence.hooks`:
529
+
530
+ ```js
531
+ // src/hooks.ws.js
532
+ import { presence } from '$lib/server/presence';
533
+ export const { subscribe, close } = presence.hooks;
534
+ ```
535
+
536
+ `subscribe` handles both regular topics (calls `join`) and `__presence:*` topics (calls `sync` so the client gets the current list). `close` calls `leave`.
537
+
538
+ If you need custom logic (auth gating, logging), wrap the hooks:
539
+
540
+ ```js
541
+ import { presence } from '$lib/server/presence';
542
+
543
+ export async function subscribe(ws, topic, ctx) {
544
+ if (!ctx.platform.getUserData(ws).authenticated) return;
545
+ await presence.hooks.subscribe(ws, topic, ctx);
546
+ }
547
+
548
+ export const { close } = presence.hooks;
549
+ ```
550
+
551
+ ---
552
+
553
+ ## Rate limiting
554
+
555
+ Same API as the core `createRateLimit` plugin, but backed by Redis using an atomic Lua script. Rate limits are enforced across all server instances with exactly one Redis roundtrip per `consume()` call.
556
+
557
+ #### Setup
558
+
559
+ ```js
560
+ // src/lib/server/ratelimit.js
561
+ import { redis } from './redis.js';
562
+ import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
563
+
564
+ export const limiter = createRateLimit(redis, {
565
+ points: 10,
566
+ interval: 1000,
567
+ blockDuration: 30000
568
+ });
569
+ ```
570
+
571
+ #### Usage
572
+
573
+ ```js
574
+ // src/hooks.ws.js
575
+ import { limiter } from '$lib/server/ratelimit';
576
+
577
+ export async function message(ws, { data, platform }) {
578
+ const { allowed } = await limiter.consume(ws);
579
+ if (!allowed) return; // drop the message
580
+ // ... handle message
581
+ }
582
+ ```
583
+
584
+ #### Options
585
+
586
+ | Option | Default | Description |
587
+ |---|---|---|
588
+ | `points` | *required* | Tokens available per interval |
589
+ | `interval` | *required* | Refill interval in ms |
590
+ | `blockDuration` | `0` | Auto-ban duration in ms (0 = no ban) |
591
+ | `keyBy` | `'ip'` | `'ip'`, `'connection'`, or a function |
592
+
593
+ #### API
594
+
595
+ All methods are async (they hit Redis). The API otherwise matches the core plugin:
596
+
597
+ | Method | Description |
598
+ |---|---|
599
+ | `consume(ws, cost?)` | Attempt to consume tokens. `cost` must be a positive integer. |
600
+ | `reset(key)` | Clear the bucket for a key |
601
+ | `ban(key, duration?)` | Manually ban a key |
602
+ | `unban(key)` | Remove a ban |
603
+ | `clear()` | Reset all state |
604
+
605
+ ---
606
+
607
+ ## Broadcast groups
608
+
609
+ Same API as the core `createGroup` plugin, but membership is stored in Redis so groups work across multiple server instances. Local WebSocket tracking is maintained per-instance, and cross-instance events are relayed via Redis pub/sub.
610
+
611
+ #### Setup
612
+
613
+ ```js
614
+ // src/lib/server/lobby.js
615
+ import { redis } from './redis.js';
616
+ import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
617
+
618
+ export const lobby = createGroup(redis, 'lobby', {
619
+ maxMembers: 50,
620
+ meta: { game: 'chess' }
621
+ });
622
+ ```
623
+
624
+ Note: the API signature is `createGroup(client, name, options)` instead of `createGroup(name, options)` -- the Redis client is the first argument.
625
+
626
+ #### Usage
627
+
628
+ ```js
629
+ // src/hooks.ws.js
630
+ import { lobby } from '$lib/server/lobby';
631
+
632
+ export async function subscribe(ws, topic, { platform }) {
633
+ if (topic === 'lobby') await lobby.join(ws, platform);
634
+ }
635
+
636
+ export async function close(ws, { platform }) {
637
+ await lobby.leave(ws, platform);
638
+ }
639
+ ```
640
+
641
+ #### Options
642
+
643
+ | Option | Default | Description |
644
+ |---|---|---|
645
+ | `maxMembers` | `Infinity` | Maximum members allowed (enforced atomically) |
646
+ | `meta` | `{}` | Initial group metadata |
647
+ | `memberTtl` | `120` | Member entry TTL in seconds. Entries from crashed instances expire after this period. |
648
+ | `onJoin` | - | Called after a member joins |
649
+ | `onLeave` | - | Called after a member leaves |
650
+ | `onFull` | - | Called when a join is rejected (full) |
651
+ | `onClose` | - | Called when the group is closed |
652
+
653
+ #### API
654
+
655
+ | Method | Description |
656
+ |---|---|
657
+ | `join(ws, platform, role?)` | Add a member (returns false if full/closed) |
658
+ | `leave(ws, platform)` | Remove a member |
659
+ | `publish(platform, event, data, role?)` | Broadcast to all or filter by role |
660
+ | `send(platform, ws, event, data)` | Send to a single member |
661
+ | `localMembers()` | Members on this instance |
662
+ | `count()` | Total members across all instances |
663
+ | `has(ws)` | Check membership on this instance |
664
+ | `getMeta()` / `setMeta(meta)` | Read/write group metadata |
665
+ | `close(platform)` | Dissolve the group |
666
+ | `destroy()` | Stop the Redis subscriber |
667
+
668
+ ---
669
+
670
+ ## Cursor
671
+
672
+ Same API as the core `createCursor` plugin, but cursor positions are shared across instances via Redis. Each instance throttles locally (same leading/trailing edge logic as the core), then relays broadcasts through Redis pub/sub so subscribers on other instances see cursor updates.
673
+
674
+ Hash entries have a TTL so stale cursors from crashed instances get cleaned up automatically.
675
+
676
+ #### Setup
677
+
678
+ ```js
679
+ // src/lib/server/cursors.js
680
+ import { redis } from './redis.js';
681
+ import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
682
+
683
+ export const cursors = createCursor(redis, {
684
+ throttle: 50,
685
+ select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color }),
686
+ ttl: 30
687
+ });
688
+ ```
689
+
690
+ #### Usage
691
+
692
+ ```js
693
+ // src/hooks.ws.js
694
+ import { cursors } from '$lib/server/cursors';
695
+
696
+ export function message(ws, { data, platform }) {
697
+ const msg = JSON.parse(Buffer.from(data).toString());
698
+ if (msg.type === 'cursor') {
699
+ cursors.update(ws, msg.topic, msg.position, platform);
700
+ }
701
+ }
702
+
703
+ export function close(ws, { platform }) {
704
+ cursors.remove(ws, platform);
705
+ }
706
+ ```
707
+
708
+ #### Options
709
+
710
+ | Option | Default | Description |
711
+ |---|---|---|
712
+ | `throttle` | `50` | Minimum ms between broadcasts per user per topic |
713
+ | `select` | strips `__`-prefixed keys | Extract user data to broadcast alongside position |
714
+ | `ttl` | `30` | Per-entry TTL in seconds (auto-refreshed on each broadcast). Stale entries from crashed instances are filtered out individually, even if other instances are still active on the same topic. |
715
+
716
+ #### API
717
+
718
+ | Method | Description |
719
+ |---|---|
720
+ | `update(ws, topic, data, platform)` | Broadcast cursor position (throttled per user per topic) |
721
+ | `remove(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
722
+ | `list(topic)` | Get current positions across all instances |
723
+ | `clear()` | Reset all local and Redis state |
724
+ | `destroy()` | Stop the Redis subscriber and clear timers |
725
+
726
+ ---
727
+
728
+ **Postgres extensions**
729
+
730
+ ## Replay buffer (Postgres)
731
+
732
+ Same API as the Redis replay buffer, but backed by a Postgres table. Best suited for durable audit trails or history that needs to survive longer than Redis TTLs. Sequence numbers are generated atomically via a dedicated `_seq` table using `INSERT ... ON CONFLICT DO UPDATE`, so concurrent publishes from multiple instances produce strictly ordered sequences with no duplicates or gaps.
733
+
734
+ Buffer trimming runs after each publish by deleting rows with `seq <= currentSeq - maxSize`. If the trim query fails, the publish still succeeds -- the periodic background cleanup (configurable via `cleanupInterval`) catches any excess rows later.
735
+
736
+ Same gap detection behavior as the Redis replay buffer: if the client's last-seen sequence is older than the oldest buffered row, or the buffer is empty but the sequence counter has advanced, a `truncated` event fires before replay. The standalone `gap(topic, lastSeenSeq)` probe is also available with the same `{ truncated, missingFrom }` shape; the gap query uses the `(topic, seq)` index for an O(log n) seek rather than scanning the buffer.
737
+
738
+ The aggregate-vs-broadcast guidance from the [Redis replay section](#aggregate-vs-broadcast-topics) applies equally here -- one topic per aggregate keeps the buffer size budget meaningful and gap detection actionable.
739
+
740
+ #### Setup
741
+
742
+ ```js
743
+ // src/lib/server/replay.js
744
+ import { pg } from './pg.js';
745
+ import { createReplay } from 'svelte-adapter-uws-extensions/postgres/replay';
746
+
747
+ export const replay = createReplay(pg, {
748
+ table: 'svti_replay',
749
+ size: 1000,
750
+ ttl: 86400, // 24 hours
751
+ autoMigrate: true // auto-create table
752
+ });
753
+ ```
754
+
755
+ #### Schema
756
+
757
+ The table is created automatically on first use (if `autoMigrate` is true):
758
+
759
+ ```sql
760
+ CREATE TABLE IF NOT EXISTS svti_replay (
761
+ svti_replay_id BIGSERIAL PRIMARY KEY,
762
+ topic TEXT NOT NULL,
763
+ seq BIGINT NOT NULL,
764
+ event TEXT NOT NULL,
765
+ data JSONB,
766
+ created_at TIMESTAMPTZ DEFAULT now()
767
+ );
768
+ CREATE INDEX IF NOT EXISTS idx_svti_replay_topic_seq ON svti_replay (topic, seq);
769
+
770
+ CREATE TABLE IF NOT EXISTS svti_replay_seq (
771
+ topic TEXT PRIMARY KEY,
772
+ seq BIGINT NOT NULL DEFAULT 0
773
+ );
774
+ ```
775
+
776
+ #### Options
777
+
778
+ | Option | Default | Description |
779
+ |---|---|---|
780
+ | `table` | `'svti_replay'` | Table name |
781
+ | `size` | `1000` | Max messages per topic |
782
+ | `ttl` | `0` | Row expiry in seconds (0 = never) |
783
+ | `autoMigrate` | `true` | Auto-create table |
784
+ | `cleanupInterval` | `60000` | Periodic cleanup interval in ms (0 to disable) |
785
+
786
+ #### API
787
+
788
+ Same as [Replay buffer (Redis)](#api-3), plus:
789
+
790
+ | Method | Description |
791
+ |---|---|
792
+ | `destroy()` | Stop the cleanup timer |
793
+
794
+ ---
795
+
796
+ ## LISTEN/NOTIFY bridge
797
+
798
+ Listens on a Postgres channel for notifications and forwards them to `platform.publish()`. You provide the trigger on your table -- this module handles the listening side.
799
+
800
+ Uses a standalone connection (not from the pool) since LISTEN requires a persistent connection that stays open for the lifetime of the bridge.
801
+
802
+ #### Setup
803
+
804
+ ```js
805
+ // src/lib/server/notify.js
806
+ import { pg } from './pg.js';
807
+ import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
808
+
809
+ export const bridge = createNotifyBridge(pg, {
810
+ channel: 'table_changes',
811
+ parse: (payload) => {
812
+ const row = JSON.parse(payload);
813
+ return { topic: row.table, event: row.op, data: row.data };
814
+ }
815
+ });
816
+ ```
817
+
818
+ #### Usage
819
+
820
+ ```js
821
+ // src/hooks.ws.js
822
+ import { bridge } from '$lib/server/notify';
823
+
824
+ let activated = false;
825
+
826
+ export function open(ws, { platform }) {
827
+ if (!activated) {
828
+ activated = true;
829
+ bridge.activate(platform);
830
+ }
831
+ }
832
+ ```
833
+
834
+ #### Setting up the trigger
835
+
836
+ Create a trigger function and attach it to your table:
837
+
838
+ ```sql
839
+ CREATE OR REPLACE FUNCTION notify_table_change() RETURNS trigger AS $$
840
+ BEGIN
841
+ PERFORM pg_notify('table_changes', json_build_object(
842
+ 'table', TG_TABLE_NAME,
843
+ 'op', lower(TG_OP),
844
+ 'data', CASE TG_OP
845
+ WHEN 'DELETE' THEN row_to_json(OLD)
846
+ ELSE row_to_json(NEW)
847
+ END
848
+ )::text);
849
+ RETURN COALESCE(NEW, OLD);
850
+ END;
851
+ $$ LANGUAGE plpgsql;
852
+
853
+ CREATE TRIGGER messages_notify
854
+ AFTER INSERT OR UPDATE OR DELETE ON messages
855
+ FOR EACH ROW EXECUTE FUNCTION notify_table_change();
856
+ ```
857
+
858
+ Now any INSERT, UPDATE, or DELETE on the `messages` table will fire a notification. The bridge parses it and calls `platform.publish()`, which reaches all connected WebSocket clients subscribed to the topic.
859
+
860
+ The client side needs no changes -- the core `crud('messages')` store already handles `created`, `updated`, and `deleted` events.
861
+
862
+ #### Options
863
+
864
+ | Option | Default | Description |
865
+ |---|---|---|
866
+ | `channel` | *required* | Postgres LISTEN channel name |
867
+ | `parse` | JSON with `{ topic, event, data }` | Parse notification payload into a publish call. Return null to skip. |
868
+ | `autoReconnect` | `true` | Reconnect on connection loss |
869
+ | `reconnectInterval` | `3000` | ms between reconnect attempts |
870
+ | `multiListener` | `'all'` | `'all'`: every replica opens its own LISTEN (current default). `'advisory'`: leader-elected via `pg_try_advisory_lock`. See [Single-listener mode](#single-listener-mode). |
871
+ | `lockId` | -- | Advisory lock id. Required when `multiListener: 'advisory'`. |
872
+ | `pollInterval` | `5000` | ms between leader-election polls (advisory mode only). |
873
+
874
+ #### Single-listener mode
875
+
876
+ By default each replica in an N-replica deployment opens its own LISTEN connection. That's N persistent Postgres connections doing the same work, plus N copies of the same notification.
877
+
878
+ `multiListener: 'advisory'` elects a single leader via Postgres advisory locks. One replica wins `pg_try_advisory_lock(lockId)` and holds the LISTEN connection; others poll for the lock every `pollInterval` ms. If the leader's connection drops, the session-scoped lock auto-releases and another replica picks it up on its next poll.
879
+
880
+ ```js
881
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
882
+ import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
883
+
884
+ const bus = createPubSubBus(redis);
885
+
886
+ const bridge = createNotifyBridge(pg, {
887
+ channel: 'table_changes',
888
+ multiListener: 'advisory',
889
+ lockId: 0x6e6f7466 // any stable 32-bit id; e.g. CRC32 of the channel name
890
+ });
891
+
892
+ export function open(ws, { platform }) {
893
+ bus.activate(platform);
894
+ bridge.activate(bus.wrap(platform));
895
+ }
896
+ ```
897
+
898
+ **Requires a cross-instance pub/sub bus.** In `'all'` mode the bridge passes `relay: false` because every replica's local LISTEN already delivers the notification. In `'advisory'` mode only the leader has LISTEN active, so the leader publishes *with* relay -- the bus fans out to non-leader replicas via Redis. Without a bus the leader's local clients receive notifications but other replicas' clients don't.
899
+
900
+ **Choosing a `lockId`.** Pick a stable 32-bit signed integer that's unique per channel within your deployment. CRC32 of the channel name is a reasonable hash; multiple channels in the same database need distinct ids.
901
+
902
+ #### API
903
+
904
+ | Method | Description |
905
+ |---|---|
906
+ | `activate(platform)` | Start listening (idempotent) |
907
+ | `deactivate()` | Stop listening and release the connection |
908
+
909
+ #### Limitations
910
+
911
+ - **Payload is hard-limited to ~8000 bytes by Postgres** (`pg_notify` silently truncates or errors above this). This is a Postgres constraint, not a library limitation. The bridge warns at 7500 bytes. For large rows, send the row ID in the notification and let the client fetch the full row via an API call.
912
+ - Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
913
+ - This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
914
+
915
+ #### When to use this instead of Redis pub/sub
916
+
917
+ If your real-time events are driven by database writes and you do not need Redis for other extensions (presence, rate limiting, groups, cursors), the LISTEN/NOTIFY bridge is a simpler deployment: no Redis infrastructure, no separate pub/sub channel management, and your notifications are inherently tied to committed transactions. Use the Redis pub/sub bus when you need to broadcast events that do not originate from database writes, or when you are already running Redis for other extensions.
918
+
919
+ ---
920
+
921
+ ## Job queue
922
+
923
+ `createJobQueue` (`svelte-adapter-uws-extensions/postgres/jobs`) is a minimal `SELECT ... FOR UPDATE SKIP LOCKED` queue that works on vanilla Postgres 9.5+ -- no extensions required.
924
+
925
+ The shape: enqueue jobs into a named queue, claim batches atomically, mark complete (delete) or fail (release for retry). Visibility timeout means a worker that crashes mid-processing has its claim auto-expire so another worker can pick the job up. Max-attempts and dead-letter behavior are intentionally NOT baked in -- the `attempts` counter is exposed on every claim, callers track it and decide when to give up.
926
+
927
+ #### Setup
928
+
929
+ ```js
930
+ // src/lib/server/jobs.js
931
+ import { pg } from './pg.js';
932
+ import { createJobQueue } from 'svelte-adapter-uws-extensions/postgres/jobs';
933
+
934
+ export const jobs = createJobQueue(pg, {
935
+ visibilityTimeout: 60000 // 60s default; per-call override on claim()
936
+ });
937
+ ```
938
+
939
+ #### Producer
940
+
941
+ ```js
942
+ // In a request handler:
943
+ const id = await jobs.enqueue('email', { to: 'user@example.com', subject: 'Welcome' });
944
+ ```
945
+
946
+ `enqueue()` returns the row id verbatim from `pg`, which serialises `BIGINT`/`BIGSERIAL` columns as **strings** by default to avoid precision loss past `Number.MAX_SAFE_INTEGER`. Pass it through to `claim()`/`complete()`/`fail()`/`extend()` as-is. If you want a JS number for logging or comparison, coerce explicitly with `Number(id)` (safe up to 2^53 -- still ~9 quadrillion rows of headroom).
947
+
948
+ #### Consumer (worker loop)
949
+
950
+ ```js
951
+ // In a separate worker process or background loop:
952
+ async function workerLoop() {
953
+ while (running) {
954
+ const batch = await jobs.claim('email', { batchSize: 5, visibilityTimeoutMs: 30000 });
955
+ if (batch.length === 0) {
956
+ await new Promise((r) => setTimeout(r, 1000));
957
+ continue;
958
+ }
959
+ for (const job of batch) {
960
+ try {
961
+ await sendEmail(job.payload);
962
+ await jobs.complete(job.id);
963
+ } catch (err) {
964
+ if (job.attempts >= 5) {
965
+ await jobs.complete(job.id); // give up after 5 tries
966
+ await logToDeadLetter(job, err);
967
+ } else {
968
+ await jobs.fail(job.id); // release for retry
969
+ }
970
+ }
971
+ }
972
+ }
973
+ }
974
+ ```
975
+
976
+ For long-running jobs that need more visibility headroom, call `jobs.extend(id, additionalMs)` periodically while processing.
977
+
978
+ #### Schema
979
+
980
+ The table is created automatically on first use (if `autoMigrate` is true):
981
+
982
+ ```sql
983
+ CREATE TABLE IF NOT EXISTS svti_jobs (
984
+ svti_jobs_id BIGSERIAL PRIMARY KEY,
985
+ queue TEXT NOT NULL,
986
+ payload JSONB,
987
+ claimed_at TIMESTAMPTZ,
988
+ claimed_until TIMESTAMPTZ,
989
+ attempts INTEGER NOT NULL DEFAULT 0,
990
+ created_at TIMESTAMPTZ DEFAULT now()
991
+ );
992
+ CREATE INDEX IF NOT EXISTS idx_svti_jobs_queue_pending
993
+ ON svti_jobs (queue, svti_jobs_id) WHERE claimed_at IS NULL;
994
+ CREATE INDEX IF NOT EXISTS idx_svti_jobs_visibility
995
+ ON svti_jobs (claimed_until) WHERE claimed_at IS NOT NULL;
996
+ ```
997
+
998
+ #### Options
999
+
1000
+ | Option | Default | Description |
1001
+ |---|---|---|
1002
+ | `table` | `'svti_jobs'` | Table name |
1003
+ | `autoMigrate` | `true` | Auto-create the table on first use |
1004
+ | `visibilityTimeout` | `30000` | Default ms a claim is held before another worker can re-claim |
1005
+
1006
+ #### API
1007
+
1008
+ | Method | Description |
1009
+ |---|---|
1010
+ | `enqueue(queue, payload)` | Insert a job; returns the job id |
1011
+ | `claim(queue, opts?)` | `SELECT ... FOR UPDATE SKIP LOCKED` claim; opts: `{ batchSize?, visibilityTimeoutMs? }` |
1012
+ | `complete(idOrIds)` | Delete the job(s) on success |
1013
+ | `fail(idOrIds)` | Release the claim for retry |
1014
+ | `extend(idOrIds, ms)` | Push back the visibility deadline |
1015
+ | `pending(queue?)` | Count of unclaimed jobs |
1016
+ | `clear(queue?)` | Delete all jobs (useful for tests) |
1017
+
1018
+ #### When to use this vs createTaskRunner
1019
+
1020
+ | | `createJobQueue` | `createTaskRunner` |
1021
+ |---|---|---|
1022
+ | Surface | minimal: claim / complete / fail | full state machine: idempotency + fence + retry + result tracking |
1023
+ | Best fit | "ingest event, defer work" with caller-driven retry | tasks that must complete exactly once with cross-instance recovery |
1024
+ | Result tracking | none (caller tracks via DB writes) | yes (`run` / `await`) |
1025
+
1026
+ If your handler must run exactly once and you want the runtime to track the result, reach for `createTaskRunner`. If you want a lighter producer/consumer split with caller-driven retry, this is the simpler shape.
1027
+
1028
+ ---
1029
+
1030
+ **Cross-backend**
1031
+
1032
+ ## Idempotency store
1033
+
1034
+ Caches the result of an effectful operation under a stable key so retries within `ttl` return the original outcome rather than re-executing. Use it for HTTP/RPC retries, webhook redeliveries, and any handler where the caller may legitimately repeat a request that must execute at most once -- charge-customer, send-email, create-order.
1035
+
1036
+ The store exposes three states via `acquire(key)`:
1037
+
1038
+ - **acquired** -- you own the slot. Run the work, then call `commit(result)` on success or `abort()` on failure.
1039
+ - **pending** -- another caller acquired the slot and has not committed yet. Decide locally whether to return a 409, retry later, or wait.
1040
+ - **result** -- a previous run committed. The cached value is returned.
1041
+
1042
+ A short `acquireTtl` (default 60 seconds) bounds how long a pending slot can hold the key, so a crashed owner cannot deadlock retries forever. On `commit` the longer `ttl` (default 48 hours) replaces the sentinel and governs the cache lifetime.
1043
+
1044
+ Two backends share the same contract: pick whichever your stack already runs. The adapter's in-memory `Dedup` plugin is the zero-config fallback for single-instance deployments.
1045
+
1046
+ #### Setup (Redis)
1047
+
1048
+ ```js
1049
+ // src/lib/server/idempotency.js
1050
+ import { redis } from './redis.js';
1051
+ import { createIdempotencyStore } from 'svelte-adapter-uws-extensions/redis/idempotency';
1052
+
1053
+ export const idempotency = createIdempotencyStore(redis, {
1054
+ keyPrefix: 'idem:',
1055
+ ttl: 48 * 3600, // result cache lifetime (48h)
1056
+ acquireTtl: 60 // pending-slot lifetime (60s)
1057
+ });
1058
+ ```
1059
+
1060
+ Backed by a single Redis string per key. The acquire path is one Lua-script round trip.
1061
+
1062
+ #### Setup (Postgres)
1063
+
1064
+ ```js
1065
+ // src/lib/server/idempotency.js
1066
+ import { pg } from './pg.js';
1067
+ import { createIdempotencyStore } from 'svelte-adapter-uws-extensions/postgres/idempotency';
1068
+
1069
+ export const idempotency = createIdempotencyStore(pg, {
1070
+ table: 'svti_idempotency',
1071
+ ttl: 48 * 3600,
1072
+ acquireTtl: 60,
1073
+ autoMigrate: true
1074
+ });
1075
+ ```
1076
+
1077
+ The Postgres backend periodically deletes expired rows (configurable via `cleanupInterval`, default 60s, 0 to disable). Stale pending rows clear on the next sweep without manual intervention.
1078
+
1079
+ The Postgres table is created automatically on first use:
1080
+
1081
+ ```sql
1082
+ CREATE TABLE IF NOT EXISTS svti_idempotency (
1083
+ svti_idempotency_key TEXT PRIMARY KEY,
1084
+ status TEXT NOT NULL,
1085
+ result JSONB,
1086
+ expires_at TIMESTAMPTZ NOT NULL
1087
+ );
1088
+ CREATE INDEX IF NOT EXISTS idx_svti_idempotency_expires_at ON svti_idempotency (expires_at);
1089
+ ```
1090
+
1091
+ #### Usage
1092
+
1093
+ ```js
1094
+ // Wrap an effectful handler. The caller passes a stable key per logical
1095
+ // operation; identical retries return the cached result.
1096
+ export async function placeOrder(input, ctx) {
1097
+ const idempotencyKey = `order:${ctx.user.id}:${input.clientOrderId}`;
1098
+
1099
+ const slot = await idempotency.acquire(idempotencyKey);
1100
+ if (slot.acquired) {
1101
+ try {
1102
+ const order = await db.createOrder(input);
1103
+ await slot.commit(order);
1104
+ return order;
1105
+ } catch (err) {
1106
+ await slot.abort();
1107
+ throw err;
1108
+ }
1109
+ }
1110
+ if (slot.pending) {
1111
+ throw new Error('duplicate request in flight');
1112
+ }
1113
+ return slot.result;
1114
+ }
1115
+ ```
1116
+
1117
+ #### Options
1118
+
1119
+ | Option | Default | Description |
1120
+ |---|---|---|
1121
+ | `keyPrefix` (Redis) | `'idem:'` | Prepended to every Redis key after the client keyPrefix |
1122
+ | `table` (Postgres) | `'svti_idempotency'` | Table name |
1123
+ | `ttl` | `172800` (48h) | Result cache lifetime in seconds |
1124
+ | `acquireTtl` | `60` | Pending-slot lifetime in seconds (anti-deadlock) |
1125
+ | `autoMigrate` (Postgres) | `true` | Auto-create the table on first use |
1126
+ | `cleanupInterval` (Postgres) | `60000` | Periodic expired-row cleanup interval in ms (0 to disable) |
1127
+ | `breaker` | -- | Circuit breaker; bypassed when broken |
1128
+ | `metrics` | -- | Prometheus registry; emits `idempotency_*_total` counters |
1129
+
1130
+ #### API
1131
+
1132
+ | Method | Description |
1133
+ |---|---|
1134
+ | `acquire(key)` | Returns `{acquired, commit, abort}` or `{acquired: false, pending: true}` or `{acquired: false, result}` |
1135
+ | `purge(key)` | Drop a single cached result |
1136
+ | `clear()` | Drop every key under the configured prefix / table |
1137
+ | `destroy()` (Postgres only) | Stop the cleanup timer |
1138
+
1139
+ #### Choosing acquireTtl
1140
+
1141
+ `acquireTtl` is the upper bound on how long a single execution of the wrapped operation can run before retries see the slot as available again. Set it longer than your worst expected latency for the wrapped handler, but short enough that a crashed instance does not block retries for too long. The default (60 seconds) suits most HTTP and RPC handlers; bump it for long-running tasks (large file uploads, multi-step workflows) and trim it for tight read-heavy paths.
1142
+
1143
+ #### Pairing with the Dedup plugin
1144
+
1145
+ The adapter's in-memory `createDedup` plugin (`svelte-adapter-uws/plugins/dedup`) is the single-instance fallback for the same contract. The shape is identical, so swapping the backend is a one-line change. Use the in-memory plugin for tests and single-process deployments; reach for this store the moment a second instance enters the picture.
1146
+
1147
+ ---
1148
+
1149
+ ## Task runner
1150
+
1151
+ Wraps an effectful operation in a state machine that survives process crashes and naturally fans across cluster instances. Use it for background work that absolutely must finish exactly once: charging a customer, sending a transactional email, posting to a webhook, kicking off a long-running pipeline.
1152
+
1153
+ **Requires Postgres 13+.** Uses the built-in `gen_random_uuid()` function (added to core in 13; older versions need the `pgcrypto` extension explicitly enabled, which the runner does not do for you).
1154
+
1155
+ **Task names must match `/^[a-zA-Z][a-zA-Z0-9_-]*$/`** -- start with a letter, then letters/digits/underscores/hyphens. Names starting with `_` or a digit are rejected at `register()` time. Trips test fixtures most often (`__noop` -> `noop`).
1156
+
1157
+ Three guarantees:
1158
+
1159
+ - **Caller-retry idempotency.** Pair the runner with the idempotency store via the `idempotency` option. When a caller passes the same `idempotencyKey` twice, the second call returns the cached result instead of re-running the handler.
1160
+ - **Worker-crash recovery.** Every attempt holds a fence UUID and a `fence_expires_at` timestamp. The conditional commit `UPDATE ... WHERE fence = $current_fence` is atomic, so a stuck attempt that comes back from the dead cannot overwrite a completed attempt's result. A periodic recovery sweep reclaims rows whose fence has expired and re-drives the handler in any live instance.
1161
+ - **External-service idempotency.** The `idempotencyKey` is forwarded to the handler, where you pass it on to Stripe / SendGrid / S3 so the side-effect target de-duplicates retries too.
1162
+
1163
+ #### Setup
1164
+
1165
+ ```js
1166
+ // src/lib/server/tasks.js
1167
+ import { pg } from './pg.js';
1168
+ import { idempotency } from './idempotency.js';
1169
+ import { createTaskRunner } from 'svelte-adapter-uws-extensions/postgres/tasks';
1170
+
1171
+ export const tasks = createTaskRunner(pg, {
1172
+ idempotency, // optional but recommended
1173
+ fenceTtl: 60, // seconds; per-attempt fence lifetime
1174
+ recoveryInterval: 30000,
1175
+ cleanupInterval: 3600000,
1176
+ rowTtl: 7 * 24 * 3600 // keep terminal rows for 7 days
1177
+ });
1178
+
1179
+ tasks.register('charge-customer', async ({ input, idempotencyKey, signal }) => {
1180
+ return await stripe.paymentIntents.create(
1181
+ { amount: input.amount, customer: input.customerId },
1182
+ { idempotencyKey, signal }
1183
+ );
1184
+ }, {
1185
+ retry: {
1186
+ maxAttempts: 5,
1187
+ backoff: (attempt) => Math.min(1000 * 2 ** (attempt - 1), 60000),
1188
+ on: (err) => err.type === 'StripeAPIError'
1189
+ }
1190
+ });
1191
+ ```
1192
+
1193
+ #### Usage
1194
+
1195
+ ```js
1196
+ // In a form action, RPC handler, anywhere with an awaited result
1197
+ import { tasks } from '$lib/server/tasks';
1198
+
1199
+ export const actions = {
1200
+ pay: async ({ request, locals }) => {
1201
+ const { amount } = Object.fromEntries(await request.formData());
1202
+ const result = await tasks.run('charge-customer', {
1203
+ input: { amount, customerId: locals.user.stripeCustomerId },
1204
+ idempotencyKey: `charge-${locals.user.id}-${request.headers.get('idempotency-key')}`
1205
+ });
1206
+ return { success: true, paymentIntentId: result.id };
1207
+ }
1208
+ };
1209
+ ```
1210
+
1211
+ #### Schema
1212
+
1213
+ The table is created automatically on first use (if `autoMigrate` is true):
1214
+
1215
+ ```sql
1216
+ CREATE TABLE IF NOT EXISTS svti_tasks (
1217
+ svti_tasks_id UUID PRIMARY KEY,
1218
+ name TEXT NOT NULL,
1219
+ input JSONB,
1220
+ svti_idempotency_key TEXT,
1221
+ status TEXT NOT NULL, -- 'running' | 'committed' | 'failed'
1222
+ result JSONB,
1223
+ error JSONB,
1224
+ fence UUID NOT NULL,
1225
+ fence_expires_at TIMESTAMPTZ NOT NULL,
1226
+ attempts INT NOT NULL DEFAULT 1,
1227
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1228
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
1229
+ );
1230
+ CREATE INDEX IF NOT EXISTS idx_svti_tasks_running_fence
1231
+ ON svti_tasks (fence_expires_at) WHERE status = 'running';
1232
+ CREATE INDEX IF NOT EXISTS idx_svti_tasks_terminal_updated
1233
+ ON svti_tasks (updated_at) WHERE status IN ('committed', 'failed');
1234
+ ```
1235
+
1236
+ #### Options
1237
+
1238
+ | Option | Default | Description |
1239
+ |---|---|---|
1240
+ | `table` | `'svti_tasks'` | Table name |
1241
+ | `idempotency` | -- | An idempotency store ([above](#idempotency-store)). When provided, results are cached per `idempotencyKey`. Strongly recommended. |
1242
+ | `fenceTtl` | `60` | Per-attempt fence lifetime in seconds. Heartbeat extends it while the handler runs. |
1243
+ | `heartbeatInterval` | `fenceTtl * 1000 / 3` | ms between fence heartbeats |
1244
+ | `recoveryInterval` | `30000` | ms between recovery sweeps. 0 disables. |
1245
+ | `recoveryBatchSize` | `10` | Max rows reclaimed per sweep |
1246
+ | `dispatchInterval` | `5000` | ms between dispatch sweeps that claim `enqueue`d pending rows. 0 disables. |
1247
+ | `dispatchBatchSize` | `10` | Max pending rows claimed per dispatch sweep |
1248
+ | `awaitPollInterval` | `500` | ms between row reads while `await()` waits |
1249
+ | `awaitTimeout` | `60000` | ms after which `await()` rejects if the task is still not terminal. 0 = no timeout. |
1250
+ | `cleanupInterval` | `3600000` | ms between cleanup sweeps. 0 disables. |
1251
+ | `rowTtl` | `604800` (7 days) | Seconds to keep terminal rows before deletion |
1252
+ | `autoMigrate` | `true` | Auto-create the table on first use |
1253
+ | `breaker` | -- | Circuit breaker; bypassed when broken |
1254
+ | `metrics` | -- | Prometheus registry; emits `tasks_*_total` counters |
1255
+
1256
+ #### Per-handler retry config
1257
+
1258
+ Retry is declared at registration so the policy travels with the handler, not with each call site:
1259
+
1260
+ ```js
1261
+ tasks.register('flaky-webhook', handler, {
1262
+ retry: {
1263
+ maxAttempts: 5,
1264
+ backoff: (attempt, err) => Math.min(1000 * 2 ** (attempt - 1), 60000),
1265
+ on: (err) => !(err instanceof PermanentError)
1266
+ }
1267
+ });
1268
+ ```
1269
+
1270
+ Default is no retry on handler-thrown errors -- safe for non-idempotent tasks. Stuck recovery (fence-expired-while-running) is always on; it is not the same thing as retry-on-failure.
1271
+
1272
+ #### Handler context
1273
+
1274
+ ```ts
1275
+ {
1276
+ input: TInput, // the input passed to run()
1277
+ idempotencyKey: string | undefined, // forward to external services
1278
+ fence: string, // this attempt's fence UUID, read-only
1279
+ signal: AbortSignal, // aborts when the fence is lost
1280
+ attempt: number // 1-based attempt counter
1281
+ }
1282
+ ```
1283
+
1284
+ The `signal` fires when the heartbeat detects another worker has reclaimed the row (your fence_expires_at passed and the recovery sweep took over). Pass it to `fetch`, Stripe, anything that supports cancellation -- the handler should bail gracefully when it fires rather than racing the new owner.
1285
+
1286
+ #### Errors
1287
+
1288
+ `run()` throws three error shapes:
1289
+
1290
+ - `UnknownTaskError` -- no handler registered for that name in this process. Recovery does not throw on unknown names because the handler may live on a different deployment.
1291
+ - `TaskInFlightError` -- the idempotency store reports the slot as pending (another caller is mid-flight for the same key). Caller may surface a 409 to the upstream HTTP request or retry after a backoff.
1292
+ - The handler's own thrown error, after retries are exhausted. Errors are serialised to `{name, message, stack, code, cause}` for the failed-row record and reconstructed as a plain `Error` if a sibling caller reads the row.
1293
+
1294
+ #### Async path: `enqueue` + `await`
1295
+
1296
+ `run()` blocks the calling process until the handler finishes. Two more verbs let you decouple submission from completion:
1297
+
1298
+ - **`enqueue(name, opts)`** -- fire-and-forget. Inserts the row with `status='pending'` and returns the `taskId` immediately. A dispatch sweep on any live instance picks up the row and runs the handler in the background.
1299
+ - **`await(taskId, opts?)`** -- block until the task reaches a terminal status. Returns the committed result, throws the stored error, or rejects with a timeout if the task is still pending/running past `awaitTimeout`.
1300
+
1301
+ ```js
1302
+ import { tasks } from '$lib/server/tasks';
1303
+
1304
+ // Submit a job that will be processed elsewhere
1305
+ const taskId = await tasks.enqueue('send-welcome-email', {
1306
+ input: { userId: locals.user.id },
1307
+ idempotencyKey: `welcome-${locals.user.id}`
1308
+ });
1309
+
1310
+ // Optionally block on completion (or fire-and-forget by skipping this)
1311
+ const result = await tasks.await(taskId, { timeout: 30000 });
1312
+ ```
1313
+
1314
+ Use cases:
1315
+
1316
+ - **HTTP handler returns 202 quickly**: `enqueue` and respond with the `taskId`. The client polls a status endpoint that reads the row.
1317
+ - **Cross-instance work distribution**: enqueue from a web tier; a worker tier with the handlers registered picks the row up via dispatch.
1318
+ - **Decoupling submission from result**: the dispatch sweep also picks up rows whose handler is unknown locally and leaves them in `running` until another instance reclaims them.
1319
+
1320
+ The dispatch loop runs every `dispatchInterval` ms (default 5000) and claims up to `dispatchBatchSize` pending rows per sweep via `FOR UPDATE SKIP LOCKED`. Set `dispatchInterval: 0` to disable dispatch entirely (use only `run()` paths).
1321
+
1322
+ `await` polls the task row every `awaitPollInterval` ms (default 500) until terminal or `awaitTimeout` ms (default 60000). For most use cases the runner-level defaults are fine; per-call overrides are available:
1323
+
1324
+ ```js
1325
+ await tasks.await(taskId, { pollInterval: 100, timeout: 10000 });
1326
+ ```
1327
+
1328
+ Errors thrown by the handler are reconstructed from the stored row (`name`, `message`, `stack`, `code`, `cause`) when `await` reads a `failed` row. The reconstructed error is a plain `Error`; the original prototype chain does not survive the JSON round-trip.
1329
+
1330
+ #### Worker thread execution
1331
+
1332
+ By default the handler runs in the current process. For CPU-bound work that would otherwise block the event loop -- image resize, hashing, large JSON parse, anything that genuinely consumes a CPU core for long enough to matter -- you can opt in to running the handler in a worker thread.
1333
+
1334
+ The handler lives in a separate file whose default export is the handler. The runner spawns a thread pool per task; each thread imports the handler file once at startup and reuses the import across runs. Database and Redis clients cannot be shared across worker threads (native handles do not cross thread boundaries), so the worker file boots its own.
1335
+
1336
+ ```js
1337
+ // src/lib/server/workers/resize.js
1338
+ import sharp from 'sharp';
1339
+
1340
+ export default async function resize({ input, signal }) {
1341
+ return await sharp(input.imageBuffer, { signal })
1342
+ .resize(input.width, input.height)
1343
+ .toBuffer();
1344
+ }
1345
+ ```
1346
+
1347
+ ```js
1348
+ // src/lib/server/tasks.js
1349
+ import { tasks } from './tasks.js';
1350
+
1351
+ tasks.register('resize-image', null, {
1352
+ worker: new URL('./workers/resize.js', import.meta.url)
1353
+ });
1354
+
1355
+ // Or with explicit pool config:
1356
+ tasks.register('resize-image', null, {
1357
+ worker: {
1358
+ path: new URL('./workers/resize.js', import.meta.url),
1359
+ pool: { size: 4, idleTimeout: 30000 }
1360
+ }
1361
+ });
1362
+ ```
1363
+
1364
+ When `worker` is set, the `handler` argument to `register` must be `null` or omitted -- the handler argument exists in the worker file, not at the registration site. Pool defaults: `size: 1`, `idleTimeout: 30000` ms (set to `0` to keep workers warm forever). Workers spawn lazily on first run; idle workers past `idleTimeout` are terminated.
1365
+
1366
+ The `signal` in the handler context fires when the runner detects a fence loss, exactly as for in-process handlers. The runner forwards an abort message to the worker; the worker translates it into a local `AbortController.abort()` so the handler can bail.
1367
+
1368
+ When *not* to use this:
1369
+
1370
+ - I/O-bound tasks (HTTP, database, Stripe). Workers add startup cost (~10ms cold, ~50MB memory) and connection-pool duplication; the event loop already handles I/O concurrency natively.
1371
+ - Anything that needs to share in-memory state with the main process. Workers have separate memory.
1372
+
1373
+ When this *is* the right tool: a single handler that synchronously consumes the event loop for tens of milliseconds or more, where blocking the cluster instance's other work is unacceptable.
1374
+
1375
+ #### Redis fence provider (force-takeover detection)
1376
+
1377
+ Pass an optional `fence` provider to add a second source of truth for "is this attempt's fence still alive". The Postgres row remains the canonical record of task state; the provider mirrors the fence value to an external store with a short TTL refreshed by heartbeat. On every heartbeat tick the runner consults both sources -- either reporting "lost" aborts the handler.
1378
+
1379
+ The primary value is force-takeover detection. If an operator manually deletes the fence key (or another instance forcibly releases it), the heartbeat sees the divergence and bails immediately, even if the Postgres `fence_expires_at` would still pass. Useful for ops scenarios like "drain this instance, kick its in-flight tasks off so the recovery sweep on a healthy instance picks them up faster than waiting for the Postgres deadline."
1380
+
1381
+ ```js
1382
+ import { createRedisFence } from 'svelte-adapter-uws-extensions/redis/fence';
1383
+ import { createTaskRunner } from 'svelte-adapter-uws-extensions/postgres/tasks';
1384
+
1385
+ export const tasks = createTaskRunner(pg, {
1386
+ idempotency,
1387
+ fence: createRedisFence(redis, { keyPrefix: 'fence:' })
1388
+ });
1389
+ ```
1390
+
1391
+ The provider exposes `acquire`/`heartbeat`/`release`. The runner pairs each with the matching Postgres operation: `acquire` runs after the row is inserted/rearmed; `heartbeat` runs before the Postgres heartbeat each tick and short-circuits the abort path on its own; `release` is best-effort after a terminal commit/fail.
1392
+
1393
+ `createRedisFence` options:
1394
+
1395
+ | Option | Default | Description |
1396
+ |---|---|---|
1397
+ | `keyPrefix` | `'fence:'` | Prefix prepended (after the client keyPrefix) to every fence key |
1398
+
1399
+ The Redis side uses two atomic Lua scripts: heartbeat is `if get == fence then pexpire end`, release is `if get == fence then del end`. No fence held by another owner can be released or refreshed by accident.
1400
+
1401
+ ---
1402
+
1403
+ **Observability**
1404
+
1405
+ ## Prometheus metrics
1406
+
1407
+ Exposes extension metrics in Prometheus text exposition format. No external dependencies. Zero overhead when not enabled -- every metric call uses optional chaining on a nullish reference, so V8 short-circuits on a single pointer check.
1408
+
1409
+ #### Setup
1410
+
1411
+ ```js
1412
+ // src/lib/server/metrics.js
1413
+ import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
1414
+
1415
+ export const metrics = createMetrics({
1416
+ prefix: 'myapp_',
1417
+ mapTopic: (topic) => topic.startsWith('room:') ? 'room:*' : topic
1418
+ });
1419
+ ```
1420
+
1421
+ Pass the `metrics` object to any extension via its options:
1422
+
1423
+ ```js
1424
+ import { metrics } from './metrics.js';
1425
+ import { redis } from './redis.js';
1426
+ import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
1427
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
1428
+ import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
1429
+ import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
1430
+ import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
1431
+ import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
1432
+
1433
+ export const bus = createPubSubBus(redis, { metrics });
1434
+ export const presence = createPresence(redis, { metrics, key: 'id' });
1435
+ export const replay = createReplay(redis, { metrics });
1436
+ export const limiter = createRateLimit(redis, { points: 10, interval: 1000, metrics });
1437
+ export const lobby = createGroup(redis, 'lobby', { metrics });
1438
+ export const cursors = createCursor(redis, { metrics });
1439
+ ```
1440
+
1441
+ #### Mounting the endpoint
1442
+
1443
+ With uWebSockets.js:
1444
+
1445
+ ```js
1446
+ app.get('/metrics', metrics.handler);
1447
+ ```
1448
+
1449
+ Or use `metrics.serialize()` to get the raw text and serve it however you like.
1450
+
1451
+ #### Options
1452
+
1453
+ | Option | Default | Description |
1454
+ |---|---|---|
1455
+ | `prefix` | `''` | Prefix for all metric names |
1456
+ | `mapTopic` | identity | Map topic names to bounded label values for cardinality control |
1457
+ | `defaultBuckets` | `[1, 5, 10, 25, 50, 100, 250, 500, 1000]` | Default histogram buckets |
1458
+
1459
+ Metric names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` and label names must match `[a-zA-Z_][a-zA-Z0-9_]*` (no `__` prefix). Invalid names throw at registration time. HELP text containing backslashes or newlines is escaped automatically.
1460
+
1461
+ #### Cardinality control
1462
+
1463
+ If your topics are user-generated (e.g. `room:abc123`), per-topic labels will grow unbounded. Use `mapTopic` to collapse them:
1464
+
1465
+ ```js
1466
+ const metrics = createMetrics({
1467
+ mapTopic: (topic) => {
1468
+ if (topic.startsWith('room:')) return 'room:*';
1469
+ if (topic.startsWith('user:')) return 'user:*';
1470
+ return topic;
1471
+ }
1472
+ });
1473
+ ```
1474
+
1475
+ #### Metrics reference
1476
+
1477
+ **Pub/sub bus**
1478
+
1479
+ | Metric | Type | Description |
1480
+ |---|---|---|
1481
+ | `pubsub_messages_relayed_total` | counter | Messages relayed to Redis |
1482
+ | `pubsub_messages_received_total` | counter | Messages received from Redis |
1483
+ | `pubsub_echo_suppressed_total` | counter | Messages dropped by echo suppression |
1484
+ | `pubsub_relay_batch_size` | histogram | Relay batch size per flush |
1485
+ | `pubsub_degraded_total` | counter | Auto-emitted `degraded` events |
1486
+ | `pubsub_recovered_total` | counter | Auto-emitted `recovered` events |
1487
+
1488
+ **Sharded pub/sub bus**
1489
+
1490
+ | Metric | Type | Labels | Description |
1491
+ |---|---|---|---|
1492
+ | `sharded_pubsub_messages_relayed_total` | counter | `topic` | Messages SPUBLISHed |
1493
+ | `sharded_pubsub_messages_received_total` | counter | `topic` | Messages received via SSUBSCRIBE |
1494
+ | `sharded_pubsub_echo_suppressed_total` | counter | | Sharded messages dropped by echo suppression |
1495
+ | `sharded_pubsub_ssubscribes_total` | counter | | SSUBSCRIBE calls (first follower per channel) |
1496
+ | `sharded_pubsub_sunsubscribes_total` | counter | | SUNSUBSCRIBE calls (last follower out) |
1497
+
1498
+ **Presence**
1499
+
1500
+ | Metric | Type | Labels | Description |
1501
+ |---|---|---|---|
1502
+ | `presence_joins_total` | counter | `topic` | Join events |
1503
+ | `presence_leaves_total` | counter | `topic` | Leave events |
1504
+ | `presence_heartbeats_total` | counter | | Heartbeat refresh cycles |
1505
+ | `presence_stale_cleaned_total` | counter | | Stale entries removed by cleanup |
1506
+ | `presence_total_online` | gauge | `topic` | Unique users present per topic on this instance |
1507
+ | `presence_heartbeat_latency_ms` | gauge | | Duration of the most recent heartbeat tick in ms |
1508
+ | `presence_keyspace_cleanups_total` | counter | | Topic hash expiries that triggered an empty-list emit (keyspace mode only) |
1509
+
1510
+ **Replay buffer (Redis and Postgres)**
1511
+
1512
+ | Metric | Type | Labels | Description |
1513
+ |---|---|---|---|
1514
+ | `replay_publishes_total` | counter | `topic` | Messages published |
1515
+ | `replay_messages_replayed_total` | counter | `topic` | Messages replayed to clients |
1516
+ | `replay_truncations_total` | counter | `topic` | Truncation events detected |
1517
+ | `replay_replications_total` | counter | | Publishes confirmed replicated within timeout (Redis only, `durability: 'replicated'` mode) |
1518
+ | `replay_replication_timeouts_total` | counter | | Publishes that did not reach `minReplicas` within timeout |
1519
+ | `replay_idmp_hits_total` | counter | `topic` | `publishIdempotent` calls served from the dedup cache (no XADD) |
1520
+ | `replay_idmp_writes_total` | counter | `topic` | `publishIdempotent` calls that produced a new entry |
1521
+
1522
+ **Rate limiting**
1523
+
1524
+ | Metric | Type | Description |
1525
+ |---|---|---|
1526
+ | `ratelimit_allowed_total` | counter | Requests allowed |
1527
+ | `ratelimit_denied_total` | counter | Requests denied |
1528
+ | `ratelimit_bans_total` | counter | Bans applied |
1529
+
1530
+ **Broadcast groups**
1531
+
1532
+ | Metric | Type | Labels | Description |
1533
+ |---|---|---|---|
1534
+ | `group_joins_total` | counter | `group` | Join events |
1535
+ | `group_joins_rejected_total` | counter | `group` | Joins rejected (full) |
1536
+ | `group_leaves_total` | counter | `group` | Leave events |
1537
+ | `group_publishes_total` | counter | `group` | Publish events |
1538
+
1539
+ **Cursor**
1540
+
1541
+ | Metric | Type | Labels | Description |
1542
+ |---|---|---|---|
1543
+ | `cursor_updates_total` | counter | `topic` | Cursor update calls |
1544
+ | `cursor_broadcasts_total` | counter | `topic` | Broadcasts actually sent |
1545
+ | `cursor_throttled_total` | counter | `topic` | Updates deferred by throttle |
1546
+
1547
+ **LISTEN/NOTIFY bridge**
1548
+
1549
+ | Metric | Type | Labels | Description |
1550
+ |---|---|---|---|
1551
+ | `notify_received_total` | counter | `channel` | Notifications received |
1552
+ | `notify_parse_errors_total` | counter | `channel` | Parse failures |
1553
+ | `notify_reconnects_total` | counter | | Reconnect attempts |
1554
+
1555
+ **Admission control**
1556
+
1557
+ | Metric | Type | Labels | Description |
1558
+ |---|---|---|---|
1559
+ | `admission_accepted_total` | counter | `class` | `shouldAccept` calls that returned `true` |
1560
+ | `admission_rejected_total` | counter | `class`, `reason` | `shouldAccept` calls that returned `false`, labeled with the pressure reason that caused rejection |
1561
+
1562
+ **Job queue**
1563
+
1564
+ | Metric | Type | Labels | Description |
1565
+ |---|---|---|---|
1566
+ | `jobs_enqueued_total` | counter | `queue` | Jobs enqueued |
1567
+ | `jobs_claimed_total` | counter | `queue` | Jobs claimed (rows returned by `claim`) |
1568
+ | `jobs_completed_total` | counter | `queue` | Jobs completed (deleted) |
1569
+ | `jobs_failed_total` | counter | `queue` | Jobs released via `fail()` for retry |
1570
+
1571
+ **Redis Functions**
1572
+
1573
+ | Metric | Type | Labels | Description |
1574
+ |---|---|---|---|
1575
+ | `redis_function_loads_total` | counter | `library` | `FUNCTION LOAD` calls |
1576
+ | `redis_function_calls_total` | counter | `library`, `function` | `FCALL` calls |
1577
+ | `redis_function_errors_total` | counter | `library`, `function` | `FCALL` calls that threw |
1578
+
1579
+ ---
1580
+
1581
+ **Reliability**
1582
+
1583
+ ## Failure handling
1584
+
1585
+ Every Redis and Postgres extension accepts an optional `breaker` option -- a shared [circuit breaker](#circuit-breaker) that tracks backend health across all extensions wired to it. When the breaker trips, each extension degrades differently depending on whether the operation is critical or best-effort:
1586
+
1587
+ | Extension | Awaited operations (join, consume, publish) | Fire-and-forget operations |
1588
+ |---|---|---|
1589
+ | **Pub/sub bus** | `wrap().publish()` queues to local platform only; relay to Redis is skipped silently | Microtask relay flush is skipped entirely |
1590
+ | **Presence** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh and stale cleanup are skipped |
1591
+ | **Replay buffer** | `publish()` / `replay()` / `seq()` throw `CircuitBrokenError` | -- |
1592
+ | **Rate limiting** | `consume()` throws `CircuitBrokenError` (fail-closed -- requests are blocked, not allowed through) | -- |
1593
+ | **Broadcast groups** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh is skipped |
1594
+ | **Cursor** | -- | Hash writes and cross-instance relay are skipped; local throttle continues |
1595
+ | **LISTEN/NOTIFY** | `activate()` throws; auto-reconnect retries on its own interval | -- |
1596
+
1597
+ The breaker is a three-state machine: **healthy** (all requests pass through) -> **broken** after N consecutive failures (all requests fail fast via `CircuitBrokenError`) -> **probing** after a timeout (one request is allowed through to test recovery) -> back to **healthy** on success. See [Circuit breaker](#circuit-breaker) for configuration.
1598
+
1599
+ #### Notifying clients of degradation
1600
+
1601
+ When Redis pub/sub fails, live streams on other replicas stop receiving updates. Connected clients continue showing stale data with no indication that the stream is degraded. The pub/sub bus emits this directly: when the shared breaker leaves the healthy state, a `degraded` event fires on the bus's `systemChannel` (default `'__realtime'`); when it returns to healthy, a `recovered` event fires.
1602
+
1603
+ ```js
1604
+ import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
1605
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
1606
+
1607
+ export const breaker = createCircuitBreaker({ failureThreshold: 5, resetTimeout: 30000 });
1608
+
1609
+ export const bus = createPubSubBus(redis, {
1610
+ breaker,
1611
+ // optional handlers for server-side reactions (logging, alerts):
1612
+ onDegraded: () => console.warn('pubsub bus degraded'),
1613
+ onRecovered: () => console.info('pubsub bus recovered')
1614
+ });
1615
+ ```
1616
+
1617
+ On the client side, subscribe to the `__realtime` topic and show a banner when the `degraded` event fires. On `recovered`, dismiss the banner and refetch stale data. The event payload is `{ at: <epoch ms> }` so a client can show "lost connection 12s ago".
1618
+
1619
+ Both the topic name and the auto-emission are configurable:
1620
+
1621
+ | Option | Default | Description |
1622
+ |---|---|---|
1623
+ | `systemChannel` | `'__realtime'` | Topic used for `degraded` / `recovered` events. Set to `null` or `false` to disable auto-emission. |
1624
+ | `onDegraded` | -- | Server-side handler invoked once on the healthy -> non-healthy transition |
1625
+ | `onRecovered` | -- | Server-side handler invoked once on the non-healthy -> healthy transition |
1626
+
1627
+ Auto-emission is local-only -- Redis is what's degraded, so the event reaches local clients via the underlying platform without attempting a relay. Each instance reports its own breaker state to its own clients. If you need different semantics (cross-instance forwarding, custom payload, filtering by failure type), use `breaker.subscribe(handler)` to register your own listener and emit through whichever channel you prefer.
1628
+
1629
+ ---
1630
+
1631
+ ## Circuit breaker
1632
+
1633
+ Prevents thundering herd when a backend goes down. When Redis or Postgres becomes unreachable, every extension that uses the breaker fails fast instead of queueing up timeouts, and fire-and-forget operations (heartbeats, relay flushes, cursor broadcasts) are skipped entirely.
1634
+
1635
+ Three states:
1636
+ - **healthy** -- everything works, requests go through
1637
+ - **broken** -- too many failures, requests fail fast via `CircuitBrokenError`
1638
+ - **probing** -- one request is allowed through to test if the backend is back
1639
+
1640
+ #### Setup
1641
+
1642
+ ```js
1643
+ // src/lib/server/breaker.js
1644
+ import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
1645
+
1646
+ export const breaker = createCircuitBreaker({
1647
+ failureThreshold: 5,
1648
+ resetTimeout: 30000,
1649
+ onStateChange: (from, to) => console.log(`circuit: ${from} -> ${to}`)
1650
+ });
1651
+ ```
1652
+
1653
+ Pass the same breaker to all extensions that share a backend:
1654
+
1655
+ ```js
1656
+ import { breaker } from './breaker.js';
1657
+
1658
+ export const bus = createPubSubBus(redis, { breaker });
1659
+ export const presence = createPresence(redis, { breaker, key: 'id' });
1660
+ export const replay = createReplay(redis, { breaker });
1661
+ export const limiter = createRateLimit(redis, { points: 10, interval: 1000, breaker });
1662
+ ```
1663
+
1664
+ Failures from any extension contribute to the same breaker. When one trips it, all others fail fast.
1665
+
1666
+ #### Options
1667
+
1668
+ | Option | Default | Description |
1669
+ |---|---|---|
1670
+ | `failureThreshold` | `5` | Consecutive failures before breaking |
1671
+ | `resetTimeout` | `30000` | Ms before transitioning from broken to probing |
1672
+ | `onStateChange` | - | Called on state transitions: `(from, to) => void` |
1673
+
1674
+ #### API
1675
+
1676
+ | Method / Property | Description |
1677
+ |---|---|
1678
+ | `breaker.state` | `'healthy'`, `'broken'`, or `'probing'` |
1679
+ | `breaker.isHealthy` | `true` only when state is `'healthy'` |
1680
+ | `breaker.failures` | Current consecutive failure count |
1681
+ | `breaker.guard()` | Throws `CircuitBrokenError` if the circuit is broken |
1682
+ | `breaker.success()` | Record a successful operation |
1683
+ | `breaker.failure()` | Record a failed operation |
1684
+ | `breaker.reset()` | Force back to healthy |
1685
+ | `breaker.destroy()` | Clear internal timers |
1686
+
1687
+ #### How extensions use it
1688
+
1689
+ Awaited operations (join, consume, publish) call `guard()` before the Redis/Postgres call, `success()` after, and `failure()` in the catch block. When the circuit is broken, `guard()` throws `CircuitBrokenError` and the operation never reaches the backend.
1690
+
1691
+ Fire-and-forget operations (heartbeat refresh, relay flush, cursor broadcast) check `isHealthy` and skip entirely when the circuit is not healthy. This prevents piling up commands on a dead connection.
1692
+
1693
+ #### Error handling
1694
+
1695
+ ```js
1696
+ import { CircuitBrokenError } from 'svelte-adapter-uws-extensions/breaker';
1697
+
1698
+ try {
1699
+ await replay.publish(platform, 'chat', 'msg', data);
1700
+ } catch (err) {
1701
+ if (err instanceof CircuitBrokenError) {
1702
+ // Backend is down -- degrade gracefully
1703
+ platform.publish('chat', 'msg', data); // local-only delivery
1704
+ }
1705
+ }
1706
+ ```
1707
+
1708
+ ---
1709
+
1710
+ ## Admission control
1711
+
1712
+ Pressure-aware companion to the [circuit breaker](#circuit-breaker). Where the breaker answers "is the backend up?", admission control answers "are we OK to take more work right now?" -- using the adapter's `platform.pressure` signal (memory, publish rate, subscriber ratio) to gate non-critical work before it ever reaches a backend.
1713
+
1714
+ Requires `svelte-adapter-uws >= 0.5.0-next.1` (the version that ships `platform.pressure`).
1715
+
1716
+ #### Setup
1717
+
1718
+ ```js
1719
+ // src/lib/server/admission.js
1720
+ import { createAdmissionControl } from 'svelte-adapter-uws-extensions/admission';
1721
+
1722
+ export const ac = createAdmissionControl({
1723
+ classes: {
1724
+ critical: ['MEMORY'], // refuse only on memory pressure
1725
+ normal: ['MEMORY', 'PUBLISH_RATE'], // refuse on memory or publish rate
1726
+ background: ['MEMORY', 'PUBLISH_RATE', 'SUBSCRIBERS'] // refuse on any pressure
1727
+ }
1728
+ });
1729
+ ```
1730
+
1731
+ #### Usage
1732
+
1733
+ ```js
1734
+ // In a server endpoint or RPC handler:
1735
+ import { ac } from '$lib/server/admission';
1736
+
1737
+ export async function POST({ platform, request }) {
1738
+ if (!ac.shouldAccept('background', platform)) {
1739
+ return new Response('busy', { status: 503 });
1740
+ }
1741
+ // ...proceed with the request...
1742
+ }
1743
+ ```
1744
+
1745
+ Each class is independently configured. The adapter has already collapsed concurrent signals (memory, publish rate, subscribers) into a single most-urgent `reason` -- this controller just maps the resolved reason to a per-class accept/reject decision.
1746
+
1747
+ #### Class rule shapes
1748
+
1749
+ A class rule is either an array of pressure reasons that should block this class, or a predicate function:
1750
+
1751
+ ```js
1752
+ classes: {
1753
+ // Array form: block when reason is in this list
1754
+ critical: ['MEMORY'],
1755
+
1756
+ // Predicate form: block when the predicate returns truthy
1757
+ streaming: (snapshot) => snapshot.subscriberRatio > 50
1758
+ }
1759
+ ```
1760
+
1761
+ Predicates receive the full `PressureSnapshot` so they can apply custom thresholds (e.g. block above a specific publish-rate that's tighter than the adapter's). Array form is the simple-case shorthand and is what 90% of callers should use.
1762
+
1763
+ Valid reason strings: `'NONE'`, `'PUBLISH_RATE'`, `'SUBSCRIBERS'`, `'MEMORY'`. Including `'NONE'` in a block list means "always block this class," which is occasionally useful for kill-switching a class without removing the wiring.
1764
+
1765
+ #### Options
1766
+
1767
+ | Option | Default | Description |
1768
+ |---|---|---|
1769
+ | `classes` | (required) | Map of class name to admission rule. Must define at least one class. |
1770
+ | `metrics` | -- | Prometheus metrics registry. |
1771
+
1772
+ #### API
1773
+
1774
+ | Method | Description |
1775
+ |---|---|
1776
+ | `shouldAccept(className, platform)` | Returns `true` to admit, `false` to shed. Throws on unknown class name (typo defense) or missing `platform.pressure`. |
1777
+
1778
+ `shouldAccept` reads `platform.pressure` via a property access -- no I/O, safe to call on every request hot path. The reason-precedence math (memory > publish rate > subscribers > none) lives in the adapter; this method only checks the resolved `reason` against the configured rule.
1779
+
1780
+ #### Composition with the breaker
1781
+
1782
+ Admission control and the circuit breaker check independent signals. Use them together:
1783
+
1784
+ ```js
1785
+ export async function POST({ platform, request }) {
1786
+ // Local pressure check first -- cheaper, no Redis call.
1787
+ if (!ac.shouldAccept('normal', platform)) {
1788
+ return new Response('busy', { status: 503 });
1789
+ }
1790
+ // Then attempt the backend call. CircuitBrokenError surfaces if the
1791
+ // breaker is open.
1792
+ try {
1793
+ await replay.publish(platform, 'chat', 'msg', data);
1794
+ } catch (err) {
1795
+ if (err instanceof CircuitBrokenError) {
1796
+ return new Response('backend unavailable', { status: 503 });
1797
+ }
1798
+ throw err;
1799
+ }
1800
+ }
1801
+ ```
1802
+
1803
+ The two layers complement each other: admission control prevents new work from piling up under server-local pressure; the breaker prevents thundering-herd retries against a dead backend.
1804
+
1805
+ ---
1806
+
1807
+ ## Redis Functions
1808
+
1809
+ `createFunctionLibrary` (`svelte-adapter-uws-extensions/redis/functions`) is a thin wrapper over Redis 7+ `FUNCTION LOAD` / `FCALL`. Versioned, hot-reloadable server-side scripts: ship a new library version and `load()` swaps it in atomically without an app redeploy.
1810
+
1811
+ The library code is plain Lua and must start with `#!lua name=<libname>` -- the wrapper parses the name from the shebang. Inside the library, declare functions via `redis.register_function(...)`. Function names are global on the Redis server (not namespaced by library), which is why `call(funcName, ...)` keys on function name only.
1812
+
1813
+ ```js
1814
+ import { createFunctionLibrary } from 'svelte-adapter-uws-extensions/redis/functions';
1815
+
1816
+ const lib = createFunctionLibrary(redis, `#!lua name=ws-presence
1817
+ redis.register_function('cleanup', function(keys, args)
1818
+ -- args[1] = now (ms), args[2] = ttl (ms)
1819
+ local now = tonumber(args[1])
1820
+ local ttl = tonumber(args[2])
1821
+ local removed = 0
1822
+ -- ... iterate hash fields, HDEL stale ...
1823
+ return removed
1824
+ end)
1825
+ `);
1826
+
1827
+ await lib.load();
1828
+ const removed = await lib.call('cleanup', {
1829
+ keys: ['presence:room1'],
1830
+ args: [Date.now(), 90000]
1831
+ });
1832
+
1833
+ await lib.delete(); // FUNCTION DELETE
1834
+ ```
1835
+
1836
+ #### Options
1837
+
1838
+ | Option | Default | Description |
1839
+ |---|---|---|
1840
+ | `metrics` | -- | Prometheus metrics registry. |
1841
+ | `breaker` | -- | Circuit breaker instance. |
1842
+
1843
+ #### API
1844
+
1845
+ | Method / Property | Description |
1846
+ |---|---|
1847
+ | `lib.name` | Library name parsed from the shebang |
1848
+ | `lib.load()` | `FUNCTION LOAD REPLACE`. Runs `INFO server` on first call and throws on Redis < 7. Idempotent. |
1849
+ | `lib.call(funcName, { keys?, args? })` | `FCALL` -- returns the function's return value |
1850
+ | `lib.delete()` | `FUNCTION DELETE <libname>` |
1851
+
1852
+ #### When to use this vs `redis.eval`
1853
+
1854
+ `redis.eval` is fine for one-off scripts that ship inside the app code. Use `createFunctionLibrary` when:
1855
+
1856
+ - Scripts have meaningful versions and ops want to roll forward without an app deploy.
1857
+ - Multiple scripts belong together as a coherent library (shared helpers, etc.).
1858
+ - Scripts are large enough that parsing + caching them per `eval` becomes a measurable cost.
1859
+
1860
+ Requires Redis 7+. There is no built-in fallback to `EVALSHA` for older servers because that would require maintaining each function in two forms (library function + plain Lua); on Redis 6 just call `redis.eval` directly with your own SHA caching.
1861
+
1862
+ ---
1863
+
1864
+ **Operations**
1865
+
1866
+ ## Graceful shutdown
1867
+
1868
+ All clients listen for the `sveltekit:shutdown` event and disconnect cleanly by default. You can disable this with `autoShutdown: false` and manage the lifecycle yourself.
1869
+
1870
+ ```js
1871
+ // Manual shutdown
1872
+ await redis.quit();
1873
+ await pg.end();
1874
+ presence.destroy();
1875
+ ```
1876
+
1877
+ ---
1878
+
1879
+ ## Testing
1880
+
1881
+ This repo runs tests in two layers. Both stay green; you can run either independently.
1882
+
1883
+ ```bash
1884
+ npm test # mock layer (24 files, 861 tests, no services needed)
1885
+ npm run test:integration # integration layer (real Redis 7 + Postgres 16 in Docker)
1886
+ ```
1887
+
1888
+ ### Mock layer (`test/`)
1889
+
1890
+ In-memory mocks for Redis and Postgres that mirror the public APIs closely enough to drive the extensions through their happy paths and edge cases. Fast feedback (~15s for the full suite), no Docker required.
1891
+
1892
+ The mocks live at `testing/mock-redis.js` and `testing/mock-pg.js` and are exported as `svelte-adapter-uws-extensions/testing` so consumers of this package can use them too. See [Testing your own code](#testing-your-own-code) below.
1893
+
1894
+ ### Integration layer (`test/integration/`)
1895
+
1896
+ Exercises the same modules against real services in `docker compose`. Picks up cases the mocks can only approximate: Lua atomicity inside Redis EVAL, Postgres LISTEN/NOTIFY cross-connection delivery, real TTL/EXPIRE behaviour, partial-index plans on the job queue.
1897
+
1898
+ The compose stack at [`test/integration/docker-compose.yml`](test/integration/docker-compose.yml) binds non-default host ports so it does not clash with a locally running Postgres/Redis: **Postgres on `55432`**, **Redis on `56379`**. `test/integration/global-setup.js` runs `docker compose up -d --wait` before the suite, exports `INTEGRATION_REDIS_URL` / `INTEGRATION_POSTGRES_URL` for the tests to read, and tears the stack down with `docker compose down -v` afterwards.
1899
+
1900
+ The host ports and compose project name are env-var overridable for running multiple stacks side-by-side on the same machine:
1901
+
1902
+ ```bash
1903
+ INTEGRATION_REDIS_HOST_PORT=56380 \
1904
+ INTEGRATION_POSTGRES_HOST_PORT=55433 \
1905
+ INTEGRATION_COMPOSE_PROJECT=my-slice \
1906
+ npm run test:integration
1907
+ ```
1908
+
1909
+ Project name auto-derives from the port pair when overridden, so unique ports also mean unique container names.
1910
+
1911
+ #### Adding a new integration test
1912
+
1913
+ 1. Drop a `*.test.js` file under `test/integration/redis/` or `test/integration/postgres/`.
1914
+ 2. In `beforeAll`, build a real client:
1915
+
1916
+ ```js
1917
+ import { createRedisClient } from '../../../redis/index.js';
1918
+ // or: import { createPgClient } from '../../../postgres/index.js';
1919
+
1920
+ beforeAll(() => {
1921
+ client = createRedisClient({
1922
+ url: process.env.INTEGRATION_REDIS_URL,
1923
+ keyPrefix: 'inttest-yourmodule:', // namespace per test file
1924
+ autoShutdown: false // tests own the lifecycle
1925
+ });
1926
+ });
1927
+ ```
1928
+ 3. In `beforeEach`, wipe state under your prefix (Redis: `SCAN MATCH prefix* + UNLINK`; Postgres: `TRUNCATE` your test tables, or use distinct channels for LISTEN/NOTIFY).
1929
+ 4. In `afterAll`, `await client.quit()` / `await client.end()`.
1930
+
1931
+ The integration layer is additive: the mock-based test for a module stays in place when you add the integration counterpart. They cover different failure modes.
1932
+
1933
+ ### Testing your own code
1934
+
1935
+ The `svelte-adapter-uws-extensions/testing` entry point exports the same in-memory mocks used by the extensions' own test suite. Use them to test your extension-consuming code without running Redis or Postgres:
1936
+
1937
+ ```js
1938
+ import { mockRedisClient, mockPlatform, mockWs } from 'svelte-adapter-uws-extensions/testing';
1939
+ import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
1940
+ import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
1941
+ import { describe, it, expect } from 'vitest';
1942
+
1943
+ describe('presence', () => {
1944
+ it('tracks users across topics', async () => {
1945
+ const client = mockRedisClient();
1946
+ const platform = mockPlatform();
1947
+ const presence = createPresence(client, { key: 'id' });
1948
+
1949
+ const ws = mockWs({ id: 'user-1', name: 'Alice' });
1950
+ await presence.join(ws, 'room:lobby', platform);
1951
+
1952
+ expect(await presence.count('room:lobby')).toBe(1);
1953
+ expect(platform.published.some(p => p.event === 'join')).toBe(true);
1954
+
1955
+ presence.destroy();
1956
+ });
1957
+ });
1958
+
1959
+ describe('rate limiting', () => {
1960
+ it('blocks after exhausting points', async () => {
1961
+ const client = mockRedisClient();
1962
+ const limiter = createRateLimit(client, { points: 3, interval: 10000 });
1963
+ const ws = mockWs({ remoteAddress: '1.2.3.4' });
1964
+
1965
+ for (let i = 0; i < 3; i++) {
1966
+ expect((await limiter.consume(ws)).allowed).toBe(true);
1967
+ }
1968
+ expect((await limiter.consume(ws)).allowed).toBe(false);
1969
+ });
1970
+ });
1971
+ ```
1972
+
1973
+ #### Available mocks
1974
+
1975
+ | Export | What it mocks | Supports |
1976
+ |---|---|---|
1977
+ | `mockRedisClient(prefix?)` | `createRedisClient()` | Strings, hashes, sorted sets, pub/sub, pipelines, scan, Lua eval for all extension scripts |
1978
+ | `mockPlatform()` | Platform API | `publish()`, `send()`, `batch()`, `topic()` -- records all calls in `.published` and `.sent` |
1979
+ | `mockWs(userData?)` | uWS WebSocket | `subscribe()`, `unsubscribe()`, `getUserData()`, `getBufferedAmount()`, `close()` |
1980
+ | `mockPgClient()` | `createPgClient()` | SQL parsing for replay buffer operations, sequence counters |
1981
+
1982
+ The circuit breaker (`createCircuitBreaker()`) is pure logic with no I/O -- use it directly in tests, no mock needed.
1983
+
1984
+ ---
1985
+
1986
+ ## Related projects
1987
+
1988
+ - [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws) -- The core adapter this package extends. Single-process WebSocket pub/sub, presence, replay, and more for SvelteKit on uWebSockets.js.
1989
+ - [svelte-realtime](https://github.com/lanteanio/svelte-realtime) -- Opinionated full-stack starter built on the adapter. Auth, database, real-time CRUD, and deployment config out of the box.
1990
+ - [svelte-realtime-demo](https://github.com/lanteanio/svelte-realtime-demo) -- Live demo of svelte-realtime. [Try it here.](https://svelte-realtime-demo.lantean.io/)
1991
+
1992
+ ---
1993
+
1994
+ ## License
1995
+
1996
+ MIT