svelte-adapter-uws-extensions 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,7 @@ The core adapter keeps everything in-process memory. That works great for single
42
42
  - [Prometheus metrics](#prometheus-metrics)
43
43
 
44
44
  **Reliability**
45
+ - [Failure handling](#failure-handling)
45
46
  - [Circuit breaker](#circuit-breaker)
46
47
 
47
48
  **Operations**
@@ -144,7 +145,9 @@ export const pg = createPgClient({
144
145
 
145
146
  ## Pub/sub bus
146
147
 
147
- 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 from the same instance are ignored).
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.
148
151
 
149
152
  #### Setup
150
153
 
@@ -198,6 +201,10 @@ export function message(ws, { data, platform }) {
198
201
 
199
202
  Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
200
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
+
201
208
  #### Setup
202
209
 
203
210
  ```js
@@ -269,6 +276,12 @@ All methods are async (they hit Redis). The API otherwise matches the core plugi
269
276
 
270
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.
271
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
+
272
285
  #### Setup
273
286
 
274
287
  ```js
@@ -527,7 +540,11 @@ export function close(ws, { platform }) {
527
540
 
528
541
  ## Replay buffer (Postgres)
529
542
 
530
- 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, so they are safe across multiple server instances.
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.
531
548
 
532
549
  #### Setup
533
550
 
@@ -669,10 +686,14 @@ The client side needs no changes -- the core `crud('messages')` store already ha
669
686
 
670
687
  #### Limitations
671
688
 
672
- - Payload is limited to 8KB by Postgres. For large rows, send the row ID in the notification and let the client fetch the full row.
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.
673
690
  - Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
674
691
  - This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
675
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
+
676
697
  ---
677
698
 
678
699
  **Observability**
@@ -812,6 +833,50 @@ const metrics = createMetrics({
812
833
 
813
834
  **Reliability**
814
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
+
815
880
  ## Circuit breaker
816
881
 
817
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.
@@ -914,6 +979,57 @@ npm test
914
979
 
915
980
  Tests use in-memory mocks for Redis and Postgres, no running services needed.
916
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
+
917
1033
  ---
918
1034
 
919
1035
  ## Related projects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Redis and Postgres extensions for svelte-adapter-uws - distributed pub/sub, replay buffers, presence tracking, rate limiting, groups, and DB change notifications",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
@@ -61,6 +61,10 @@
61
61
  "./breaker": {
62
62
  "types": "./shared/breaker.d.ts",
63
63
  "default": "./shared/breaker.js"
64
+ },
65
+ "./testing": {
66
+ "types": "./testing/index.d.ts",
67
+ "default": "./testing/index.js"
64
68
  }
65
69
  },
66
70
  "files": [
@@ -68,6 +72,7 @@
68
72
  "postgres",
69
73
  "prometheus",
70
74
  "shared",
75
+ "testing",
71
76
  "LICENSE",
72
77
  "README.md"
73
78
  ],
@@ -131,8 +131,11 @@ export function createNotifyBridge(client, options) {
131
131
  } else if (!result && isDefaultParser) {
132
132
  mParseErrors?.inc({ channel });
133
133
  }
134
- } catch {
134
+ } catch (err) {
135
135
  mParseErrors?.inc({ channel });
136
+ if (!isDefaultParser) {
137
+ console.warn(`[postgres/notify] parse error on "${channel}":`, err.message || err);
138
+ }
136
139
  }
137
140
  }
138
141
 
@@ -154,6 +154,11 @@ export function createRateLimit(client, options) {
154
154
 
155
155
  const redis = client.redis;
156
156
 
157
+ // Version prefix for Redis keys. Different script versions use different
158
+ // key spaces so rolling deployments with algorithm changes don't produce
159
+ // inconsistent rate limiting. Old-version keys expire naturally via TTL.
160
+ const SCRIPT_VERSION = 'v1';
161
+
157
162
  const b = options.breaker;
158
163
  const m = options.metrics;
159
164
  const mAllowed = m?.counter('ratelimit_allowed_total', 'Requests allowed');
@@ -190,7 +195,7 @@ export function createRateLimit(client, options) {
190
195
  }
191
196
 
192
197
  function bucketKey(key) {
193
- return client.key('ratelimit:' + key);
198
+ return client.key(SCRIPT_VERSION + ':ratelimit:' + key);
194
199
  }
195
200
 
196
201
  return {
@@ -272,7 +277,7 @@ export function createRateLimit(client, options) {
272
277
  async clear() {
273
278
  if (b) b.guard();
274
279
  try {
275
- const pattern = client.key('ratelimit:*');
280
+ const pattern = client.key(SCRIPT_VERSION + ':ratelimit:*');
276
281
  let cursor = '0';
277
282
  do {
278
283
  const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
package/redis/replay.js CHANGED
@@ -164,7 +164,6 @@ export function createReplay(client, options = {}) {
164
164
  let rawAll;
165
165
  try {
166
166
  rawAll = await redis.zrangebyscore(bufKey(topic), '-inf', '+inf');
167
- b?.success();
168
167
  } catch (err) {
169
168
  b?.failure(err);
170
169
  throw err;
@@ -192,6 +191,21 @@ export function createReplay(client, options = {}) {
192
191
  } catch { /* skip corrupted */ }
193
192
  }
194
193
 
194
+ if (oldestSeq === null && sinceSeq > 0 && missed.length === 0) {
195
+ try {
196
+ const val = await redis.get(seqKey(topic));
197
+ const currentSeq = val ? parseInt(val, 10) : 0;
198
+ if (currentSeq > sinceSeq) {
199
+ mTruncations?.inc({ topic: mt(topic) });
200
+ platform.send(ws, replayTopic, 'truncated', null);
201
+ }
202
+ } catch (err) {
203
+ b?.failure(err);
204
+ throw err;
205
+ }
206
+ }
207
+ b?.success();
208
+
195
209
  for (let i = 0; i < missed.length; i++) {
196
210
  const msg = missed[i];
197
211
  platform.send(ws, replayTopic, 'msg', {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Test utilities for svelte-adapter-uws-extensions.
3
+ *
4
+ * In-memory mocks for Redis, Postgres, Platform, and WebSocket that mirror
5
+ * the real APIs closely enough to test extension-consuming code without
6
+ * running any infrastructure.
7
+ *
8
+ * @module svelte-adapter-uws-extensions/testing
9
+ */
10
+
11
+ import type { RedisClient } from '../redis/index.js';
12
+ import type { PgClient } from '../postgres/index.js';
13
+
14
+ // -- Mock Redis ---------------------------------------------------------------
15
+
16
+ export interface MockRedisClient extends RedisClient {
17
+ /** Direct access to the string store (key -> value). */
18
+ readonly _store: Map<string, string>;
19
+ /** Direct access to sorted sets (key -> Array<{score, member}>). */
20
+ readonly _sortedSets: Map<string, Array<{ score: number; member: string }>>;
21
+ /** Direct access to hashes (key -> Map<field, value>). */
22
+ readonly _hashes: Map<string, Map<string, string>>;
23
+ }
24
+
25
+ /**
26
+ * Create an in-memory Redis client mock.
27
+ * Supports strings, hashes, sorted sets, pub/sub, pipelines, scan,
28
+ * and Lua script evaluation for all extension scripts.
29
+ */
30
+ export function mockRedisClient(keyPrefix?: string): MockRedisClient;
31
+
32
+ // -- Mock Platform ------------------------------------------------------------
33
+
34
+ export interface MockPlatform {
35
+ /** All `publish()` calls recorded as `{ topic, event, data, options }`. */
36
+ published: Array<{ topic: string; event: string; data: any; options?: any }>;
37
+ /** All `send()` calls recorded as `{ ws, topic, event, data }`. */
38
+ sent: Array<{ ws: any; topic: string; event: string; data: any }>;
39
+ connections: number;
40
+ publish(topic: string, event: string, data?: any, options?: any): boolean;
41
+ send(ws: any, topic: string, event: string, data?: any): number;
42
+ batch(messages: Array<{ topic: string; event: string; data?: any }>): boolean[];
43
+ sendTo(filter: any, topic: string, event: string, data?: any): number;
44
+ subscribers(topic: string): number;
45
+ topic(t: string): {
46
+ publish(event: string, data?: any): void;
47
+ created(data?: any): void;
48
+ updated(data?: any): void;
49
+ deleted(data?: any): void;
50
+ set(value?: any): void;
51
+ increment(amount?: number): void;
52
+ decrement(amount?: number): void;
53
+ };
54
+ /** Clear all recorded publish/send calls. */
55
+ reset(): void;
56
+ }
57
+
58
+ /**
59
+ * Create an in-memory Platform mock that records all publish/send calls.
60
+ */
61
+ export function mockPlatform(): MockPlatform;
62
+
63
+ // -- Mock WebSocket -----------------------------------------------------------
64
+
65
+ export interface MockWs<T extends Record<string, any> = Record<string, any>> {
66
+ getUserData(): T;
67
+ subscribe(topic: string): boolean;
68
+ unsubscribe(topic: string): boolean;
69
+ isSubscribed(topic: string): boolean;
70
+ getBufferedAmount(): number;
71
+ /** Simulate socket close. After this, subscribe/unsubscribe/getBufferedAmount throw. */
72
+ close(): void;
73
+ /** Whether close() has been called. */
74
+ readonly _closed: boolean;
75
+ /** Currently subscribed topics. */
76
+ readonly _topics: Set<string>;
77
+ }
78
+
79
+ /**
80
+ * Create an in-memory WebSocket mock. Throws after close(), matching uWS behavior.
81
+ */
82
+ export function mockWs<T extends Record<string, any> = Record<string, any>>(userData?: T): MockWs<T>;
83
+
84
+ // -- Mock Postgres ------------------------------------------------------------
85
+
86
+ export interface MockPgClient {
87
+ readonly pool: {};
88
+ query(textOrObj: string | { text: string; values?: any[] }, values?: any[]): Promise<{ rows: any[]; rowCount: number }>;
89
+ end(): Promise<void>;
90
+ /** Get all stored rows (Postgres replay mock). */
91
+ _getRows(): any[];
92
+ /** Get sequence counters by topic (Postgres replay mock). */
93
+ _getSeqCounters(): Map<string, number>;
94
+ /** Reset all state. */
95
+ _reset(): void;
96
+ }
97
+
98
+ /**
99
+ * Create an in-memory Postgres client mock.
100
+ * Parses SQL to simulate the replay buffer table operations.
101
+ */
102
+ export function mockPgClient(): MockPgClient;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Test utilities for svelte-adapter-uws-extensions.
3
+ *
4
+ * In-memory mocks for Redis, Postgres, Platform, and WebSocket that mirror
5
+ * the real APIs closely enough to test extension-consuming code without
6
+ * running any infrastructure.
7
+ *
8
+ * @module svelte-adapter-uws-extensions/testing
9
+ */
10
+
11
+ export { mockRedisClient } from './mock-redis.js';
12
+ export { mockPlatform } from './mock-platform.js';
13
+ export { mockWs } from './mock-ws.js';
14
+ export { mockPgClient } from './mock-pg.js';
@@ -0,0 +1,214 @@
1
+ /**
2
+ * In-memory mock that implements the PgClient interface.
3
+ * Parses SQL enough to simulate the ws_replay table operations
4
+ * and the ws_replay_seq table for atomic sequence generation.
5
+ */
6
+ export function mockPgClient() {
7
+ /** @type {Array<{ws_replay_id: number, topic: string, seq: number, event: string, data: any, created_date: Date}>} */
8
+ let rows = [];
9
+ let nextId = 1;
10
+ let tableCreated = false;
11
+
12
+ /** @type {Map<string, number>} topic -> seq */
13
+ const seqCounters = new Map();
14
+
15
+ return {
16
+ pool: {},
17
+
18
+ async query(textOrObj, values) {
19
+ if (typeof textOrObj === 'object' && textOrObj !== null) {
20
+ values = textOrObj.values || [];
21
+ textOrObj = textOrObj.text;
22
+ }
23
+ if (!values) values = [];
24
+ const sql = textOrObj.trim().replace(/\s+/g, ' ');
25
+
26
+ // CREATE TABLE
27
+ if (sql.startsWith('CREATE TABLE')) {
28
+ tableCreated = true;
29
+ return { rows: [], rowCount: 0 };
30
+ }
31
+
32
+ // CREATE INDEX
33
+ if (sql.startsWith('CREATE INDEX')) {
34
+ return { rows: [], rowCount: 0 };
35
+ }
36
+
37
+ // CTE publish: atomic seq increment + insert in one query
38
+ if (sql.includes('WITH new_seq') && sql.includes('ON CONFLICT') && sql.includes('RETURNING seq')) {
39
+ const topic = values[0];
40
+ const current = seqCounters.get(topic) || 0;
41
+ const next = current + 1;
42
+ seqCounters.set(topic, next);
43
+ const row = {
44
+ ws_replay_id: nextId++,
45
+ topic,
46
+ seq: next,
47
+ event: values[1],
48
+ data: typeof values[2] === 'string' ? JSON.parse(values[2]) : values[2],
49
+ created_date: new Date()
50
+ };
51
+ rows.push(row);
52
+ return { rows: [{ seq: String(next) }], rowCount: 1 };
53
+ }
54
+
55
+ // INSERT INTO *_seq (atomic sequence generation)
56
+ if (sql.includes('ON CONFLICT') && sql.includes('RETURNING seq')) {
57
+ const topic = values[0];
58
+ const current = seqCounters.get(topic) || 0;
59
+ const next = current + 1;
60
+ seqCounters.set(topic, next);
61
+ return { rows: [{ seq: String(next) }], rowCount: 1 };
62
+ }
63
+
64
+ // INSERT
65
+ if (sql.startsWith('INSERT INTO')) {
66
+ const row = {
67
+ ws_replay_id: nextId++,
68
+ topic: values[0],
69
+ seq: parseInt(values[1], 10),
70
+ event: values[2],
71
+ data: typeof values[3] === 'string' ? JSON.parse(values[3]) : values[3],
72
+ created_date: new Date()
73
+ };
74
+ rows.push(row);
75
+ return { rows: [row], rowCount: 1 };
76
+ }
77
+
78
+ // SELECT COALESCE(seq, 0) FROM _seq table
79
+ if (sql.includes('current_seq') && sql.includes('_seq')) {
80
+ const topic = values[0];
81
+ const seq = seqCounters.get(topic);
82
+ if (seq !== undefined) {
83
+ return { rows: [{ current_seq: String(seq) }], rowCount: 1 };
84
+ }
85
+ return { rows: [], rowCount: 0 };
86
+ }
87
+
88
+ // SELECT COALESCE(MAX(seq)
89
+ if (sql.includes('MAX(seq)')) {
90
+ const topic = values[0];
91
+ const topicRows = rows.filter((r) => r.topic === topic);
92
+ const maxSeq = topicRows.reduce((max, r) => Math.max(max, r.seq), 0);
93
+ return { rows: [{ max_seq: String(maxSeq) }], rowCount: 1 };
94
+ }
95
+
96
+ // SELECT COUNT
97
+ if (sql.includes('COUNT(*)')) {
98
+ const topic = values[0];
99
+ const message_count = rows.filter((r) => r.topic === topic).length;
100
+ return { rows: [{ message_count }], rowCount: 1 };
101
+ }
102
+
103
+ // SELECT seq, topic, event, data ... WHERE topic = $1 AND seq > $2
104
+ if (sql.includes('SELECT seq, topic, event, data')) {
105
+ const topic = values[0];
106
+ const since = parseInt(values[1], 10);
107
+ const result = rows
108
+ .filter((r) => r.topic === topic && r.seq > since)
109
+ .sort((a, b) => a.seq - b.seq)
110
+ .map((r) => ({
111
+ seq: String(r.seq),
112
+ topic: r.topic,
113
+ event: r.event,
114
+ data: r.data
115
+ }));
116
+ return { rows: result, rowCount: result.length };
117
+ }
118
+
119
+ // Seq-based inline trim: DELETE WHERE topic = $1 AND seq <= $2
120
+ if (sql.includes('DELETE FROM') && sql.includes('seq <=') && !sql.includes('OFFSET') && !sql.includes('cutoff_seq')) {
121
+ const topic = values[0];
122
+ const cutoffSeq = parseInt(values[1], 10);
123
+ const before = rows.length;
124
+ rows = rows.filter((r) => r.topic !== topic || r.seq > cutoffSeq);
125
+ return { rows: [], rowCount: before - rows.length };
126
+ }
127
+
128
+ // Range-based inline trim: DELETE WHERE topic = $1 AND seq <= (SELECT ... OFFSET $2 LIMIT 1)
129
+ if (sql.includes('DELETE FROM') && sql.includes('seq <=') && sql.includes('OFFSET')) {
130
+ const topic = values[0];
131
+ const offset = parseInt(values[1], 10);
132
+ const topicRows = rows
133
+ .filter((r) => r.topic === topic)
134
+ .sort((a, b) => b.seq - a.seq);
135
+ if (offset < topicRows.length) {
136
+ const cutoffSeq = topicRows[offset].seq;
137
+ const before = rows.length;
138
+ rows = rows.filter((r) => r.topic !== topic || r.seq > cutoffSeq);
139
+ return { rows: [], rowCount: before - rows.length };
140
+ }
141
+ return { rows: [], rowCount: 0 };
142
+ }
143
+
144
+ // Range-based periodic cleanup: DELETE using OFFSET-based cutoff per topic
145
+ if (sql.includes('DELETE FROM') && sql.includes('cutoff_seq') && sql.includes('DISTINCT topic')) {
146
+ const offset = parseInt(values[0], 10);
147
+ const topics = [...new Set(rows.map((r) => r.topic))];
148
+ let totalRemoved = 0;
149
+ for (const topic of topics) {
150
+ const topicRows = rows
151
+ .filter((r) => r.topic === topic)
152
+ .sort((a, b) => b.seq - a.seq);
153
+ if (offset < topicRows.length) {
154
+ const cutoffSeq = topicRows[offset].seq;
155
+ const before = rows.length;
156
+ rows = rows.filter((r) => r.topic !== topic || r.seq > cutoffSeq);
157
+ totalRemoved += before - rows.length;
158
+ }
159
+ }
160
+ return { rows: [], rowCount: totalRemoved };
161
+ }
162
+
163
+ // DELETE FROM table WHERE topic = $1 AND ws_replay_id NOT IN (... LIMIT $2)
164
+ if (sql.includes('DELETE FROM') && sql.includes('NOT IN') && sql.includes('LIMIT')) {
165
+ const topic = values[0];
166
+ const limit = parseInt(values[1], 10);
167
+ const topicRows = rows
168
+ .filter((r) => r.topic === topic)
169
+ .sort((a, b) => b.seq - a.seq);
170
+ const keepIds = new Set(topicRows.slice(0, limit).map((r) => r.ws_replay_id));
171
+ const before = rows.length;
172
+ rows = rows.filter((r) => r.topic !== topic || keepIds.has(r.ws_replay_id));
173
+ return { rows: [], rowCount: before - rows.length };
174
+ }
175
+
176
+ // DELETE FROM *_seq WHERE topic = $1
177
+ if (sql.includes('DELETE FROM') && sql.includes('_seq') && sql.includes('WHERE topic')) {
178
+ const topic = values[0];
179
+ seqCounters.delete(topic);
180
+ return { rows: [], rowCount: 1 };
181
+ }
182
+
183
+ // DELETE FROM table WHERE topic = $1
184
+ if (sql.includes('DELETE FROM') && sql.includes('WHERE topic')) {
185
+ const topic = values[0];
186
+ const before = rows.length;
187
+ rows = rows.filter((r) => r.topic !== topic);
188
+ return { rows: [], rowCount: before - rows.length };
189
+ }
190
+
191
+ // DELETE FROM *_seq (clear all sequences)
192
+ if (sql.includes('DELETE FROM') && sql.includes('_seq')) {
193
+ seqCounters.clear();
194
+ return { rows: [], rowCount: 0 };
195
+ }
196
+
197
+ // DELETE FROM table (clear all)
198
+ if (sql.startsWith('DELETE FROM')) {
199
+ const before = rows.length;
200
+ rows = [];
201
+ return { rows: [], rowCount: before };
202
+ }
203
+
204
+ return { rows: [], rowCount: 0 };
205
+ },
206
+
207
+ async end() {},
208
+
209
+ // Test helpers
210
+ _getRows() { return rows; },
211
+ _getSeqCounters() { return seqCounters; },
212
+ _reset() { rows = []; nextId = 1; tableCreated = false; seqCounters.clear(); }
213
+ };
214
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Create a mock platform that records publish/send calls.
3
+ * Matches the core svelte-adapter-uws Platform interface.
4
+ */
5
+ export function mockPlatform() {
6
+ const p = {
7
+ published: [],
8
+ sent: [],
9
+ connections: 0,
10
+ publish(topic, event, data, options) {
11
+ p.published.push({ topic, event, data, options });
12
+ return true;
13
+ },
14
+ send(ws, topic, event, data) {
15
+ p.sent.push({ ws, topic, event, data });
16
+ return 1;
17
+ },
18
+ batch(messages) {
19
+ return messages.map((m) => p.publish(m.topic, m.event, m.data));
20
+ },
21
+ sendTo(filter, topic, event, data) {
22
+ return 0;
23
+ },
24
+ subscribers(topic) {
25
+ return 0;
26
+ },
27
+ topic(t) {
28
+ return {
29
+ publish(event, data) { p.publish(t, event, data); },
30
+ created(data) { p.publish(t, 'created', data); },
31
+ updated(data) { p.publish(t, 'updated', data); },
32
+ deleted(data) { p.publish(t, 'deleted', data); },
33
+ set(value) { p.publish(t, 'set', value); },
34
+ increment(amount) { p.publish(t, 'increment', amount); },
35
+ decrement(amount) { p.publish(t, 'decrement', amount); }
36
+ };
37
+ },
38
+ reset() {
39
+ p.published.length = 0;
40
+ p.sent.length = 0;
41
+ }
42
+ };
43
+ return p;
44
+ }
@@ -0,0 +1,561 @@
1
+ /**
2
+ * In-memory mock that implements the subset of ioredis used by the extensions.
3
+ * No real Redis connection needed.
4
+ */
5
+ export function mockRedisClient(keyPrefix = '') {
6
+ const store = new Map(); // key -> value (string)
7
+ const sortedSets = new Map(); // key -> [{score, member}]
8
+ const hashes = new Map(); // key -> Map<field, value>
9
+ const pubsubHandlers = []; // {channel, handler}
10
+
11
+ function mockRedis() {
12
+ const listeners = new Map();
13
+ const subscribedChannels = new Set();
14
+
15
+ const r = {
16
+ // String ops
17
+ async get(key) { return store.get(key) || null; },
18
+ async set(key, val) { store.set(key, String(val)); return 'OK'; },
19
+ async incr(key) {
20
+ const v = parseInt(store.get(key) || '0', 10) + 1;
21
+ store.set(key, String(v));
22
+ return v;
23
+ },
24
+ async del(...keys) {
25
+ let count = 0;
26
+ for (const k of keys) {
27
+ if (store.delete(k)) count++;
28
+ if (sortedSets.delete(k)) count++;
29
+ if (hashes.delete(k)) count++;
30
+ }
31
+ return count;
32
+ },
33
+ async unlink(...keys) {
34
+ return r.del(...keys);
35
+ },
36
+ async expire() { return 1; },
37
+ async pexpire() { return 1; },
38
+
39
+ // Sorted set ops
40
+ async zadd(key, score, member) {
41
+ if (!sortedSets.has(key)) sortedSets.set(key, []);
42
+ const set = sortedSets.get(key);
43
+ set.push({ score: Number(score), member });
44
+ set.sort((a, b) => a.score - b.score);
45
+ return 1;
46
+ },
47
+ async zcard(key) {
48
+ const set = sortedSets.get(key);
49
+ return set ? set.length : 0;
50
+ },
51
+ async zrangebyscore(key, min, max, ...extra) {
52
+ const set = sortedSets.get(key);
53
+ if (!set) return [];
54
+ const lo = min === '-inf' ? -Infinity : Number(min);
55
+ const hi = max === '+inf' ? Infinity : Number(max);
56
+ let result = set.filter((e) => e.score >= lo && e.score <= hi).map((e) => e.member);
57
+ const limitIdx = extra.indexOf('LIMIT');
58
+ if (limitIdx !== -1) {
59
+ const offset = Number(extra[limitIdx + 1]);
60
+ const count = Number(extra[limitIdx + 2]);
61
+ result = result.slice(offset, offset + count);
62
+ }
63
+ return result;
64
+ },
65
+ async zremrangebyrank(key, start, stop) {
66
+ const set = sortedSets.get(key);
67
+ if (!set) return 0;
68
+ const removed = set.splice(start, stop - start + 1);
69
+ return removed.length;
70
+ },
71
+
72
+ // Hash ops
73
+ async hset(key, field, value) {
74
+ if (!hashes.has(key)) hashes.set(key, new Map());
75
+ hashes.get(key).set(String(field), String(value));
76
+ return 1;
77
+ },
78
+ async hmset(key, ...args) {
79
+ if (!hashes.has(key)) hashes.set(key, new Map());
80
+ const h = hashes.get(key);
81
+ // hmset(key, field, value, field, value, ...)
82
+ // or hmset(key, { field: value, ... })
83
+ if (typeof args[0] === 'object' && args[0] !== null) {
84
+ for (const [f, v] of Object.entries(args[0])) {
85
+ h.set(String(f), String(v));
86
+ }
87
+ } else {
88
+ for (let i = 0; i < args.length; i += 2) {
89
+ h.set(String(args[i]), String(args[i + 1]));
90
+ }
91
+ }
92
+ return 'OK';
93
+ },
94
+ async hget(key, field) {
95
+ const h = hashes.get(key);
96
+ return h ? (h.get(field) || null) : null;
97
+ },
98
+ async hmget(key, ...fields) {
99
+ const h = hashes.get(key);
100
+ return fields.map((f) => (h ? (h.get(String(f)) ?? null) : null));
101
+ },
102
+ async hgetall(key) {
103
+ const h = hashes.get(key);
104
+ if (!h) return {};
105
+ const result = {};
106
+ for (const [k, v] of h) result[k] = v;
107
+ return result;
108
+ },
109
+ async hdel(key, ...fields) {
110
+ const h = hashes.get(key);
111
+ if (!h) return 0;
112
+ let count = 0;
113
+ for (const f of fields) {
114
+ if (h.delete(f)) count++;
115
+ }
116
+ if (h.size === 0) hashes.delete(key);
117
+ return count;
118
+ },
119
+ async hlen(key) {
120
+ const h = hashes.get(key);
121
+ return h ? h.size : 0;
122
+ },
123
+ async hkeys(key) {
124
+ const h = hashes.get(key);
125
+ return h ? [...h.keys()] : [];
126
+ },
127
+
128
+ // Pub/sub
129
+ async publish(channel, message) {
130
+ for (const handler of pubsubHandlers) {
131
+ if (handler.channels.has(channel)) {
132
+ const msgListener = handler.listeners.get('message');
133
+ if (msgListener) msgListener(channel, message);
134
+ }
135
+ }
136
+ return 1;
137
+ },
138
+ async subscribe(channel) {
139
+ subscribedChannels.add(channel);
140
+ return 1;
141
+ },
142
+ async unsubscribe(channel) {
143
+ subscribedChannels.delete(channel);
144
+ return 1;
145
+ },
146
+
147
+ // Eval - dispatches based on script content
148
+ async eval(script, numKeys, ...args) {
149
+ // Ban script (atomic ban with Redis TIME)
150
+ if (script.includes('defaultPoints') && script.includes('defaultInterval')) {
151
+ return evalBanScript(args);
152
+ }
153
+ // Rate limit script (token bucket)
154
+ if (script.includes('bannedUntil')) {
155
+ return evalRateLimit(args);
156
+ }
157
+ // Replay publish script (atomic incr + zadd + trim)
158
+ if (script.includes('zremrangebyrank') && script.includes('cjson.encode')) {
159
+ return evalReplayPublish(numKeys, args);
160
+ }
161
+ // Presence join script (hset + expire, no dedup scan)
162
+ if (script.includes('hset') && script.includes('expire') && !script.includes('hdel') && !script.includes('suffix')) {
163
+ return evalPresenceJoin(args);
164
+ }
165
+ // Presence leave script (hdel + check remaining by suffix)
166
+ if (script.includes('hdel') && script.includes('suffix')) {
167
+ return evalPresenceLeave(args);
168
+ }
169
+ // Stale field cleanup script (server-side HGETALL + HDEL)
170
+ if (script.includes('CLEANUP_STALE')) {
171
+ return evalCleanupStale(args);
172
+ }
173
+ // Count dedup script (presence: deduplicated by userKey via | separator)
174
+ if (script.includes('seen[userKey] = true') && script.includes('pairs(seen)')) {
175
+ return evalCountDedupScript(args);
176
+ }
177
+ // Count script (server-side live entry count)
178
+ if (script.includes('count = count + 1') && !script.includes('hdel') && !script.includes('hset')) {
179
+ return evalCountScript(args);
180
+ }
181
+ // List script (server-side presence list with dedup)
182
+ if (script.includes('seen[userKey]') && script.includes('best[userKey]')) {
183
+ return evalListScript(args);
184
+ }
185
+ // Group join script (atomic capacity check + insert)
186
+ if (script.includes('cjson.decode') && script.includes('liveCount')) {
187
+ return evalGroupJoin(numKeys, args);
188
+ }
189
+ throw new Error('mock-redis: unrecognized eval script');
190
+ },
191
+
192
+ // Scan
193
+ async scan(cursor, ...args) {
194
+ // Simple mock: return all matching keys in one go
195
+ const matchIdx = args.indexOf('MATCH');
196
+ const pattern = matchIdx !== -1 ? args[matchIdx + 1] : '*';
197
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
198
+
199
+ const allKeys = [...store.keys(), ...sortedSets.keys(), ...hashes.keys()];
200
+ const matched = allKeys.filter((k) => regex.test(k));
201
+ return ['0', matched];
202
+ },
203
+
204
+ // Pipeline support for batched commands
205
+ pipeline() {
206
+ const commands = [];
207
+ const p = new Proxy({}, {
208
+ get(_, method) {
209
+ if (method === 'exec') {
210
+ return async () => {
211
+ const results = [];
212
+ for (const { method: m, args } of commands) {
213
+ try {
214
+ const result = await r[m](...args);
215
+ results.push([null, result]);
216
+ } catch (err) {
217
+ results.push([err, null]);
218
+ }
219
+ }
220
+ return results;
221
+ };
222
+ }
223
+ return (...args) => {
224
+ commands.push({ method, args });
225
+ return p;
226
+ };
227
+ }
228
+ });
229
+ return p;
230
+ },
231
+
232
+ // Lifecycle
233
+ duplicate(/* overrides */) {
234
+ const dup = mockRedis();
235
+ // Register this duplicate as a pub/sub receiver
236
+ pubsubHandlers.push({
237
+ channels: dup._subscribedChannels,
238
+ listeners: dup._listeners
239
+ });
240
+ return dup;
241
+ },
242
+ async quit() {},
243
+ disconnect() {},
244
+
245
+ // Event handling
246
+ on(event, fn) {
247
+ listeners.set(event, fn);
248
+ return r;
249
+ },
250
+
251
+ defineCommand(name, { lua }) {
252
+ r[name] = async (numKeys, ...args) => r.eval(lua, numKeys, ...args);
253
+ },
254
+
255
+ _subscribedChannels: subscribedChannels,
256
+ _listeners: listeners
257
+ };
258
+
259
+ // Rate limit Lua script simulation
260
+ function evalRateLimit(args) {
261
+ const key = args[0];
262
+ const maxPoints = Number(args[1]);
263
+ const interval = Number(args[2]);
264
+ const cost = Number(args[3]);
265
+ const blockDuration = Number(args[4]);
266
+ const now = Date.now(); // Simulates Redis TIME command
267
+
268
+ if (!hashes.has(key)) hashes.set(key, new Map());
269
+ const h = hashes.get(key);
270
+
271
+ let pts = h.has('points') ? Number(h.get('points')) : null;
272
+ let resetAt = h.has('resetAt') ? Number(h.get('resetAt')) : null;
273
+ let bannedUntil = h.has('bannedUntil') ? Number(h.get('bannedUntil')) : null;
274
+
275
+ if (pts === null) {
276
+ pts = maxPoints;
277
+ resetAt = now + interval;
278
+ bannedUntil = 0;
279
+ }
280
+
281
+ if (bannedUntil > now) {
282
+ return [0, 0, bannedUntil - now];
283
+ }
284
+
285
+ if (resetAt <= now) {
286
+ pts = maxPoints;
287
+ resetAt = now + interval;
288
+ }
289
+
290
+ if (pts >= cost) {
291
+ pts -= cost;
292
+ h.set('points', String(pts));
293
+ h.set('resetAt', String(resetAt));
294
+ h.set('bannedUntil', String(bannedUntil));
295
+ return [1, pts, resetAt - now];
296
+ }
297
+
298
+ if (blockDuration > 0) {
299
+ bannedUntil = now + blockDuration;
300
+ h.set('points', String(pts));
301
+ h.set('resetAt', String(resetAt));
302
+ h.set('bannedUntil', String(bannedUntil));
303
+ return [0, 0, blockDuration];
304
+ }
305
+
306
+ h.set('points', String(pts));
307
+ h.set('resetAt', String(resetAt));
308
+ h.set('bannedUntil', String(bannedUntil));
309
+ return [0, Math.max(0, pts), resetAt - now];
310
+ }
311
+
312
+ // Ban Lua script simulation
313
+ function evalBanScript(args) {
314
+ const key = args[0];
315
+ const duration = Number(args[1]);
316
+ const defaultPoints = Number(args[2]);
317
+ const defaultInterval = Number(args[3]);
318
+ const now = Date.now();
319
+
320
+ if (!hashes.has(key)) hashes.set(key, new Map());
321
+ const h = hashes.get(key);
322
+
323
+ const pts = h.get('points') ?? String(defaultPoints);
324
+ const rst = h.get('resetAt') ?? String(now + defaultInterval);
325
+
326
+ h.set('points', String(pts));
327
+ h.set('resetAt', String(rst));
328
+ h.set('bannedUntil', String(now + duration));
329
+ return 1;
330
+ }
331
+
332
+ // Presence join Lua script simulation
333
+ // HSET + EXPIRE, always returns 1. Cross-instance dedup was removed
334
+ // from the real Lua script (O(N) scan per join was the bottleneck).
335
+ function evalPresenceJoin(args) {
336
+ const key = args[0];
337
+ const field = args[1];
338
+ const value = args[2];
339
+ // args[3] = ttlSec (for EXPIRE, no-op in mock)
340
+
341
+ if (!hashes.has(key)) hashes.set(key, new Map());
342
+ hashes.get(key).set(field, value);
343
+
344
+ return 1;
345
+ }
346
+
347
+ // Presence leave Lua script simulation
348
+ function evalPresenceLeave(args) {
349
+ const key = args[0];
350
+ const field = args[1];
351
+ const suffix = args[2];
352
+ const now = Number(args[3]);
353
+ const ttlMs = Number(args[4]);
354
+
355
+ // hdel
356
+ const h = hashes.get(key);
357
+ if (h) {
358
+ h.delete(field);
359
+ if (h.size === 0) hashes.delete(key);
360
+ }
361
+
362
+ // Check remaining fields for suffix match, ignoring stale entries
363
+ const remaining = hashes.get(key);
364
+ if (remaining) {
365
+ for (const [f, v] of remaining) {
366
+ if (f.length >= suffix.length && f.slice(-suffix.length) === suffix) {
367
+ try {
368
+ const parsed = JSON.parse(v);
369
+ if (parsed.ts && (now - parsed.ts) <= ttlMs) {
370
+ return 0; // User still present on another live instance
371
+ }
372
+ } catch { /* skip */ }
373
+ }
374
+ }
375
+ }
376
+ return 1; // User is gone
377
+ }
378
+
379
+ // Group join Lua script simulation (2 keys: members, closed)
380
+ function evalGroupJoin(numKeys, args) {
381
+ const key = args[0];
382
+ const closedFlag = numKeys >= 2 ? args[1] : null;
383
+ const argOffset = numKeys;
384
+ const maxMembers = Number(args[argOffset]);
385
+ const memberId = args[argOffset + 1];
386
+ const memberData = args[argOffset + 2];
387
+ const now = Number(args[argOffset + 3]);
388
+ const memberTtlMs = Number(args[argOffset + 4]);
389
+
390
+ if (closedFlag && store.get(closedFlag) === '1') {
391
+ return [-1];
392
+ }
393
+
394
+ if (!hashes.has(key)) hashes.set(key, new Map());
395
+ const h = hashes.get(key);
396
+
397
+ let liveCount = 0;
398
+ const toRemove = [];
399
+ const live = [];
400
+ for (const [f, v] of h) {
401
+ try {
402
+ const val = JSON.parse(v);
403
+ if (val.ts && (now - val.ts) <= memberTtlMs) {
404
+ liveCount++;
405
+ live.push(v);
406
+ } else {
407
+ toRemove.push(f);
408
+ }
409
+ } catch {
410
+ toRemove.push(f);
411
+ }
412
+ }
413
+ for (const f of toRemove) h.delete(f);
414
+
415
+ if (liveCount >= maxMembers) {
416
+ return [0];
417
+ }
418
+ h.set(memberId, memberData);
419
+ live.push(memberData);
420
+ return [1, ...live];
421
+ }
422
+
423
+ // Replay publish Lua script simulation
424
+ // args layout: [seqKey, bufKey, topic, event, dataJson, maxSize, ttl]
425
+ function evalReplayPublish(numKeys, args) {
426
+ const seqKey = args[0];
427
+ const bufKey = args[1];
428
+ const topic = args[2];
429
+ const event = args[3];
430
+ const dataJson = args[4];
431
+ const maxSize = Number(args[5]);
432
+
433
+ // Increment seq
434
+ const v = parseInt(store.get(seqKey) || '0', 10) + 1;
435
+ store.set(seqKey, String(v));
436
+ const seq = v;
437
+
438
+ // zadd
439
+ const data = JSON.parse(dataJson);
440
+ const payload = JSON.stringify({ seq, topic, event, data });
441
+ if (!sortedSets.has(bufKey)) sortedSets.set(bufKey, []);
442
+ const set = sortedSets.get(bufKey);
443
+ set.push({ score: seq, member: payload });
444
+ set.sort((a, b) => a.score - b.score);
445
+
446
+ // Trim
447
+ if (set.length > maxSize) {
448
+ set.splice(0, set.length - maxSize);
449
+ }
450
+
451
+ return seq;
452
+ }
453
+
454
+ // Count dedup Lua script simulation (presence: deduplicated by userKey)
455
+ function evalCountDedupScript(args) {
456
+ const key = args[0];
457
+ const now = Number(args[1]);
458
+ const ttlMs = Number(args[2]);
459
+ const h = hashes.get(key);
460
+ if (!h) return 0;
461
+ const seen = new Set();
462
+ for (const [field, v] of h) {
463
+ try {
464
+ const parsed = JSON.parse(v);
465
+ if (parsed.ts && (now - parsed.ts) <= ttlMs) {
466
+ const sep = field.indexOf('|');
467
+ const userKey = sep !== -1 ? field.slice(sep + 1) : field;
468
+ seen.add(userKey);
469
+ }
470
+ } catch { /* skip */ }
471
+ }
472
+ return seen.size;
473
+ }
474
+
475
+ // Count Lua script simulation
476
+ function evalCountScript(args) {
477
+ const key = args[0];
478
+ const now = Number(args[1]);
479
+ const ttlMs = Number(args[2]);
480
+ const h = hashes.get(key);
481
+ if (!h) return 0;
482
+ let count = 0;
483
+ for (const [, v] of h) {
484
+ try {
485
+ const parsed = JSON.parse(v);
486
+ if (parsed.ts && (now - parsed.ts) <= ttlMs) count++;
487
+ } catch { /* skip */ }
488
+ }
489
+ return count;
490
+ }
491
+
492
+ // List Lua script simulation (presence list with per-user dedup)
493
+ function evalListScript(args) {
494
+ const key = args[0];
495
+ const now = Number(args[1]);
496
+ const ttlMs = Number(args[2]);
497
+ const h = hashes.get(key);
498
+ if (!h) return [];
499
+ const seen = new Map();
500
+ for (const [field, v] of h) {
501
+ try {
502
+ const parsed = JSON.parse(v);
503
+ if (parsed.ts && (now - parsed.ts) <= ttlMs) {
504
+ const sep = field.indexOf('|');
505
+ const userKey = sep !== -1 ? field.slice(sep + 1) : field;
506
+ const existing = seen.get(userKey);
507
+ if (!existing || parsed.ts > existing.ts) {
508
+ seen.set(userKey, { ts: parsed.ts, json: v });
509
+ }
510
+ }
511
+ } catch { /* skip */ }
512
+ }
513
+ const out = [];
514
+ for (const [k, v] of seen) {
515
+ out.push(k, v.json);
516
+ }
517
+ return out;
518
+ }
519
+
520
+ // Stale field cleanup Lua script simulation
521
+ function evalCleanupStale(args) {
522
+ const key = args[0];
523
+ const now = Number(args[1]);
524
+ const ttlMs = Number(args[2]);
525
+
526
+ const h = hashes.get(key);
527
+ if (!h) return 0;
528
+
529
+ const toRemove = [];
530
+ for (const [f, v] of h) {
531
+ try {
532
+ const parsed = JSON.parse(v);
533
+ if (!parsed.ts || (now - parsed.ts) > ttlMs) {
534
+ toRemove.push(f);
535
+ }
536
+ } catch {
537
+ toRemove.push(f);
538
+ }
539
+ }
540
+ for (const f of toRemove) h.delete(f);
541
+ if (h.size === 0) hashes.delete(key);
542
+ return toRemove.length;
543
+ }
544
+
545
+ return r;
546
+ }
547
+
548
+ const redis = mockRedis();
549
+
550
+ return {
551
+ redis,
552
+ keyPrefix,
553
+ key(k) { return keyPrefix + k; },
554
+ duplicate(overrides) { return redis.duplicate(overrides); },
555
+ async quit() {},
556
+ // Test helpers
557
+ _store: store,
558
+ _sortedSets: sortedSets,
559
+ _hashes: hashes
560
+ };
561
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Create a mock WebSocket that mimics the uWS/vite wrapper API.
3
+ * Call ws.close() to simulate a dead socket whose close handler never fired.
4
+ * After close(), subscribe/unsubscribe/getBufferedAmount throw, matching uWS.
5
+ * @param {Record<string, any>} [userData]
6
+ */
7
+ export function mockWs(userData = {}) {
8
+ const topics = new Set();
9
+ let closed = false;
10
+ function assertOpen() {
11
+ if (closed) throw new Error('WebSocket is closed');
12
+ }
13
+ return {
14
+ getUserData: () => userData,
15
+ subscribe: (topic) => { assertOpen(); topics.add(topic); return true; },
16
+ unsubscribe: (topic) => { assertOpen(); topics.delete(topic); return true; },
17
+ isSubscribed: (topic) => topics.has(topic),
18
+ getBufferedAmount: () => { assertOpen(); return 0; },
19
+ close: () => { closed = true; },
20
+ get _closed() { return closed; },
21
+ _topics: topics
22
+ };
23
+ }