svelte-adapter-uws-extensions 0.4.1 → 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 +77 -0
- package/package.json +6 -1
- package/postgres/notify.js +4 -1
- package/redis/ratelimit.js +7 -2
- package/testing/index.d.ts +102 -0
- package/testing/index.js +14 -0
- package/testing/mock-pg.js +214 -0
- package/testing/mock-platform.js +44 -0
- package/testing/mock-redis.js +561 -0
- package/testing/mock-ws.js +23 -0
package/README.md
CHANGED
|
@@ -849,6 +849,32 @@ Every Redis and Postgres extension accepts an optional `breaker` option -- a sha
|
|
|
849
849
|
|
|
850
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
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
|
+
|
|
852
878
|
---
|
|
853
879
|
|
|
854
880
|
## Circuit breaker
|
|
@@ -953,6 +979,57 @@ npm test
|
|
|
953
979
|
|
|
954
980
|
Tests use in-memory mocks for Redis and Postgres, no running services needed.
|
|
955
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
|
+
|
|
956
1033
|
---
|
|
957
1034
|
|
|
958
1035
|
## Related projects
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.4.
|
|
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
|
],
|
package/postgres/notify.js
CHANGED
|
@@ -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
|
|
package/redis/ratelimit.js
CHANGED
|
@@ -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);
|
|
@@ -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;
|
package/testing/index.js
ADDED
|
@@ -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
|
+
}
|